Compare commits
105 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4d819b72d | |||
| 0cd87e0597 | |||
| 4b7b2d9c48 | |||
| 69a4654dd7 | |||
| 97867dbf37 | |||
| 239e88cecb | |||
| 2975b0563f | |||
| eb0b3fd29d | |||
| f4bf3ae2cb | |||
| b58393099a | |||
| 1d63492617 | |||
| 96e32df87b | |||
| 1bdaefc268 | |||
| e8afb148d3 | |||
| 3cf5c714a2 | |||
| 7e94910566 | |||
| dd9db60fc9 | |||
| bfd577aeee | |||
| 346dfac6fa | |||
| 18af5a24e9 | |||
| 4ad1875b31 | |||
| 7f097d524f | |||
| a1e786ba4f | |||
| be1056aac0 | |||
| 0f9e319c85 | |||
| 45efed3dbf | |||
| 6bb08cc84d | |||
| cf75249b5e | |||
| ad1eceb76a | |||
| 2082b7d112 | |||
| 27988801be | |||
| c2e3a56a20 | |||
| fa1ddd963a | |||
| d2c45a3bb8 | |||
| 8f7e196046 | |||
| 42e48514e4 | |||
| ba9f82f952 | |||
| a371832688 | |||
| a17823a9af | |||
| d664b70520 | |||
| 82f881afc4 | |||
| 872b8ac281 | |||
| cefa751990 | |||
| 2a3cb537f9 | |||
| fcfb2979ef | |||
| 759daa7201 | |||
| 9c8acec71f | |||
| 817fb8933f | |||
| 319e5541c9 | |||
| 49833aded8 | |||
| ee0c762151 | |||
| 4be7a16788 | |||
| f93830ce74 | |||
| f9862b8a6d | |||
| 0f9c091a14 | |||
| ba66f1fc17 | |||
| 7b3b487d26 | |||
| c1409ae9b9 | |||
| 313375184c | |||
| e7833b65fc | |||
| 55f83a3c99 | |||
| 685b770cb9 | |||
| 0a5a347ea1 | |||
| 51ca45500c | |||
| 7548a627ba | |||
| e3b044a476 | |||
| 43d8873339 | |||
| 778b517e6f | |||
| bb647ce35f | |||
| 62d910ed6d | |||
| d03c3831bd | |||
| dac1db48c5 | |||
| 9eba0f6169 | |||
| f3aadb5183 | |||
| f75ed5655c | |||
| 73a7f117d6 | |||
| 51fe6a1197 | |||
| 2f9d391b95 | |||
| 5ac6c87053 | |||
| 55633028e5 | |||
| e71e60f14e | |||
| b6a62d7699 | |||
| 5a270704fd | |||
| 292352eff8 | |||
| a5cfc331fd | |||
| 47efc5979e | |||
| a86b92e2b5 | |||
| d77c18aa53 | |||
| 3633571c7e | |||
| 9fd14aee99 | |||
| 76d5eda101 | |||
| 9b63125f6a | |||
| 7abc5a1dcb | |||
| c6992cad57 | |||
| fe686b6594 | |||
| 891f9506bf | |||
| 9b828fab4c | |||
| 96743ecce1 | |||
| 0874188fe4 | |||
| c6c5f17752 | |||
| d678765fad | |||
| b0bf8c5817 | |||
| 03949c6c98 | |||
| dfe9049b55 | |||
| 0a631e3061 |
173 changed files with 16066 additions and 458 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "config-audit",
|
"name": "config-audit",
|
||||||
"description": "Multi-agent workflow for analyzing, reporting, and optimizing Claude Code configuration across your entire machine",
|
"description": "Multi-agent workflow for analyzing, reporting, and optimizing Claude Code configuration across your entire machine",
|
||||||
"version": "5.2.0",
|
"version": "5.12.5",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Kjell Tore Guttormsen"
|
"name": "Kjell Tore Guttormsen"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -24,7 +24,12 @@ S*-PROMPT.md
|
||||||
# v5 namespace research (local-only spike output)
|
# v5 namespace research (local-only spike output)
|
||||||
docs/v5-namespace-research.md
|
docs/v5-namespace-research.md
|
||||||
|
|
||||||
# --- session/local state (gitignored per ~/.claude polyrepo-konvensjon) ---
|
# --- session/local state (gitignored) ---
|
||||||
|
# STATE.md is LOCAL-ONLY: this repo's only remote is the PUBLIC open/ mirror, and
|
||||||
|
# the global continuity rule says STATE.md must never reach a public mirror (it
|
||||||
|
# would leak internal state-of-play). Mirrors the linkedin-studio precedent.
|
||||||
|
# (Previously tracked under the old "always tracked" rule that assumed a private
|
||||||
|
# remote — corrected 2026-06-21. See ~/.claude/CLAUDE.md.)
|
||||||
STATE.md
|
STATE.md
|
||||||
REMEMBER.md
|
REMEMBER.md
|
||||||
ROADMAP.md
|
ROADMAP.md
|
||||||
|
|
|
||||||
630
CHANGELOG.md
630
CHANGELOG.md
|
|
@ -5,6 +5,636 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [5.12.5] - 2026-06-26
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
"Dogfood denoise" — a samle-release of the Fase-3 scanner false-positive batch: `M-BUG-2/6/7/8/10`,
|
||||||
|
all dogfooding finds from running config-audit on the maintainer's real `~/.claude`. The shared theme
|
||||||
|
is **non-user / non-live config wrongly counted as the user's authored cascade**: installed plugins'
|
||||||
|
bundled config, frozen backup copies, doc examples, and forward-compatible settings keys all produced
|
||||||
|
findings the user could neither act on nor was responsible for. No new scanner, command, agent, or hook
|
||||||
|
(counts stay scanners **16**, agents **7**, commands **21**, hooks **4**); all five fixes are byte-stable
|
||||||
|
— the frozen v5.0.0 + SC-5 + default-output snapshots are untouched and **no fixture was re-seeded**
|
||||||
|
(verified per bug: each affected fixture's findings are genuinely unchanged because the snapshot fixtures
|
||||||
|
contain none of the triggering paths/tokens). **1344** tests (+37).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`conflict-detector` segregates plugin-bundled config (`M-BUG-2`).** CNF compared every discovered
|
||||||
|
`settings.json`/`hooks.json` pairwise regardless of origin, so it treated installed plugins' bundled
|
||||||
|
configs — each plugin's own settings/hooks plus its shipped fixtures and examples under
|
||||||
|
`~/.claude/plugins/` — as the user's cascade. A "conflict" between two plugins' bundled test fixtures
|
||||||
|
is not user-resolvable, yet these dominated the count (dogfood **339** findings: 315 high-sev
|
||||||
|
allow/deny, 18 duplicate-hook, 6 settings-key — Conflicts grade F on ~100% plugin noise). Fix: a new
|
||||||
|
`isPluginBundled` predicate excludes any file whose absolute path is under `.claude/plugins/` from
|
||||||
|
conflict analysis. Kept **CNF-local, not a discovery-level skip** on purpose — an active plugin's
|
||||||
|
contributed `hooks.json`/`.mcp.json` legitimately lives in `plugins/cache` and other scanners need it;
|
||||||
|
only conflict analysis must ignore plugin-bundled files. Same class as `M-BUG-8`. Dogfood **339→0**
|
||||||
|
(the ~3 genuine user-scope local settings have no actually-conflicting keys). +3 tests (plugin-bundled
|
||||||
|
exclusion, discovery-side sanity, over-exclusion guard).
|
||||||
|
- **`file-discovery` skips `backups/` (`M-BUG-8`).** A directory named `backups` holds backup COPIES, not
|
||||||
|
live config, so walking it during an audit produces stale findings. config-audit's own session backups
|
||||||
|
(`~/.claude/config-audit/backups/<ts>/files/.../CLAUDE.md`) were the canonical case: a `~/.claude`-scope
|
||||||
|
audit walked 36 frozen copies as if live, polluting CPS and HKV/RUL. Fix: add `backups` to `SKIP_DIRS`
|
||||||
|
(broad, name-based — consistent with `vendor`/`dist`/`.cache`). Dogfood files-under-`/backups/`
|
||||||
|
**36→0**, 717 live config files retained. +3 tests.
|
||||||
|
- **token estimator discounts block-level HTML comments (`M-BUG-6`).** CLAUDE.md token estimates counted
|
||||||
|
block-level `<!-- -->` comments toward always-loaded tokens, but CC strips them before injection
|
||||||
|
(preserved only inside code fences, per `code.claude.com/docs/en/memory`). Fix: new
|
||||||
|
`stripInjectedHtmlComments` + `effectiveMemoryBytes` in `active-config-reader`; the CML cascade and
|
||||||
|
`token-hotspots` now size CLAUDE.md from effective (stripped) bytes while raw byte figures stay honest.
|
||||||
|
Block-level only — inline comments retained (conservative, verified scope). Dogfood `~/.claude` CLAUDE.md
|
||||||
|
~3386→3301 tok (~85 tok). +13 tests.
|
||||||
|
- **`cache-prefix-stability` ignores code + CC-stable path vars (`M-BUG-7`).** CPS flagged
|
||||||
|
`${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` (CC-provided stable paths) and `{date}`/timestamp tokens
|
||||||
|
shown in documentation as cache-busters. Fix: skip fenced code blocks, strip inline-code spans, and
|
||||||
|
whitelist CC-stable vars before pattern-matching. Suppress-only — frozen v5.0.0 snapshots untouched
|
||||||
|
(CPS yields `findings:[]` there). Dogfood **5→2** (3 doc false-positives suppressed; 2 remaining are
|
||||||
|
own volatile test fixtures). +6 tests.
|
||||||
|
- **`settings-validator` typo-gates unknown keys (`M-BUG-10`).** The CC settings schema is passthrough
|
||||||
|
(verified against the 2.1.193 binary): it forwards unrecognized keys unchanged rather than rejecting
|
||||||
|
them, so an arbitrary unknown key is forward-compatible, not an error — the finding's "silently ignored"
|
||||||
|
claim was factually wrong. The only real risk is a TYPO of a real key (the intended setting then
|
||||||
|
silently has no effect). Fix: flag an unknown key only when it closely matches a known key (new
|
||||||
|
`levenshtein` helper; edit distance ≤2, both keys ≥4 chars); severity medium→low; honest passthrough
|
||||||
|
framing in scanner + humanizer. Also refreshed `KNOWN_KEYS` with 6 binary-verified keys
|
||||||
|
(`agentPushNotifEnabled`, `remoteControlAtStartup`, `skipAutoPermissionPrompt`,
|
||||||
|
`skipDangerousModePermissionPrompt`, `skipWorkflowUsageWarning`, `tui`). Dogfood
|
||||||
|
`~/.claude/settings.json` **6→0** (all 6 were false unknown-key findings; 0 typo flags introduced across
|
||||||
|
167 walked files). +12 tests.
|
||||||
|
|
||||||
|
## [5.12.4] - 2026-06-26
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
"Rooted rules" — fixes `M-BUG-9` (dogfooding find) in `scanners/rules-validator.mjs`. The RUL
|
||||||
|
"Rule path pattern matches no files" check resolved a rule's `paths:`/`globs:` glob against the
|
||||||
|
outer **scan root** instead of the rule's **own project root** (the directory containing its
|
||||||
|
`.claude/`), and `collectProjectFiles` carried a `depth>4` cutoff that never reached deep matching
|
||||||
|
files. As a result, a live rule in a **nested repo** — e.g. a marketplace checkout under
|
||||||
|
`~/.claude/plugins/marketplaces/<mkt>/.claude/rules/` — was wrongly flagged "never activates" (high
|
||||||
|
severity), a false F-grade for any user with rules in a nested repo. Same scope-conflation family as
|
||||||
|
`M-BUG-1/2` (the scanner treats a nested repo's config as scoped to the outer scan root). The fix is a
|
||||||
|
no-op for the common single-repo scan (`projectRoot === targetPath`), so the frozen v5.0.0 +
|
||||||
|
default-output snapshots stay byte-stable; no count change (scanners **16**, agents **7**, commands
|
||||||
|
**21**). **1307** tests (+2).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`rules-validator` glob base (`M-BUG-9`).** The dead-rule check now resolves each rule against its
|
||||||
|
own project root:
|
||||||
|
- `deriveProjectRoot(ruleAbsPath)` returns the parent of the rule's `.claude` segment.
|
||||||
|
- Project files are collected and globbed **per project root** (cached), relative to that root, so a
|
||||||
|
nested repo's rule matches against its own tree where its files live. This also sidesteps the old
|
||||||
|
`depth>4` cutoff, because the walk now starts at the nearby project root.
|
||||||
|
- User-global rules (`projectRoot === $HOME`, i.e. `~/.claude/rules/`) skip the no-match check: they
|
||||||
|
scope against whatever project is active at runtime, not a fixed tree, so "matches 0 files here" is
|
||||||
|
not a dead-rule signal (and this avoids a `$HOME`-wide file walk).
|
||||||
|
- TDD: 2 failing tests (nested-repo false-positive + HOME guard) → fix → full suite 1307/0, frozen
|
||||||
|
v5.0.0 + default-output snapshots untouched (RUL findings appear in none). Real-machine verify: the
|
||||||
|
two `ktg-privat` false positives clear and a previously-hidden genuine dead rule (a false negative)
|
||||||
|
surfaces in the bundled `optimal-setup` example; zero new false positives.
|
||||||
|
|
||||||
|
## [5.12.3] - 2026-06-26
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
"Phantom agents" — fixes `M-BUG-3/4/5` (dogfooding finds) in `scanners/lib/active-config-reader.mjs`
|
||||||
|
so that `enumerateAgents` counts only the agents Claude Code actually **registers**. Per the official
|
||||||
|
subagents documentation, an agent file must carry valid `name`+`description` frontmatter, and CC
|
||||||
|
scans the agents directory **recursively** while silently skipping frontmatter-less files. The reader
|
||||||
|
violated all three rules: it counted every `.md` regardless of frontmatter (`M-BUG-5`), never recursed
|
||||||
|
into agent subdirectories (`M-BUG-3`), and double-counted entries when the project directory equals the
|
||||||
|
user directory — the case when the scope root is `$HOME` (`M-BUG-4`, the root cause, which also affected
|
||||||
|
rules and output-styles). Real-machine verify: the user-agent count dropped **13→0** (all 12 user
|
||||||
|
agents plus `REMEMBER.md` are frontmatter-less, so CC registers none of them) and the HOME `project`
|
||||||
|
duplicate dropped **13→0**; the corrected always-loaded baseline is ≈ **53**, not 66. Agent enumeration
|
||||||
|
is machine-dependent and therefore absent from the frozen snapshots, so the v5.0.0 + SC-5 +
|
||||||
|
default-output snapshots stay byte-stable; no count change (scanners **16**, agents **7**, commands
|
||||||
|
**21**). **1305** tests (+4).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`active-config-reader` agent enumeration (`M-BUG-3/4/5`).** Three surgical fixes:
|
||||||
|
- `listMarkdownFiles` gains an opt-in `recursive` flag so agent enumeration descends into
|
||||||
|
subdirectories the way Claude Code does (`M-BUG-3`).
|
||||||
|
- `configDirs` now de-duplicates the project and user paths when they resolve to the same directory
|
||||||
|
(the case when the scope root is `$HOME`), the root cause that also double-counted rules and
|
||||||
|
output-styles (`M-BUG-4`).
|
||||||
|
- `enumerateAgents` requires a valid `name`+`description` frontmatter block before counting a file,
|
||||||
|
matching CC's actual registration rule — frontmatter-less files are silently skipped (`M-BUG-5`).
|
||||||
|
- Added a `hasText` frontmatter helper. TDD: 4 failing tests (one per bug) → fix → full suite
|
||||||
|
1305/0, frozen v5.0.0 + SC-5 + default-output snapshots untouched (agent enumeration is
|
||||||
|
machine-dependent and never seeded into a snapshot).
|
||||||
|
|
||||||
|
## [5.12.2] - 2026-06-24
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
"Honest census" — fixes a plugin-enumeration bug (`M-BUG-1`, dogfooding find) that made the
|
||||||
|
always-loaded inventory untrustworthy on two common setups: machines with **disabled plugins** and
|
||||||
|
**polyrepo marketplaces**. `enumeratePlugins` walked `~/.claude/plugins/marketplaces/<mkt>/plugins/`
|
||||||
|
and ignored both enable-state and the polyrepo cache layout, so it **over-counted phantom agents**
|
||||||
|
from disabled/unenabled plugins while **missing the entire enabled polyrepo set** (whose plugins
|
||||||
|
live under `cache/`, not `marketplaces/<mkt>/plugins/`). It now gates on `installed_plugins.json` +
|
||||||
|
`enabledPlugins` and enumerates each plugin from its active `installPath`, with the marketplaces
|
||||||
|
walk as fallback. Affects `manifest`, `whats-active`, the agent-listing (AGT) and `token-hotspots`
|
||||||
|
for every such user. No new finding ID or scanner (count stays **16**, agents **7**, commands
|
||||||
|
**21**); `--json`/`--raw` stay byte-stable and the frozen v5.0.0 + SC-5 + default-output snapshots
|
||||||
|
are untouched. **1301** tests (+4).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`active-config-reader` plugin enumeration (`M-BUG-1`).** `enumeratePlugins(repoPath)` now honors
|
||||||
|
`enabledPlugins` (disabled plugins no longer contribute phantom agents/skills/commands) and
|
||||||
|
enumerates polyrepo plugins from their active `installPath` in `installed_plugins.json` (not only
|
||||||
|
`marketplaces/<mkt>/plugins/`). Real-machine verify: the always-loaded agent listing dropped from
|
||||||
|
114 to 104 with the phantom ghosts gone and the true enabled set present. TDD: 4 failing tests →
|
||||||
|
fix → full suite 1301/0, snapshots untouched.
|
||||||
|
|
||||||
|
## [5.12.1] - 2026-06-24
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
"Footgun guard" — Pattern H (stale plugin-cache versions, `token-hotspots`) recommended deleting
|
||||||
|
stale version directories without warning that a currently-running session may still hold one of
|
||||||
|
those versions for its whole lifetime. "Stale" is judged against `installed_plugins.json` (what NEW
|
||||||
|
sessions load), so the recommendation could reproduce the exact failure that broke a live session:
|
||||||
|
deleting the directory pulls the files out from under the running session, which then breaks and
|
||||||
|
must `/exit` + restart. Recommendation text only — no new finding ID or scanner (count stays **16**,
|
||||||
|
agents **7**, commands **21**), no token figures changed, so `--json`/`--raw` stay byte-stable and
|
||||||
|
the frozen v5.0.0 + SC-5 + default-output snapshots are untouched. **1297** tests.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Pattern H live-session caveat (`token-hotspots`, `plugin-cache-hygiene`).** The stale-cache
|
||||||
|
cleanup recommendation now cautions against deleting a version a running session still uses, and
|
||||||
|
tells affected sessions to `/exit` + restart to pick up the active version — closing the footgun
|
||||||
|
that broke a live session during the C4 cache cleanup.
|
||||||
|
|
||||||
|
## [5.12.0] - 2026-06-23
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
"Auto-calibration" — completes the deferred half of B8. `--context-window auto` now **probes the
|
||||||
|
configured model** and calibrates SKL/CML budgets to its real context window, instead of always
|
||||||
|
falling back to the conservative advisory anchor. A 1M-tier host self-calibrates without the manual
|
||||||
|
`--context-window 1000000`. No new finding ID or scanner (count stays **16**, agents **7**, commands
|
||||||
|
**21**); the default and explicit `--context-window` paths are unchanged, so `--json`/`--raw` stay
|
||||||
|
byte-stable and the frozen v5.0.0 + SC-5 snapshots are untouched. **1296** tests.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **B8b — model→window auto-probe.** `lib/context-window.mjs` gains a pure `modelToContextWindow()`
|
||||||
|
that maps a configured model id/alias to its context window:
|
||||||
|
- the explicit `[1m]` tier tag wins (the running session model surfaces as e.g.
|
||||||
|
`claude-opus-4-8[1m]`);
|
||||||
|
- known 1M-tier families `LARGE_CONTEXT_MODEL_IDS` (`claude-fable-5`, `claude-opus-4-8`,
|
||||||
|
`claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6` — verified June 2026 against the
|
||||||
|
platform.claude.com models overview) match by substring, so dated (`-20260528`) and
|
||||||
|
provider-prefixed (`us.anthropic.…`) IDs resolve too;
|
||||||
|
- the short aliases `opus` / `sonnet` / `fable` / `opusplan` resolve to 1M.
|
||||||
|
- Models we cannot confirm (Haiku, older 200k-era IDs, unknown) return `null` — the caller then
|
||||||
|
keeps the conservative anchor rather than guess a relaxed budget.
|
||||||
|
- **New IO helper `lib/active-model.mjs` `resolveActiveModel()`** reads the configured model the way
|
||||||
|
Claude Code resolves it: the shell `ANTHROPIC_MODEL` override first, otherwise the settings cascade
|
||||||
|
`model` field (user `~/.claude` → project `.claude` → project-local, local wins). Reads the cascade
|
||||||
|
files directly (like `isBundledSkillsDisabled`) and takes an injectable `env`, so it stays
|
||||||
|
deterministic and hermetic under the test HOME. Returns `null` when no model is pinned anywhere.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **`resolveContextWindow(arg, opts)` — the `auto` branch now probes.** It maps `opts.model` via
|
||||||
|
`modelToContextWindow()`: a recognized 1M-tier model calibrates to its window (source `auto-probed`,
|
||||||
|
**not** advisory); an unknown or unpinned model keeps the conservative 200k anchor and stays advisory
|
||||||
|
(source `auto-unresolved`, the pre-B8b `auto` behavior). `scan-orchestrator` resolves the active
|
||||||
|
model (only when the flag is `auto`) and threads it in; `posture` inherits this via `runAllScanners`.
|
||||||
|
The default (no flag) and explicit `--context-window <n>` paths ignore `opts.model` and are unchanged.
|
||||||
|
|
||||||
|
## [5.11.0] - 2026-06-23
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
"Precision polish" — the two LOW-priority calibration gaps from the hardening plan, both additive.
|
||||||
|
**B7** flags an oversized SKILL.md body (`CA-SKL-003`), honestly framed as an on-demand cost. **B8**
|
||||||
|
lets `CA-SKL-002` and the CML char-budget calibrate to a real context window via `--context-window`,
|
||||||
|
and downgrade to advisory when the window is unknown — so the 200k anchor stops crying wolf on a 1M
|
||||||
|
host. Scanner count stays **16** (both extend the existing SKL/CML scanners), agents **7**, commands
|
||||||
|
**21**; `--json`/`--raw` stay byte-stable and the frozen v5.0.0 + SC-5 snapshots are untouched. **1279** tests.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **B7 — oversized skill body (`CA-SKL-003`, low).** `measureActiveSkillListing()` now measures the
|
||||||
|
SKILL.md **body** below the frontmatter (the file was already read in full; only the frontmatter was
|
||||||
|
parsed). A body over ~5,000 tokens (`BODY_TOKEN_THRESHOLD`) fires `CA-SKL-003`, recommending a
|
||||||
|
supporting-file split and `context: fork` for heavy skills.
|
||||||
|
- **Honest framing (Verifiseringsplikt):** `BODY_CALIBRATION_NOTE` marks this as an **on-demand**
|
||||||
|
cost — the body loads only when the skill is invoked, **not** every turn like the always-loaded
|
||||||
|
listing — and an estimate (chars/4), hence low severity. Distinct from the always-loaded
|
||||||
|
listing-budget findings.
|
||||||
|
- **B8 — context-window calibration (`--context-window`).** `CA-SKL-002` (skill-listing budget) and
|
||||||
|
the CML char-budget threshold now calibrate to a resolved context window instead of always
|
||||||
|
anchoring at 200k. `lib/context-window.mjs` gains `resolveContextWindow()` and `scaleForWindow()`:
|
||||||
|
- `--context-window <n>` calibrates the budget to `n` (e.g. `1000000` relaxes the SKL listing budget
|
||||||
|
to ~20,000 tok, so an over-200k listing is within budget and does not fire).
|
||||||
|
- `--context-window auto` keeps the conservative 200k anchor but marks the result **advisory** —
|
||||||
|
SKL/CML emit the finding at **info** rather than as a budget breach (model→window auto-probing is
|
||||||
|
deferred to a later B8b).
|
||||||
|
- No flag → the conservative 200k anchor at full severity, **byte-identical** to the pre-B8 default.
|
||||||
|
- Both SKL and CML keep an untouched default branch (`window === 200k && !advisory`) for
|
||||||
|
byte-stability plus a calibrated branch. The flag is wired through `scan-orchestrator` and
|
||||||
|
`posture`; `runAllScanners` resolves it once and threads `{ contextWindow }` to the scanners
|
||||||
|
(others ignore the third arg).
|
||||||
|
- **CPS intentionally excluded:** it has no window-anchored budget (a fixed 150-line volatility
|
||||||
|
heuristic), so there is nothing to calibrate.
|
||||||
|
|
||||||
|
## [5.10.0] - 2026-06-23
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
"Deferral & injection hygiene" — three additive hardening levers that extend existing scanners
|
||||||
|
toward a tighter always-loaded prefix. **B4** detects config that forces full MCP tool schemas into
|
||||||
|
the always-loaded prefix (deferral defeated), with a CLI-over-MCP companion lever. **B5** adds a
|
||||||
|
hook `additionalContext`-injection advisory plus a filter-before-Claude-reads lever. **B6** extends
|
||||||
|
the cache-prefix scanner to follow `@import`s and flag volatile content in imported files. Scanner
|
||||||
|
count stays **16** (all three extend existing scanners), agents **7**, commands **21**;
|
||||||
|
`--json`/`--raw` stay byte-stable and the frozen v5.0.0 + SC-5 snapshots are untouched. **1257** tests.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **B4 — MCP tool-schema deferral (`CA-TOK-006`; tokens patterns 7 → 8).** By default Claude Code
|
||||||
|
defers MCP tool schemas (names-only, ~120 tok; full schemas load on demand via tool search).
|
||||||
|
`CA-TOK-006` detects config-file signals that force the FULL schemas into the always-loaded prefix
|
||||||
|
every turn: `settings.json` `env.ENABLE_TOOL_SEARCH="false"` (high), `"ToolSearch"` in
|
||||||
|
`permissions.deny` (high), a configured Haiku model (medium), and per-server `.mcp.json`
|
||||||
|
`alwaysLoad:true` (CC v2.1.121+, high). Severity scales with the aggregate forced-upfront tokens
|
||||||
|
(medium-confidence reasons cap at medium). New pure engine `lib/mcp-deferral.mjs`
|
||||||
|
(`assessMcpDeferral`, unit-tested, no IO) shared by TOK and GAP. A feature-gap **CLI-over-MCP**
|
||||||
|
lever fires only as a companion to `CA-TOK-006` (prefer `gh`/`aws`/`gcloud` over MCP for common
|
||||||
|
operations — CLI adds zero context tokens until used).
|
||||||
|
- **Honest scoping (Verifiseringsplikt):** triggers on config files ONLY, never `process.env`
|
||||||
|
shell vars. Vertex / custom `ANTHROPIC_BASE_URL` / a runtime `/model` switch are launch state
|
||||||
|
(would flap snapshots machine-dependently), so they are DISCLOSED in every finding, not
|
||||||
|
triggered. Mechanism verified 2026-06-23 against code.claude.com/docs (`context-window.md`,
|
||||||
|
`mcp.md#configure-tool-search` + `#exempt-a-server-from-deferral`, `costs.md`); the
|
||||||
|
prefix-cache-invalidation claim was NOT-CONFIRMED in docs and is not asserted.
|
||||||
|
- **B5 — hook `additionalContext`-injection advisory + filter-before lever.** HKV emits an info
|
||||||
|
advisory when a hook injects unfiltered command output into `additionalContext` (it enters context
|
||||||
|
every turn the hook fires). A feature-gap **filter-before-Claude-reads** companion lever cites the
|
||||||
|
documented `filter-test-output.sh` pattern (filter at the hook, not after Claude reads).
|
||||||
|
- **B6 — CPS `@import` volatile scan.** The cache-prefix scanner now follows `@import`s (one hop) and
|
||||||
|
flags volatile content in the imported file that breaks the cached prefix — a new medium finding
|
||||||
|
("Volatile content in @imported file breaks cached prefix"), keyed on the resolved file with
|
||||||
|
evidence "imported by <file> (@<path> at line N)". Scoped to one hop (the IMP scanner owns deep
|
||||||
|
chains); resolved files that are themselves discovered CLAUDE.md are skipped (own iteration).
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- Scanner count unchanged at **16** — B4/B5/B6 all extend existing scanners (TOK / HKV + GAP / CPS).
|
||||||
|
`--json`/`--raw` output remains byte-stable; frozen v5.0.0 + SC-5 snapshots untouched.
|
||||||
|
|
||||||
|
## [5.9.0] - 2026-06-23
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
"Machine-wide token lens" — the three highest-impact hardening gaps toward whole-machine token
|
||||||
|
tuning (the shift from *correct?* to *best-practice-tuned?*). **B1** measures the always-loaded
|
||||||
|
agent listing (a new orchestrated scanner), **B2** gives the campaign ledger a machine-wide
|
||||||
|
always-loaded token bill that counts the shared global layer once, and **B3** makes the token and
|
||||||
|
conflict scans cache-aware so stale plugin-cache versions stop polluting them. Every new token
|
||||||
|
figure carries an honesty caveat where the load mechanism is inferred or the number is an
|
||||||
|
upper-bound estimate. `--json`/`--raw` stay byte-stable; frozen v5.0.0 + SC-5 snapshots untouched.
|
||||||
|
Scanner count 15 → **16**, agents **7**, commands **21**; **1215** tests.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **B1 — Agent-listing budget (new orchestrated scanner `AGT`, count 15 → 16).** Claude Code injects
|
||||||
|
every active agent's name+description into the system prompt on every turn; on a heavily-plugged
|
||||||
|
machine this is often the dominant single always-loaded source, yet nothing measured it. New
|
||||||
|
`scanners/agent-listing-scanner.mjs` + `scanners/lib/agent-listing-budget.mjs`:
|
||||||
|
- `CA-AGT-001` — per-agent description over the soft bloat cap (advisory; mirrors the TOK 500-char
|
||||||
|
SKILL.md heuristic — agents have no verified per-description cap, so nothing is truncated, the
|
||||||
|
description is simply re-sent in full every turn).
|
||||||
|
- `CA-AGT-002` — the summed agent-listing estimate exceeding the listing budget.
|
||||||
|
- **Intellectual-honesty contract:** both findings are LOW and explicitly **inferred /
|
||||||
|
upper-bound** — the agent-listing mechanism is undocumented (agents are absent from Claude Code's
|
||||||
|
published context breakdown), the per-agent estimate is the frontmatter cap not a measurement,
|
||||||
|
and the budget is a config-audit heuristic anchored on a conservative 200k window. The evidence
|
||||||
|
discloses all three caveats rather than overstating certainty.
|
||||||
|
- **B2 — Machine-wide always-loaded token roll-up (campaign).** The campaign ledger now carries a
|
||||||
|
token bill alongside the severity roll-up. `campaign refresh-tokens` runs a live cross-repo sweep
|
||||||
|
(`readActiveConfig` → `buildManifest` → ownership split) that factors out the **shared global
|
||||||
|
always-loaded layer** (global CLAUDE.md + agent listing + global MCP + unscoped global rules) and
|
||||||
|
counts it **once**, then adds each repo's delta — surfacing one machine-wide always-loaded total
|
||||||
|
plus a ranked "most expensive repos" table. The shared layer is written once from the first
|
||||||
|
readable repo (counted-once guard against the `whats-active` double-count).
|
||||||
|
- **B3 — Stale plugin-cache disk-cleanup finding (`TOK`).** Stale `~/.claude/plugins/cache` versions
|
||||||
|
surface as a finding (`--global`) categorized **Dead config** (not "Wasted tokens"): they occupy
|
||||||
|
disk but load on zero turns. A per-finding category override (`plugin-cache-hygiene`) plus a
|
||||||
|
dedicated humanizer translation keep the prose honest ("safe to delete … zero tokens per turn …
|
||||||
|
housekeeping, not a performance problem").
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **B3 — Cache-aware filtering (folds in B0): `--exclude-cache` (default ON) for token-hotspots and
|
||||||
|
the orchestrator.** `~/.claude/plugins/cache` holds *both* active and stale plugin versions —
|
||||||
|
`installed_plugins.json`'s `installPath` points INTO the cache — so a blunt "skip all of
|
||||||
|
plugins/cache" would drop *active* config. Discovery is now **version-aware**: it reads the
|
||||||
|
adjacent `installed_plugins.json`, keeps each plugin's active version dir, and drops only stale
|
||||||
|
ones. Result: stale versions no longer pollute the token-hotspot ranking, and stale cached
|
||||||
|
`hooks.json` no longer inflate CNF "duplicate hook" findings (verified: cache exclusion measurably
|
||||||
|
drops the count). `--no-exclude-cache` restores the full walk. When `installed_plugins.json` is
|
||||||
|
absent or unparseable, nothing is filtered (never silently drop live config).
|
||||||
|
- **B2 — `manifest` ownership split.** `splitManifestByOwnership` separates the shared-global layer
|
||||||
|
from per-repo deltas; `~/.claude.json:projects` MCP is correctly classified as a per-repo delta
|
||||||
|
(its slice is keyed on the repo path), not a shared-global source.
|
||||||
|
- Humanizer: a finding's `category` can now override the scanner-default impact label
|
||||||
|
(`CATEGORY_TO_IMPACT`), used so a TOK finding can read as **Dead config** when that is the honest
|
||||||
|
bucket. The raw `category` field is unchanged, so `--json`/`--raw` stay byte-stable.
|
||||||
|
|
||||||
|
## [5.8.0] - 2026-06-23
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
"Campaign motor" — a durable, machine-wide audit **campaign** that sits ABOVE individual sessions.
|
||||||
|
A single audited repo is one session; a fleet of repos is a campaign. This release adds a durable
|
||||||
|
ledger (per-repo lifecycle + machine-wide roll-up), a read-only reporter and a human-approved write
|
||||||
|
surface behind one `/config-audit campaign` command, a cross-repo prioritized backlog, and per-repo
|
||||||
|
plan export — with execution deliberately **reusing** the existing implement/rollback engines rather
|
||||||
|
than adding new machinery. The whole campaign surface is `-cli`/lib only, so the scanner count stays
|
||||||
|
**15**, agents **7**, and the snapshot suite is untouched; commands 20 → **21**.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Durable campaign ledger (`scanners/lib/campaign-ledger.mjs`).** The ledger that sits above
|
||||||
|
sessions: a repo list + per-repo lifecycle (`pending → audited → planned → implemented`) + a
|
||||||
|
machine-wide roll-up (counts by status and by severity, aggregated across repos). Persists to a
|
||||||
|
single JSON file **outside** the plugin dir (`~/.claude/config-audit/campaign-ledger.json`, next
|
||||||
|
to `sessions/`) so it survives uninstall / reinstall / upgrade. Pure transforms
|
||||||
|
(`createLedger` / `addRepo` / `setRepoStatus` / `rollUp`) with `now` injected (never the clock) +
|
||||||
|
a soft `validateLedger` + a thin IO shell; `schemaVersion` stamped from the start.
|
||||||
|
- **Cross-repo prioritized backlog (`buildBacklog`).** One machine-wide work list — each item is a
|
||||||
|
repo (the ledger tracks per-repo severity *counts*, not individual findings), ordered by a
|
||||||
|
weighted severity score (`critical:1000 / high:100 / medium:10 / low:1`) with a deterministic
|
||||||
|
tie-break so criticals always win. Includes only repos not yet `implemented` that have known
|
||||||
|
findings.
|
||||||
|
- **`/config-audit campaign` (commands 20 → 21).** A thin orchestrator that always **reports** first
|
||||||
|
(read-only `campaign-cli`: status, roll-up, backlog) and, for `init` / `add` / `set-status`,
|
||||||
|
proposes the change and mutates **only on explicit human approval** via `campaign-write-cli` — it
|
||||||
|
never hand-edits the ledger JSON (verification duty). `add --discover <root>` finds git repos
|
||||||
|
under a root to pick from; marking a repo `audited` attaches findings-by-severity from its session
|
||||||
|
(or user-provided counts), never invented.
|
||||||
|
- **Per-repo plan export (`campaign-export-cli`).** `--repo <path>` resolves the repo's linked
|
||||||
|
session, reads its `action-plan.md`, and (under opt-in `--write`) drops a provenance-stamped,
|
||||||
|
byte-faithful copy into that repo's own `docs/config-audit-plan-<session>.md` — a durable record
|
||||||
|
next to the code it changes. Advisory gates for "no session linked" / "no action plan yet".
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Execution by reuse.** A campaign adds no execution engine of its own: the exported `docs/` file
|
||||||
|
is the durable record, while `/config-audit implement` (backup + apply + verify) and
|
||||||
|
`/config-audit rollback` do the work, then `campaign set-status <repo> implemented` records it.
|
||||||
|
- **`knowledge-refresh` wired into the surface.** The v5.7.0 `knowledge-refresh` command shipped but
|
||||||
|
was never routed; it is now in the `/config-audit` router (argument-hint + routing) and the `help`
|
||||||
|
command table.
|
||||||
|
- **CLAUDE.md trimmed to lean invariants.** The 19 per-scanner / per-block implementation notes
|
||||||
|
moved verbatim into `docs/scanner-internals.md` ("Implementation notes"); CLAUDE.md drops
|
||||||
|
540 → 134 lines (raising its own config grade B → A). A real "Conventions" section now points at
|
||||||
|
`.claude/rules/`.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- 1091 → **1168** (campaign ledger +28, read-only campaign-cli +8, campaign-write-cli +11,
|
||||||
|
cross-repo backlog +12, plan-export +18).
|
||||||
|
|
||||||
|
## [5.7.0] - 2026-06-21
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
"Optimization lens" — the first detector of the **«is this config optimally shaped?»** axis, on top
|
||||||
|
of the existing «is it correct?» checks, plus a **living** machine-readable knowledge layer that keeps
|
||||||
|
the lens's rules fresh. A config that *works* can still fit a better mechanism (a long CLAUDE.md
|
||||||
|
procedure that belongs in a skill, a lifecycle instruction that belongs in a hook); this release adds
|
||||||
|
a hybrid deterministic + opus motor that surfaces those as opportunities, every finding citing a
|
||||||
|
provenance-stamped best-practices register entry. Scanner count 14 → **15**, commands 18 → **20**,
|
||||||
|
agents 6 → **7**.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Best-practices register (knowledge layer).** `knowledge/best-practices.json` — a provenance-stamped,
|
||||||
|
schema-validated register (each entry: `id` / `claim` / `confidence` / `source` + optional
|
||||||
|
`mechanism` / `lensCheck`), the first runtime-consumed file under `knowledge/` and the source of
|
||||||
|
truth for the optimization lens. Loaded/validated by `scanners/lib/best-practices-register.mjs`.
|
||||||
|
Only **confirmed** entries are user-facing (verification duty).
|
||||||
|
- **Optimization-lens scanner (`CA-OPT`, scanner count 14 → 15).** New orchestrated scanner:
|
||||||
|
`CA-OPT-001` (LOW, *Missed opportunity*) — a CLAUDE.md procedure (≥6 consecutive numbered steps)
|
||||||
|
that would fit better as a skill, with recommendation + provenance from register entry `BP-MECH-003`.
|
||||||
|
Deterministic and conservative (no false positives on the negative corpus).
|
||||||
|
- **`/config-audit optimize` + `optimization-lens-agent` (opus, agents 6 → 7).** The prose-judgment
|
||||||
|
half of the lens, for the cases the deterministic scanner deliberately skips: lifecycle phrasing →
|
||||||
|
hook (`BP-MECH-001`), unscoped path-specific instruction → rule (`BP-MECH-002`), absolute "never"
|
||||||
|
prohibition → permission (`BP-MECH-004`). A pre-filter (`scanners/lib/lens-prefilter.mjs`,
|
||||||
|
unit-tested) does cheap recall; the opus agent is the precision gate, dropping low-confidence
|
||||||
|
candidates and citing the register id + source for each kept one. Agent-driven → deliberately not
|
||||||
|
byte-stable (own command, outside the snapshot suite).
|
||||||
|
- **`/config-audit knowledge-refresh` (commands 19 → 20).** Keeps the register current so the lens
|
||||||
|
never reads stale rules. A deterministic core (`scanners/lib/knowledge-refresh.mjs`, `assessFreshness`
|
||||||
|
with an **injected** reference date — it never reads the clock) classifies each entry fresh/stale by
|
||||||
|
the age of its `source.verified` stamp (default 90-day quarterly cadence); a read-only CLI
|
||||||
|
(`knowledge-refresh-cli.mjs`, `--reference-date` / `--stale-after` / `--dry-run`) reports staleness
|
||||||
|
without ever writing the register or hitting the network. The command layer re-verifies stale sources
|
||||||
|
(WebFetch) and polls the CC changelog + Anthropic blog (WebSearch), then applies **only
|
||||||
|
human-approved writes** (verification duty). Web/judgment-driven → deliberately not byte-stable.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- **Byte-stability.** The new OPT scanner's additive entry is stripped at compare time
|
||||||
|
(`tests/helpers/strip-added-scanner.mjs`); the original 13-scanner `--json` / `--raw` output stays
|
||||||
|
byte-stable, and only the SC-5 default-output snapshots are regenerated (additive). The two
|
||||||
|
non-deterministic surfaces (`optimize`, `knowledge-refresh`) live outside the snapshot suite by
|
||||||
|
design. `-cli` / lib modules are not orchestrated scanners (self-audit excludes them), so the
|
||||||
|
scanner count stays **15**. Suite **1091** pass (hermetic HOME), self-audit A/A (config 94,
|
||||||
|
plugin 100), readmeCheck passed.
|
||||||
|
|
||||||
|
## [5.6.0] - 2026-06-20
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
"Steering-model II" — the per-mechanism **load-pattern** and **compaction-survival** model lands
|
||||||
|
end-to-end across enumeration, the `manifest` / `tokens` token accounting, and a new output-style
|
||||||
|
scanner. v5.5.0 shipped A+E (additive to existing scanners); this release adds the **Foundation**
|
||||||
|
those features needed, plus **B** (load-pattern accounting) and **C** (the new `CA-OST` family,
|
||||||
|
scanner count 13 → **14**). Every new claim is anchored to the live Claude Code docs
|
||||||
|
(`code.claude.com/docs`) per the project's verification duty.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Foundation — active-config-reader enumeration + load-pattern model.** `active-config-reader`
|
||||||
|
now enumerates **rules**, **agents**, and **output styles** (alongside the existing
|
||||||
|
CLAUDE.md / plugins / skills / hooks / MCP). Each source carries a `loadPattern`
|
||||||
|
(`always` / `on-demand` / `external`), `survivesCompaction`, and `derivationConfidence`, derived
|
||||||
|
from the published loading model. The frontmatter parser additionally reads YAML **block
|
||||||
|
sequences** (`paths:` as a `- item` list), resolving a pre-existing RUL false-positive.
|
||||||
|
- **C — output-style scanner (`CA-OST`, scanner count 13 → 14).** New orchestrated scanner:
|
||||||
|
`CA-OST-001` (MEDIUM) a user/project custom output style missing `keep-coding-instructions: true`
|
||||||
|
(defaults false) silently strips Claude Code's built-in software-engineering instructions when
|
||||||
|
active (V10); `CA-OST-002` (LOW) a **plugin** output style with `force-for-plugin: true` overrides
|
||||||
|
the user's selected `outputStyle` (V11); `CA-OST-003` (MEDIUM) a settings `outputStyle` matching
|
||||||
|
no built-in (Default / Explanatory / Learning / Proactive) nor discovered custom style is dead
|
||||||
|
config.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **B — load-pattern accounting in `manifest` and `tokens`.** `manifest` now reports
|
||||||
|
**component-level** token sources (the coarse plugin roll-up that double-counted its own
|
||||||
|
skills / rules / agents is gone), tags every source with the load-pattern triple, and leads with
|
||||||
|
an **always-loaded subtotal** ("≈X tokens enter context every turn before you type").
|
||||||
|
`token-hotspots` annotates each ranked hotspot with its load pattern. `manifest`'s `total` changes
|
||||||
|
(de-duplicated) — the intended correctness fix.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- **Byte-stability.** `--json` / `--raw` stay byte-stable for the original 13 scanners. The frozen
|
||||||
|
v5.0.0 snapshots are **preserved**: B2's additive hotspot fields and C's additive OST scanner
|
||||||
|
entry are stripped at compare time (`tests/helpers/strip-hotspot-load-pattern.mjs`,
|
||||||
|
`tests/helpers/strip-added-scanner.mjs`); only the SC-5 default-output snapshots are regenerated.
|
||||||
|
Suite 1023 pass, self-audit A/A (config 93, plugin 100), readmeCheck passed.
|
||||||
|
|
||||||
|
## [5.5.0] - 2026-06-20
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
"Steering-model I" — two additive findings on the compaction-durability / dead-config theme,
|
||||||
|
extending the existing RUL, CML, and PLH scanners (scanner count stays **13**; `--json` and `--raw`
|
||||||
|
remain byte-stable). Both are anchored to the live Claude Code docs (`code.claude.com/docs`) per the
|
||||||
|
project's verification duty. The Foundation work (active-config-reader enumeration) and feature B are
|
||||||
|
deferred to v5.6 — A and E are additive to existing scanners and do not consume it.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **RUL / CML compaction-durability findings (A)** — per the official "what survives compaction"
|
||||||
|
model (`context-window.md`), only the project-root CLAUDE.md and unscoped rules are re-injected
|
||||||
|
after a context compaction. Two structural findings, both severity **LOW**: a large (>50-line)
|
||||||
|
**path-scoped** rule reloads only on a matching file read and is not re-injected after compaction,
|
||||||
|
so a must-hold rule can silently drop mid-session (RUL); a **nested** (subdirectory) CLAUDE.md is
|
||||||
|
not re-injected after compaction — only the project root is (CML).
|
||||||
|
- **PLH plugin-agent ignored-frontmatter finding (E)** — plugin subagents silently ignore
|
||||||
|
`hooks`/`mcpServers`/`permissionMode` frontmatter (honored only for user/project agents in
|
||||||
|
`.claude/agents/`). Setting them in a plugin agent is dead config: `permissionMode` is **MEDIUM**
|
||||||
|
(it implies a restriction Claude Code does not apply — false security); `hooks`/`mcpServers` are
|
||||||
|
**LOW**. Additive to PLH's agent-frontmatter loop; one humanizer pattern covers the three fields.
|
||||||
|
|
||||||
|
### Known limitations
|
||||||
|
- The lightweight frontmatter parser (`scanners/lib/yaml-parser.mjs`) reads inline `paths: "a, b"`
|
||||||
|
but not YAML block sequences (`paths:\n - a`), so a block-sequence-scoped rule is still seen as
|
||||||
|
unscoped and the RUL durability check (A) does not fire on it. Pre-existing and broader than A;
|
||||||
|
to be fixed in the v5.6 Foundation work.
|
||||||
|
|
||||||
|
## [5.4.1] - 2026-06-20
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Scanner-correctness patch. Five primary-source-verified fixes to the PLH, HKV, and RUL scanners —
|
||||||
|
no new scanner, no command or output-format change (scanner count stays **13**; `--json` and `--raw`
|
||||||
|
remain byte-stable). All fixes are anchored to the live Claude Code docs (`code.claude.com/docs`,
|
||||||
|
docs map 2026-06-19) per the project's verification duty.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **HKV hook events** — added `Setup`, `UserPromptExpansion`, and `PostToolBatch` to the valid-event
|
||||||
|
set (verified against `hooks.md`); a valid hook using one of these was wrongly flagged
|
||||||
|
"Unknown hook event … will never fire", which could lead a user to delete a working hook. The
|
||||||
|
"(N total)" hint in the recommendation is now derived from the set so it cannot drift.
|
||||||
|
- **HKV `post-session`** — removed from the valid-event set. The 2.1.169 changelog `post-session`
|
||||||
|
hook is a **self-hosted-runner** workspace-lifecycle hook (runs after the session, before the
|
||||||
|
workspace is deleted), **not** a `settings.json` hook event — it is absent from `hooks.md` (all
|
||||||
|
settings.json events are PascalCase). A bogus `post-session` settings hook is now correctly flagged.
|
||||||
|
- **RUL `globs` rule** — reworded the finding. Only `paths:` is documented as the rule-scoping
|
||||||
|
field; whether Claude Code ever read `globs` is unverified, so the previous "deprecated/legacy"
|
||||||
|
framing overclaimed. The finding (and its humanizer entry) now steer to the documented `paths:`
|
||||||
|
field without asserting deprecation. The coupled fix-engine title match was updated in lockstep.
|
||||||
|
- **PLH required frontmatter** — `model`/`tools`/`name`/`allowed-tools` are optional per the CC
|
||||||
|
agent/command docs; a missing one is no longer flagged.
|
||||||
|
- **PLH CLAUDE.md component sections** — a commands/agents/hooks section is required only for a
|
||||||
|
component the plugin actually ships.
|
||||||
|
|
||||||
|
## [5.4.0] - 2026-06-19
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Plugin-hygiene & settings-validation hardening. Three additive findings extend the existing PLH and
|
||||||
|
SET scanners — no new scanner file, so the scanner count stays **13**. PLH gains plugin-folder
|
||||||
|
shadow detection and `skills:`-array validation; the settings validator now checks `autoMode`
|
||||||
|
structure and flags it when placed in shared project settings, where Claude Code does not read it.
|
||||||
|
Scanner internals only — no command, agent, or output-format changes; `--json` and `--raw` remain
|
||||||
|
byte-stable.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **PLH plugin-folder shadowing** (`CA-PLH-015`) — flags a `plugin.json` component-path key in the
|
||||||
|
*replaces* set (`commands`/`agents`/`outputStyles`) that points at a custom path while the
|
||||||
|
default folder of that name still exists on disk, so the folder is silently ignored (dead
|
||||||
|
config). Mirrors Claude Code's own warning in `/doctor`, `claude plugin list`, and the `/plugin`
|
||||||
|
detail view (v2.1.140+). Severity **medium**, `category: 'plugin-hygiene'`,
|
||||||
|
`details: { field, ignoredDir, customPaths }`. Deliberately excludes `skills` (adds to the
|
||||||
|
default scan, never shadows) and `hooks`/`mcpServers`/`lspServers` (own merge rules); honors the
|
||||||
|
explicit-address exception (a custom path resolving *into* the default folder is not flagged).
|
||||||
|
- **PLH `skills:`-array validation** (`CA-PLH-016`) — validates each `plugin.json` `skills` entry
|
||||||
|
(string or array) resolves to an existing directory inside the plugin root; one finding per bad
|
||||||
|
entry with `problem` ∈ `non-string` / `escapes-root` / `not-found` / `not-a-directory`. Mirrors
|
||||||
|
`claude plugin validate` (~2.1.145). Severity **medium**, `category: 'plugin-hygiene'`,
|
||||||
|
`details: { field: 'skills', entry, problem }`. Path-escape detection is containment-based
|
||||||
|
(resolve + plugin-root prefix), backed by the docs' path-traversal rule.
|
||||||
|
- **SET autoMode structure + dead-config** (`CA-SET`) — `autoMode`, if present, must be an object
|
||||||
|
whose only keys are `environment`/`allow`/`soft_deny`/`hard_deny`, each a **string array** (the
|
||||||
|
literal `"$defaults"` is a valid entry); unknown sub-keys and wrong value types are flagged
|
||||||
|
(`problem` ∈ `not-an-object` / `unknown-subkey` / `not-string-array`), severity **medium**.
|
||||||
|
Separately, `autoMode` placed in **shared** project settings (`.claude/settings.json`) is flagged
|
||||||
|
as dead config (`problem: 'shared-project-scope'`), severity **low** — Claude Code's classifier
|
||||||
|
does not read `autoMode` from shared project settings. The two sub-checks are independent. Both
|
||||||
|
verified against `code.claude.com/docs/en/auto-mode-config`.
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- **Test count** — 936 → **949** (+13: PLH-015, PLH-016, and SET autoMode structure/dead-config).
|
||||||
|
- **Scanner count stays 13** — all three findings extend existing PLH and SET scanners; no new
|
||||||
|
scanner file, no badge/table/`countScannerShape` change.
|
||||||
|
- **Byte-stability** — `json-backcompat` + `raw-backcompat` + the SC-5 default-output snapshot stay
|
||||||
|
green; the three findings are additive and appear only in configs that trigger them.
|
||||||
|
|
||||||
|
## [5.3.0] - 2026-06-19
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Permission-rule & plugin-hygiene hardening. Five additive scanner findings extend the existing
|
||||||
|
DIS, CML, PLH, and feature-gap scanners — no new scanner file, so the scanner count stays **13**.
|
||||||
|
DIS gains forbidden-parameter and ineffective-allow-wildcard detection; CML mirrors Claude Code's
|
||||||
|
own 40.0k-char "large CLAUDE.md" startup warning, context-window scaled; PLH flags two plugins
|
||||||
|
that declare the same name; and feature-gap recommends `disableBundledSkills` under skill-listing
|
||||||
|
pressure. The cross-plugin command-name finding is reframed from a HIGH conflict to a LOW
|
||||||
|
ambiguity. Scanner internals only — no command, agent, or output-format changes; `--json` and
|
||||||
|
`--raw` remain byte-stable.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **DIS forbidden-param rules** — flags `Tool(param:value)` whose key is the tool's own
|
||||||
|
canonicalizing field (`command`/`file_path`/`path`/`notebook_path`/`url`); Claude Code ignores
|
||||||
|
these and emits a startup warning. Severity by intent: **deny/ask = false security (medium)**,
|
||||||
|
**allow = dead config (low)**. Valid forms (`Bash(npm:*)`, `WebFetch(domain:host)`,
|
||||||
|
`Agent(model:opus)`) are never flagged. Predicate `forbiddenParamRule` in `permission-rules.mjs`.
|
||||||
|
- **DIS ineffective allow-wildcards + `Tool(*)` deny-all** — flags unanchored tool-name globs in
|
||||||
|
`permissions.allow` (`*`, `B*`, `mcp__*`) that CC silently skips (low); treats `Tool(*)` as
|
||||||
|
deny-all (`Bash(*)` ≡ `Bash`) so a bare allow killed by it is reported as dead config. Valid
|
||||||
|
`mcp__<server>__*` is never flagged.
|
||||||
|
- **CML context-window-scaled char budget** — new `CA-CML` finding mirroring CC's startup warning
|
||||||
|
*"Large CLAUDE.md will impact performance (X chars > 40.0k)"*. Anchors on the conservative 200k
|
||||||
|
window; discloses the relaxed ~200,000-char figure at 1M context. Severity **medium** (token
|
||||||
|
cost). Char-keyed, complementary to the 200/500-line checks. Window constants live in the shared
|
||||||
|
`scanners/lib/context-window.mjs`.
|
||||||
|
- **PLH plugin namespace collision** — flags 2+ discovered plugins declaring the same `name` in
|
||||||
|
`plugin.json`. Namespaces collapse; CC picks an undocumented winner; the loser's
|
||||||
|
commands/skills/agents go silently unreachable. Severity **medium** (dead config),
|
||||||
|
`category: 'plugin-hygiene'`, COL-shaped `details.namespaces`. Keys on the declared `name`, not
|
||||||
|
`basename(dir)`; name-less plugins are excluded.
|
||||||
|
- **feature-gap `disableBundledSkills` lever** — conditional recommendation to set
|
||||||
|
`disableBundledSkills` when the active skill listing is over budget; remediation companion to
|
||||||
|
SKL `CA-SKL-002`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **PLH cross-plugin command-name overlap: HIGH → LOW** — reframed from "conflict" to "ambiguity".
|
||||||
|
Commands are namespaced (`/name:command`) so both stay reachable; only a same-`name` plugin
|
||||||
|
collision loses components (covered by the new namespace-collision finding). Now group-first
|
||||||
|
(one finding per command name listing every namespace), COL-shaped `details.namespaces`. The old
|
||||||
|
HIGH `Cross-plugin command name conflict` finding and its humanizer entry are removed. Scoring
|
||||||
|
impact: configs with cross-plugin command overlap score slightly higher. Not a `--json`-shape
|
||||||
|
break — no test or consumer asserted the old HIGH.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **README scanner table** — added the missing SKL row (12 → 13 rows); the prose table lagged the
|
||||||
|
badge/self-audit count (the `--check-readme` gate is number-only and didn't catch it).
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- **Extract skill-listing budget to shared lib** — `scanners/lib/context-window.mjs` as the single
|
||||||
|
source of truth for the 200k/1M context-window constants, re-exported by
|
||||||
|
`skill-listing-budget.mjs` and consumed by the new CML char-budget finding.
|
||||||
|
|
||||||
|
### Knowledge
|
||||||
|
- Three backing entries added: `disableBundledSkills` as a token-efficiency lever
|
||||||
|
(`prompt-cache-patterns.md`); the 40.0k-char "large CLAUDE.md" startup warning + CC 2.1.169
|
||||||
|
context-window scaling (`claude-code-capabilities.md`); and `Tool(param:value)` permission
|
||||||
|
semantics — deny/ask-only matching plus the canonicalizing-field rules CC ignores
|
||||||
|
(`claude-code-capabilities.md`).
|
||||||
|
|
||||||
|
### Test count
|
||||||
|
- 936 tests across 56 test files (unchanged — this release covers already-tested work on `main`).
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- 936/936 tests pass (`node --test 'tests/**/*.test.mjs'`).
|
||||||
|
- `node scanners/self-audit.mjs --json --check-readme` → `configGrade: A`, `pluginGrade: A`,
|
||||||
|
`readmeCheck.passed: true`.
|
||||||
|
- README version badge updated: `version-5.2.0` → `version-5.3.0`. Scanner count stays 13; tests
|
||||||
|
badge already `936+`.
|
||||||
|
|
||||||
## [5.2.0] - 2026-06-18
|
## [5.2.0] - 2026-06-18
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|
|
||||||
86
CLAUDE.md
86
CLAUDE.md
|
|
@ -1,13 +1,8 @@
|
||||||
# Config-Audit Plugin
|
# Config-Audit Plugin
|
||||||
|
|
||||||
Claude Code Configuration Intelligence — know if your configuration is correct, find what could improve it, fix it automatically.
|
Claude Code Configuration Intelligence — know if your config is correct, find what could improve it, fix it automatically. Three pillars: **Health** (deterministic scanners), **Opportunities** (context-aware recommendations), **Action** (auto-fix with backup/rollback).
|
||||||
|
|
||||||
## What this plugin does
|
Per-command flags, patterns, and feature lists live in `README.md` and `/config-audit help`. This file carries what's invariant for working on the plugin.
|
||||||
|
|
||||||
Analyzes and optimizes Claude Code configuration across three pillars:
|
|
||||||
- **Health** — Deterministic scanners verify correctness, consistency, and completeness
|
|
||||||
- **Opportunities** — Context-aware recommendations for features that could benefit your project
|
|
||||||
- **Action** — Auto-fix with backup/rollback
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|
@ -15,14 +10,15 @@ Analyzes and optimizes Claude Code configuration across three pillars:
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `/config-audit` | Full audit with auto-scope detection (no setup needed) |
|
| `/config-audit` | Full audit with auto-scope detection |
|
||||||
| `/config-audit posture` | Quick health scorecard (A-F grades, 10 quality areas incl. Token Efficiency, Plugin Hygiene) |
|
| `/config-audit posture` | A-F health scorecard (10 quality areas) |
|
||||||
| `/config-audit tokens` | prompt-cache-aware token hotspots (6 patterns: cache-breaking, redundant perms, deep imports, oversized cascade, bloated SKILL.md desc, MCP tool-schema budget) — optional `--accurate-tokens` API calibration, `--with-telemetry-recipe` cache-hit recipe pointer |
|
| `/config-audit tokens` | Prompt-cache-aware token hotspots, each tagged with its load pattern; cache-aware |
|
||||||
| `/config-audit manifest` | Ranked table of every system-prompt token source (CLAUDE.md, plugins, skills, MCP, hooks) sorted by estimated tokens |
|
| `/config-audit manifest` | Ranked table of every token source + always-loaded subtotal |
|
||||||
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact |
|
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact |
|
||||||
|
| `/config-audit optimize` | Mechanism-fit lens (procedure→skill, lifecycle→hook, path→rule, never→permission). Agent-driven, **not byte-stable** |
|
||||||
| `/config-audit fix` | Auto-fix deterministic issues with backup + verification |
|
| `/config-audit fix` | Auto-fix deterministic issues with backup + verification |
|
||||||
| `/config-audit rollback` | Restore configuration from backup |
|
| `/config-audit rollback` | Restore configuration from backup |
|
||||||
| `/config-audit plan` | Create action plan from audit findings |
|
| `/config-audit plan` | Create action plan from findings |
|
||||||
| `/config-audit implement` | Execute plan with backups + auto-verify |
|
| `/config-audit implement` | Execute plan with backups + auto-verify |
|
||||||
| `/config-audit help` | Show all commands |
|
| `/config-audit help` | Show all commands |
|
||||||
|
|
||||||
|
|
@ -32,7 +28,9 @@ Analyzes and optimizes Claude Code configuration across three pillars:
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `/config-audit drift` | Compare current config against saved baseline |
|
| `/config-audit drift` | Compare current config against saved baseline |
|
||||||
| `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence |
|
| `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence |
|
||||||
| `/config-audit whats-active` | Read-only inventory of plugins, skills, MCP, hooks, CLAUDE.md active for a repo (with token estimates) |
|
| `/config-audit whats-active` | Read-only inventory of active plugins/skills/MCP/hooks/CLAUDE.md (with token estimates) |
|
||||||
|
| `/config-audit knowledge-refresh` | Refresh the best-practices register (stale check + web poll). Human-approved writes; **not byte-stable** |
|
||||||
|
| `/config-audit campaign` | Machine-wide audit ledger + token bill across repos. Human-approved writes; **not byte-stable** |
|
||||||
| `/config-audit discover` | Run discovery phase only |
|
| `/config-audit discover` | Run discovery phase only |
|
||||||
| `/config-audit analyze` | Run analysis phase only |
|
| `/config-audit analyze` | Run analysis phase only |
|
||||||
| `/config-audit interview` | Gather user preferences (opt-in) |
|
| `/config-audit interview` | Gather user preferences (opt-in) |
|
||||||
|
|
@ -48,60 +46,46 @@ Analyzes and optimizes Claude Code configuration across three pillars:
|
||||||
| planner-agent | Create action plan | opus | yellow | Read, Glob, Write |
|
| planner-agent | Create action plan | opus | yellow | Read, Glob, Write |
|
||||||
| implementer-agent | Execute changes | sonnet | magenta | Read, Write, Edit, Bash, Glob |
|
| implementer-agent | Execute changes | sonnet | magenta | Read, Write, Edit, Bash, Glob |
|
||||||
| verifier-agent | Verify results | sonnet | purple | Read, Glob, Grep |
|
| verifier-agent | Verify results | sonnet | purple | Read, Glob, Grep |
|
||||||
| feature-gap-agent | Context-aware feature recommendations | opus | green | Read, Glob, Grep, Write |
|
| feature-gap-agent | Feature recommendations | opus | green | Read, Glob, Grep, Write |
|
||||||
|
| optimization-lens-agent | Mechanism-fit precision gate | opus | orange | Read, Glob, Grep, Write |
|
||||||
|
|
||||||
## Hooks
|
## Hooks
|
||||||
|
|
||||||
| Event | Script | Purpose |
|
| Event | Script | Purpose |
|
||||||
|-------|--------|---------|
|
|-------|--------|---------|
|
||||||
| PreToolUse | `auto-backup-config.mjs` | Auto-backup config files before Edit/Write |
|
| PreToolUse | `auto-backup-config.mjs` | Backup config files before Edit/Write |
|
||||||
| PostToolUse | `post-edit-verify.mjs` | Verify config files after Edit/Write, block on new critical/high |
|
| PostToolUse | `post-edit-verify.mjs` | Verify after Edit/Write, block on new critical/high |
|
||||||
| SessionStart | `session-start.mjs` | Checks for active (unfinished) sessions |
|
| SessionStart | `session-start.mjs` | Check for active (unfinished) sessions |
|
||||||
| Stop | `stop-session-reminder.mjs` | Reminds about current session phase |
|
| Stop | `stop-session-reminder.mjs` | Remind about current session phase |
|
||||||
|
|
||||||
## Reference docs (read on demand)
|
## Reference docs (read on demand)
|
||||||
|
|
||||||
- **Scanner inventory, lib modules, action engines, knowledge base:** `docs/scanner-internals.md`
|
- `docs/scanner-internals.md` — scanner inventory, lib modules, action engines, knowledge base, per-scanner/per-block implementation notes (design rationale, primary-source verification, byte-stability lessons)
|
||||||
- **Plain-language output (v5.1.0), humanizer vocabularies, output modes:** `docs/humanizer.md`
|
- `docs/humanizer.md` — plain-language output (v5.1.0), humanizer vocabularies, output modes
|
||||||
|
|
||||||
## Plain-Language Output (v5.1.0) — summary
|
## Plain-Language Output (v5.1.0)
|
||||||
|
|
||||||
Default output of all 18 commands routes through `humanizeEnvelope` from `lib/humanizer.mjs`. Findings get three decorated fields:
|
Default output of all commands routes through `humanizeEnvelope` (`lib/humanizer.mjs`), decorating each finding with `userImpactCategory`, `userActionLanguage`, and `relevanceContext`. `--raw` and `--json` bypass the humanizer for byte-stable v5.0.0 output. Full detail: `docs/humanizer.md`.
|
||||||
|
|
||||||
- `userImpactCategory` — Configuration mistake / Conflict / Wasted tokens / Dead config / Missed opportunity
|
|
||||||
- `userActionLanguage` — Fix this now / Fix soon / Fix when convenient / Optional cleanup / FYI (derived from severity)
|
|
||||||
- `relevanceContext` — `affects-everyone` (default) / `affects-this-machine-only` (`*.local.*` files) / `test-fixture-no-impact`
|
|
||||||
|
|
||||||
`--raw` bypasses the humanizer for byte-stable v5.0.0 output. `--json` is also byte-stable. Full detail and Wave 5 lessons: `docs/humanizer.md`.
|
|
||||||
|
|
||||||
## Suppressions
|
## Suppressions
|
||||||
|
|
||||||
Create `.config-audit-ignore` at project root to suppress known findings:
|
Create `.config-audit-ignore` at project root — one exact ID or glob per line (`CA-SET-003`, `CA-GAP-*`). Suppressed findings are tracked in the envelope's `suppressed_findings` for audit trail. Disable with `--no-suppress`.
|
||||||
```
|
|
||||||
CA-SET-003 # Exact ID
|
|
||||||
CA-GAP-* # Glob pattern (all GAP findings)
|
|
||||||
```
|
|
||||||
Suppressed findings tracked in envelope's `suppressed_findings` for audit trail. Disable with `--no-suppress`.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Workflow
|
Workflow: `/config-audit → discover + analyze (auto) → plan → implement → verify`. Auto-detects scope from git context; override with `full|repo|home|current`; `--delta` for incremental. Session state lives under `~/.claude/config-audit/sessions/{id}/` (scope.yaml, discovery.json, state.yaml, findings/, analysis-report.md, action-plan.md, backups/, implementation-log.md).
|
||||||
```
|
|
||||||
/config-audit → discover + analyze (auto) → plan → implement → verify
|
|
||||||
```
|
|
||||||
Default: auto-detects scope from git context. Override with `/config-audit full|repo|home|current`. Delta mode: `--delta` (incremental).
|
|
||||||
|
|
||||||
### Session Directory
|
Finding ID format: `CA-{SCANNER}-{NNN}` — e.g. `CA-CML-001`, `CA-SET-003`, `CA-HKV-002`, `CA-RUL-005`, `CA-TOK-005`, `CA-CPS-001`, `CA-SKL-001`, `CA-OST-001`, `CA-OPT-001`, `CA-AGT-001`.
|
||||||
```
|
|
||||||
~/.claude/config-audit/sessions/{session-id}/
|
|
||||||
├── scope.yaml, discovery.json, state.yaml
|
|
||||||
├── findings/, analysis-report.md, action-plan.md
|
|
||||||
├── backups/, implementation-log.md
|
|
||||||
└── interview.md (if interview run)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Finding ID Format
|
## Conventions
|
||||||
`CA-{SCANNER}-{NNN}` — e.g. `CA-CML-001`, `CA-SET-003`, `CA-HKV-002`, `CA-RUL-005`, `CA-TOK-005`, `CA-CPS-001`, `CA-DIS-001`, `CA-COL-001`, `CA-SKL-001`
|
|
||||||
|
Enforced conventions live in `.claude/rules/` (auto-loaded as project instructions):
|
||||||
|
- `ux-rules.md` — output/narration/formatting for all commands (never dump raw JSON, narrate before each step, space-separated command suggestions)
|
||||||
|
- `command-development.md` — required command frontmatter + `plugin:action` naming
|
||||||
|
- `agent-development.md` — agent frontmatter + "when to use" conventions
|
||||||
|
- `state-management.md` — update `state.yaml` after every workflow phase
|
||||||
|
|
||||||
|
Coding style: scanners are zero-dependency Node ESM; new findings use the `CA-{SCANNER}-{NNN}` ID format; byte-stable CLIs are verified against frozen `tests/snapshots/v5.0.0/` baselines.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|
@ -109,10 +93,10 @@ Default: auto-detects scope from git context. Override with `/config-audit full|
|
||||||
node --test 'tests/**/*.test.mjs'
|
node --test 'tests/**/*.test.mjs'
|
||||||
```
|
```
|
||||||
|
|
||||||
875 tests across 55 test files (16 lib + 29 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`.
|
Test fixtures in `tests/fixtures/`. Per-scanner and per-build-block implementation notes (design rationale, primary-source verification, byte-stability lessons) live in `docs/scanner-internals.md` → **Implementation notes**.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Session directories accumulate — use `/config-audit cleanup` to manage
|
- Session directories accumulate — use `/config-audit cleanup` to manage
|
||||||
- Scanners run on Node.js >= 18 (uses node:test, node:fs/promises)
|
- Scanners run on Node.js ≥ 18 (uses node:test, node:fs/promises)
|
||||||
- Plugin CLAUDE.md files in node_modules should be excluded via scope
|
- Plugin CLAUDE.md files in node_modules should be excluded via scope
|
||||||
|
|
|
||||||
182
README.md
182
README.md
|
|
@ -6,22 +6,22 @@
|
||||||
|
|
||||||
*AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)*
|
*AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)*
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 13 deterministic scanners across 10 quality areas, context-aware feature recommendations, auto-fix with backup/rollback, a prompt-cache-aware Token Hotspots scanner with optional API-calibrated `--accurate-tokens` mode, plus cache-prefix stability, dead-tool, and cross-plugin collision detection. Zero external dependencies.
|
A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 16 deterministic scanners across 10 quality areas, context-aware feature recommendations, auto-fix with backup/rollback, a prompt-cache-aware Token Hotspots scanner with optional API-calibrated `--accurate-tokens` mode, plus cache-prefix stability, dead-tool, cross-plugin collision, output-style, and always-loaded agent-listing-budget detection. Zero external dependencies.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [What's New in v5.2.0](#whats-new-in-v520)
|
- [What's New in v5.4.0](#whats-new-in-v540)
|
||||||
- [What Is This?](#what-is-this)
|
- [What Is This?](#what-is-this)
|
||||||
- [The Configuration Problem](#the-configuration-problem)
|
- [The Configuration Problem](#the-configuration-problem)
|
||||||
- [Quick Start](#quick-start)
|
- [Quick Start](#quick-start)
|
||||||
|
|
@ -45,16 +45,28 @@ A Claude Code plugin that checks configuration health, suggests context-aware im
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What's New in v5.2.0
|
## What's New in v5.4.0
|
||||||
|
|
||||||
**Claude Code 2.1.114→181 compatibility + skill-listing budget.** A new orchestrated
|
**Plugin-hygiene & settings-validation hardening.** Three additive findings extend the plugin and
|
||||||
scanner, **SKL**, checks the model's skill-listing token budget: `CA-SKL-001` flags any
|
settings surfaces — no new scanner, so the count stays **13**:
|
||||||
active skill description over the 1,536-char listing cap (silently truncated by CC 2.1.105),
|
|
||||||
and `CA-SKL-002` flags when the summed descriptions exceed the listing budget (~2% of context).
|
- **PLH plugin-folder shadowing** (`CA-PLH-015`) — flags a `plugin.json` component-path key in the
|
||||||
Five validators were refreshed for the settings and hook surface that shipped across
|
*replaces* set (`commands`/`agents`/`outputStyles`) that points at a custom path while the
|
||||||
CC 2.1.114–181 (new settings keys, `xhigh` effort, `MessageDisplay` + post-session hook
|
default folder of that name still exists, so the folder is silently ignored (dead config).
|
||||||
events), and an adversarial gap-review eliminated a batch of false positives in the MCP and
|
Mirrors Claude Code's own warning in `/doctor`, `claude plugin list`, and the `/plugin` detail
|
||||||
permissions scanners. → **13 orchestrated deterministic scanners** (+ standalone plugin-health).
|
view. `skills` is excluded (it *adds to* the default scan, never shadows), as are
|
||||||
|
`hooks`/`mcpServers`/`lspServers` (own merge rules); a custom path resolving *into* the default
|
||||||
|
folder is not flagged.
|
||||||
|
- **PLH `skills:`-array validation** (`CA-PLH-016`) — validates each `plugin.json` `skills` entry
|
||||||
|
(string or array) resolves to an existing directory inside the plugin root; flags `non-string`,
|
||||||
|
`escapes-root`, `not-found`, and `not-a-directory` entries. Mirrors `claude plugin validate`.
|
||||||
|
- **SET `autoMode` structure + dead-config** — checks that `autoMode` is an object whose only keys
|
||||||
|
are `environment`/`allow`/`soft_deny`/`hard_deny`, each a string array (the literal `"$defaults"`
|
||||||
|
is valid); unknown sub-keys and wrong types are flagged (medium). Separately, `autoMode` placed
|
||||||
|
in **shared** project settings (`.claude/settings.json`) is flagged as dead config (low) —
|
||||||
|
Claude Code's classifier does not read it there.
|
||||||
|
|
||||||
|
All three extend existing PLH and SET scanners. `--json` and `--raw` output remain byte-stable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -64,7 +76,7 @@ Claude Code reads instructions from at least 7 different file types across multi
|
||||||
|
|
||||||
This plugin provides three layers of configuration intelligence:
|
This plugin provides three layers of configuration intelligence:
|
||||||
|
|
||||||
- **Health** — 13 deterministic scanners verify correctness across every configuration file, catching broken imports, deprecated settings, conflicting rules, format errors, permission contradictions, prompt-cache token waste, cache-prefix instability, dead tool grants, and cross-plugin skill collisions
|
- **Health** — 16 deterministic scanners verify correctness across every configuration file, catching broken imports, deprecated settings, conflicting rules, format errors, permission contradictions, prompt-cache token waste, cache-prefix instability, dead tool grants, cross-plugin skill collisions, output styles that silently strip Claude Code's coding instructions, an oversized always-loaded agent listing, and procedures in CLAUDE.md that would fit better as a skill
|
||||||
- **Opportunities** — context-aware recommendations for Claude Code features that could benefit your specific project, backed by Anthropic's official guidance
|
- **Opportunities** — context-aware recommendations for Claude Code features that could benefit your specific project, backed by Anthropic's official guidance
|
||||||
- **Action** — auto-fix with mandatory backups, syntax validation, rollback support, and a human-in-the-loop workflow for anything non-trivial
|
- **Action** — auto-fix with mandatory backups, syntax validation, rollback support, and a human-in-the-loop workflow for anything non-trivial
|
||||||
|
|
||||||
|
|
@ -263,9 +275,10 @@ Your team configuration changes over time. Track it:
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `/config-audit` | Full audit with auto-scope detection (no setup needed) |
|
| `/config-audit` | Full audit with auto-scope detection (no setup needed) |
|
||||||
| `/config-audit posture` | Quick health scorecard: A-F grades across 10 quality areas (incl. Token Efficiency, Plugin Hygiene) |
|
| `/config-audit posture` | Quick health scorecard: A-F grades across 10 quality areas (incl. Token Efficiency, Plugin Hygiene) |
|
||||||
| `/config-audit tokens` | prompt-cache-aware token hotspots — ranked by estimated waste; 6 patterns + optional `--accurate-tokens` API calibration |
|
| `/config-audit tokens` | prompt-cache-aware token hotspots — ranked by estimated waste, each tagged with its load pattern (always / on-demand / external); 8 patterns + optional `--accurate-tokens` API calibration. **Cache-aware:** stale `~/.claude/plugins/cache` versions (superseded installs that load on zero turns) are excluded from the ranking by default — only each plugin's active version is counted; `--no-exclude-cache` restores the full walk. Stale versions surface as a separate **Dead config** disk-cleanup finding |
|
||||||
| `/config-audit manifest` | Ranked table of every system-prompt token source (CLAUDE.md, plugins, skills, MCP, hooks) sorted by estimated tokens |
|
| `/config-audit manifest` | Ranked table of every token source (CLAUDE.md, rules, agents, skills, output styles, MCP, hooks) sorted by estimated tokens — each tagged with its **load pattern** (always-loaded / on-demand / external) plus an **always-loaded subtotal** ("≈X tokens enter context every turn before you type"). Component-level: no coarse plugin roll-up (it would double-count) |
|
||||||
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact |
|
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact |
|
||||||
|
| `/config-audit optimize` | Optimization lens (mechanism-fit): config that works but fits a better mechanism — procedure→skill, lifecycle→hook, unscoped path→rule, "never"→permission. Hybrid motor (deterministic pre-filter + opus precision gate), every finding cites a best-practices-register rule |
|
||||||
| `/config-audit fix` | Auto-fix deterministic issues with backup + verification |
|
| `/config-audit fix` | Auto-fix deterministic issues with backup + verification |
|
||||||
| `/config-audit rollback` | Restore configuration from a previous backup |
|
| `/config-audit rollback` | Restore configuration from a previous backup |
|
||||||
| `/config-audit plan` | Generate prioritized action plan from audit findings |
|
| `/config-audit plan` | Generate prioritized action plan from audit findings |
|
||||||
|
|
@ -279,6 +292,8 @@ Your team configuration changes over time. Track it:
|
||||||
| `/config-audit drift` | Compare current config against a saved baseline |
|
| `/config-audit drift` | Compare current config against a saved baseline |
|
||||||
| `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence |
|
| `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence |
|
||||||
| `/config-audit whats-active` | Read-only inventory of plugins, skills, MCP, hooks, CLAUDE.md active for a repo (with token estimates) |
|
| `/config-audit whats-active` | Read-only inventory of plugins, skills, MCP, hooks, CLAUDE.md active for a repo (with token estimates) |
|
||||||
|
| `/config-audit knowledge-refresh` | Keep the best-practices register fresh — flag stale entries (sources older than ~90d) + poll for new/changed Claude Code practices; **human-approved writes only** (Verifiseringsplikt). Deterministic stale core + web candidate poll |
|
||||||
|
| `/config-audit campaign` | Machine-wide audit campaign — durable ledger above sessions tracking each repo's lifecycle (pending → audited → planned → implemented) + a machine-wide roll-up by severity + a **machine-wide always-loaded token bill** (`refresh-tokens` live cross-repo sweep — the shared global layer counted once + per-repo deltas, ranked "most expensive repos") + a single **cross-repo prioritized backlog** to pick from (severity-weighted) + plan **export** (drop a planned repo's plan into its own `docs/`), resumable across sessions; **human-approved writes only** (read-only report + deterministic write/export CLIs). Execution reuses the existing `/config-audit implement` + `rollback` |
|
||||||
| `/config-audit discover` | Run discovery phase only |
|
| `/config-audit discover` | Run discovery phase only |
|
||||||
| `/config-audit analyze` | Run analysis phase only |
|
| `/config-audit analyze` | Run analysis phase only |
|
||||||
| `/config-audit interview` | Set preferences for action plan _(optional)_ |
|
| `/config-audit interview` | Set preferences for action plan _(optional)_ |
|
||||||
|
|
@ -293,24 +308,104 @@ By default, `/config-audit` auto-detects scope from your git context. Override w
|
||||||
|
|
||||||
## Deterministic Scanners
|
## Deterministic Scanners
|
||||||
|
|
||||||
13 Node.js scanners that perform structural analysis an LLM cannot reliably do: schema validation, circular reference detection, import resolution, conflict detection across scopes, prompt-cache-aware token-cost analysis, cache-prefix stability, dead-tool detection, and cross-plugin skill collisions. Plus a standalone plugin-health scanner. Zero external dependencies.
|
15 Node.js scanners that perform structural analysis an LLM cannot reliably do: schema validation, circular reference detection, import resolution, conflict detection across scopes, prompt-cache-aware token-cost analysis, cache-prefix stability, dead-tool detection, cross-plugin skill collisions, output-style validation, and a best-practice optimization lens (mechanism-fit). Plus a standalone plugin-health scanner. Zero external dependencies.
|
||||||
|
|
||||||
**Why deterministic?** LLMs are powerful at understanding intent and context. But they cannot reliably validate JSON schemas, detect circular `@import` chains, or catch that your global `settings.json` contradicts your project-level one. These scanners fill that gap — fast, repeatable, and zero false positives on structural issues.
|
**Why deterministic?** LLMs are powerful at understanding intent and context. But they cannot reliably validate JSON schemas, detect circular `@import` chains, or catch that your global `settings.json` contradicts your project-level one. These scanners fill that gap — fast, repeatable, and zero false positives on structural issues.
|
||||||
|
|
||||||
| Scanner | Prefix | What It Catches |
|
| Scanner | Prefix | What It Catches |
|
||||||
|---------|--------|-----------------|
|
|---------|--------|-----------------|
|
||||||
| `claude-md-linter.mjs` | CML | Oversized files, missing sections, broken @imports, duplicates, stale TODOs |
|
| `claude-md-linter.mjs` | CML | Oversized files (line count **plus** a context-window-scaled char budget mirroring Claude Code's ~40.0k-char startup warning), missing sections, broken @imports, duplicates, stale TODOs |
|
||||||
| `settings-validator.mjs` | SET | Schema violations, unknown/deprecated keys, type mismatches, permission issues |
|
| `settings-validator.mjs` | SET | Schema violations, unknown/deprecated keys, type mismatches, permission issues |
|
||||||
| `hook-validator.mjs` | HKV | Invalid format, missing scripts, wrong event names, timeout risks |
|
| `hook-validator.mjs` | HKV | Invalid format, missing scripts, wrong event names, timeout risks, verbose-stdout scripts, and a low-precision **advisory** (info) when a hook injects un-grepped command output into `hookSpecificOutput.additionalContext` — that payload enters context on every fire (plain stdout does not) |
|
||||||
| `rules-validator.mjs` | RUL | Bad glob patterns, orphaned rules, deprecated fields, unscoped rules |
|
| `rules-validator.mjs` | RUL | Bad glob patterns, orphaned rules, deprecated fields, unscoped rules |
|
||||||
| `mcp-config-validator.mjs` | MCP | Invalid server types, exposed env vars, unknown fields |
|
| `mcp-config-validator.mjs` | MCP | Invalid server types, exposed env vars, unknown fields |
|
||||||
| `import-resolver.mjs` | IMP | Broken @imports, circular references, deep chains, tilde path issues |
|
| `import-resolver.mjs` | IMP | Broken @imports, circular references, deep chains, tilde path issues |
|
||||||
| `conflict-detector.mjs` | CNF | Settings contradictions across scopes, permission conflicts, hook duplicates |
|
| `conflict-detector.mjs` | CNF | Settings contradictions across scopes, permission conflicts, hook duplicates |
|
||||||
| `feature-gap-scanner.mjs` | GAP | 25 feature checks — shown as opportunities, not grades |
|
| `feature-gap-scanner.mjs` | GAP | 25 feature checks shown as opportunities, not grades — plus a conditional `disableBundledSkills` recommendation when the active skill listing is over budget, and a conditional **filter-before-Claude-reads** lever when a hook injects unfiltered output into `additionalContext` (companion to the HKV advisory; cites the documented `filter-test-output.sh` pattern) |
|
||||||
| `token-hotspots.mjs` | TOK | Cache-breaking volatile content, redundant tool permissions, deep import chains, oversized cascades, bloated skill descriptions, MCP tool-schema budget |
|
| `token-hotspots.mjs` | TOK | Cache-breaking volatile content, redundant tool permissions, deep import chains, oversized cascades, bloated skill descriptions, MCP tool-schema budget, and stale `~/.claude/plugins/cache` versions (disk-cleanup, zero live-context impact) — cache-aware ranking excludes superseded plugin versions by default (`--no-exclude-cache` to include) |
|
||||||
| `cache-prefix-scanner.mjs` | CPS | Volatile content in lines 31–150 of the CLAUDE.md cascade — beyond the cache-prefix window but still re-loaded every turn |
|
| `cache-prefix-scanner.mjs` | CPS | Volatile content in lines 31–150 of the CLAUDE.md cascade — beyond Pattern A's top-30 window but still re-loaded every turn — **plus** volatile content inside `@import`-ed files (inlined into the cached prefix, one hop, otherwise invisible to per-file scans) |
|
||||||
| `disabled-in-schema-scanner.mjs` | DIS | Tools listed in BOTH `permissions.deny` and `permissions.allow` — deny wins, allow entries are dead config |
|
| `disabled-in-schema-scanner.mjs` | DIS | Dead/ineffective permission entries: (1) tools in BOTH `permissions.deny` and `permissions.allow` — deny wins (incl. the `Tool(*)` deny-all glob, equivalent to a bare deny); (2) unanchored allow wildcards (`*`, `B*`, `mcp__*`) that Claude Code silently skips — valid only as `mcp__<server>__*`; (3) `Tool(param:value)` rules whose key is the tool's own canonicalizing field (`command`/`file_path`/`path`/`notebook_path`/`url`) — CC ignores these and emits a startup warning |
|
||||||
| `collision-scanner.mjs` | COL | Cross-plugin skill name collisions; user-vs-plugin overlaps |
|
| `collision-scanner.mjs` | COL | Cross-plugin skill name collisions; user-vs-plugin overlaps |
|
||||||
|
| `skill-listing-scanner.mjs` | SKL | Skill-listing token budget: a single skill description over the ~1,536-char listing cap Claude Code truncates (`CA-SKL-001`), the summed active-skill descriptions exceeding the ~2%-of-context listing budget (`CA-SKL-002`), and an oversized SKILL.md **body** over ~5,000 tokens (`CA-SKL-003`, low — on-demand cost: the body loads only when the skill runs, not every turn; recommends supporting-file split + `context: fork`). The `CA-SKL-002` (and CML char-budget) findings accept `--context-window <n>` to calibrate to your real window instead of the conservative 200k anchor (`--context-window auto` keeps the anchor but downgrades to advisory) |
|
||||||
|
| `output-style-scanner.mjs` | OST | Output-style validation: a custom (user/project) style missing `keep-coding-instructions: true` that silently strips built-in software-engineering instructions (`CA-OST-001`), a plugin style with `force-for-plugin: true` overriding the user's selected `outputStyle` (`CA-OST-002`), and a settings `outputStyle` resolving to no built-in or custom style — dead config (`CA-OST-003`) |
|
||||||
|
| `optimization-lens-scanner.mjs` | OPT | Optimization lens (mechanism-fit): a multi-step procedure in CLAUDE.md that would fit better as a skill (`CA-OPT-001`) — reads the machine-readable best-practices register, framed as an opportunity, not a failure. The deterministic half of the lens; prose-judgment cases (lifecycle→hook, unscoped path→rule, "never"→permission) are judged by the opus `optimization-lens-agent` via `/config-audit optimize` |
|
||||||
|
| `agent-listing-scanner.mjs` | AGT | Always-loaded agent-listing budget: a per-agent description over the soft bloat cap (`CA-AGT-001`, advisory) and the summed active-agent name+description listing — re-sent every turn — exceeding the listing budget (`CA-AGT-002`). Both LOW and explicitly **inferred / upper-bound**: the agent-listing mechanism is undocumented, so the evidence discloses the estimate and heuristic budget rather than overstating certainty |
|
||||||
|
|
||||||
|
> **Cross-scanner remediation — diagnosis meets the fix.** SKL diagnoses an over-budget
|
||||||
|
> skill listing (`CA-SKL-002`); GAP prescribes the remedy. When the active skill listing
|
||||||
|
> exceeds its ~2%-of-context budget and `disableBundledSkills` is not already set (in the
|
||||||
|
> env var or the settings cascade), the feature-gap scanner recommends that lever — hiding
|
||||||
|
> Claude Code's bundled skills (`/code-review`, `/batch`, `/debug`, `/loop`, `/claude-api`, …)
|
||||||
|
> from the model to reclaim listing budget without touching your own skills (CC 2.1.169+).
|
||||||
|
> It fires only under measured pressure, so it stays an opportunity rather than noise. Both
|
||||||
|
> scanners share one budget definition (`scanners/lib/skill-listing-budget.mjs`).
|
||||||
|
|
||||||
|
> **CLAUDE.md size — two complementary signals.** CML checks line count (200/500, for
|
||||||
|
> readability) **and** a character budget that mirrors Claude Code's own startup warning
|
||||||
|
> — *"Large CLAUDE.md will impact performance (X chars > 40.0k)."* CC 2.1.169 scales that
|
||||||
|
> threshold with the model's context window, so the char finding anchors on a conservative
|
||||||
|
> 200k window and discloses the relaxed ~200,000-char figure at 1M context. A file can be
|
||||||
|
> long by lines yet under the char budget (short lines), or short by lines yet over it — so
|
||||||
|
> both signals earn their place. The 200k/1M window constants live in the shared
|
||||||
|
> `scanners/lib/context-window.mjs` (single source of truth with the skill-listing budget).
|
||||||
|
|
||||||
|
> **Permission rules CC silently ignores — severity follows intent.** `Tool(param:value)`
|
||||||
|
> matching is real (CC 2.1.178), but the tool's own canonicalizing fields are off-limits:
|
||||||
|
> `command` (Bash/PowerShell), `file_path` (Read/Edit/Write), `path` (Grep/Glob),
|
||||||
|
> `notebook_path` (NotebookEdit), `url` (WebFetch). CC ignores a rule keyed on its tool's
|
||||||
|
> field and emits a startup warning, because `Bash(command:rm *)` is bypassable by a compound
|
||||||
|
> command. DIS splits severity by where the rule lives: in **deny/ask** it is **false
|
||||||
|
> security** (medium — the block you intended never applies), in **allow** it is **dead
|
||||||
|
> config** (low — `param:value` matching is deny/ask-only, so the entry grants nothing). The
|
||||||
|
> predicate lives in `scanners/lib/permission-rules.mjs`; valid forms like `Bash(npm:*)`,
|
||||||
|
> `WebFetch(domain:host)`, and `Agent(model:opus)` are never flagged.
|
||||||
|
|
||||||
|
> **Plugin namespace collisions — the one shadow that actually loses components.**
|
||||||
|
> Claude Code namespaces every plugin component by the plugin's declared `name`
|
||||||
|
> (`/name:command`, `name:skill`, agent `name`), so a plugin component can never shadow a
|
||||||
|
> user- or project-level one — they live in separate namespaces. The real hazard is two
|
||||||
|
> plugins that declare the **same** `name` in `plugin.json`: their namespaces collapse into
|
||||||
|
> one, and because the resolution between two installed same-name plugins is undocumented,
|
||||||
|
> one plugin's commands, skills, and agents are silently shadowed and become unreachable.
|
||||||
|
> The standalone plugin-health scanner (PLH) flags this at **medium** severity, keying on the
|
||||||
|
> declared `name` field rather than the folder name (the folder name is irrelevant to the
|
||||||
|
> namespace). A *command* name shared by two **differently-named** plugins is a milder case —
|
||||||
|
> namespacing keeps both reachable as `/a:cmd` and `/b:cmd`, so it is only ambiguity in error
|
||||||
|
> messages, search results, and the command listing. PLH reports that at **low** severity
|
||||||
|
> (group-first, one finding per command name), mirroring the COL scanner, which owns the
|
||||||
|
> analogous skill-name overlaps across *different* namespaces.
|
||||||
|
|
||||||
|
> **Plugin-folder shadowing — when a manifest path silently buries a default folder.**
|
||||||
|
> A plugin's `plugin.json` can point a component type at a custom path — `commands`,
|
||||||
|
> `agents`, and `outputStyles` all *replace* their default folder when set. So if a plugin
|
||||||
|
> declares `"commands": "./custom/"` while a `commands/` folder still exists, Claude Code
|
||||||
|
> stops scanning `commands/` entirely and everything in it silently disappears (dead config).
|
||||||
|
> PLH flags this at **medium** severity (`CA-PLH-015`), mirroring Claude Code's own warning in
|
||||||
|
> `/doctor` and `claude plugin list` (v2.1.140+). It does **not** flag `skills` — that key
|
||||||
|
> *adds to* the default `skills/` scan rather than replacing it, so both load — nor does it
|
||||||
|
> flag a custom path that points back into the default folder (e.g.
|
||||||
|
> `"commands": ["./commands/x.md"]`), because the folder is then addressed explicitly.
|
||||||
|
|
||||||
|
> **`skills:`-array validation — every listed path must be a real skill folder.**
|
||||||
|
> A plugin's `plugin.json` may list custom skill directories in a `skills` array (each entry a
|
||||||
|
> path to a folder containing `SKILL.md`). PLH validates each entry (`CA-PLH-016`, **medium**)
|
||||||
|
> and flags four ways an entry can be broken: it isn't a string, it points at a path that
|
||||||
|
> **doesn't exist**, it points at a **file** instead of a directory, or it **escapes the plugin
|
||||||
|
> root** (`../…` — installed plugins can't reference files outside their own directory, so the
|
||||||
|
> skill never loads). A valid existing directory is never flagged. This mirrors
|
||||||
|
> `claude plugin validate`. Note `skills` *adds to* the default `skills/` scan, so a custom path
|
||||||
|
> here is never a shadow — it just has to resolve to a real folder.
|
||||||
|
|
||||||
|
> **`autoMode` validation — structure and the shared-settings blind spot.** The SET scanner
|
||||||
|
> checks the auto-mode classifier config two ways. **Structure:** `autoMode` must be an object
|
||||||
|
> whose only keys are `environment`, `allow`, `soft_deny`, and `hard_deny`, each a list of
|
||||||
|
> plain-text rule strings (the literal `"$defaults"` is allowed). An unknown sub-key (e.g. a
|
||||||
|
> typo'd `hard_denies`), a non-object value, or a sub-key that isn't a string array is flagged
|
||||||
|
> **medium** — a typo'd key silently drops those rules. **Scope:** Claude Code does **not** read
|
||||||
|
> `autoMode` from *shared* project settings (`.claude/settings.json`) — "a checked-in repo cannot
|
||||||
|
> inject its own allow rules" — so an `autoMode` block committed there is dead config (**low**);
|
||||||
|
> it only takes effect in user (`~/.claude/settings.json`), local (`.claude/settings.local.json`),
|
||||||
|
> or managed settings.
|
||||||
|
|
||||||
### CLI Tools
|
### CLI Tools
|
||||||
|
|
||||||
|
|
@ -321,8 +416,8 @@ All tools work standalone — no Claude Code session needed:
|
||||||
| **Posture** | `node scanners/posture.mjs <path> [--json] [--global] [--full-machine] [--output-file path]` |
|
| **Posture** | `node scanners/posture.mjs <path> [--json] [--global] [--full-machine] [--output-file path]` |
|
||||||
| **Fix** | `node scanners/fix-cli.mjs <path> [--apply] [--json] [--global]` |
|
| **Fix** | `node scanners/fix-cli.mjs <path> [--apply] [--json] [--global]` |
|
||||||
| **Drift** | `node scanners/drift-cli.mjs <path> [--save] [--baseline name] [--json]` |
|
| **Drift** | `node scanners/drift-cli.mjs <path> [--save] [--baseline name] [--json]` |
|
||||||
| **Tokens** | `node scanners/token-hotspots-cli.mjs <path> [--json] [--global] [--output-file path] [--accurate-tokens] [--with-telemetry-recipe]` |
|
| **Tokens** | `node scanners/token-hotspots-cli.mjs <path> [--json] [--global] [--no-exclude-cache] [--output-file path] [--accurate-tokens] [--with-telemetry-recipe]` |
|
||||||
| **Manifest** | `node scanners/manifest.mjs <path> [--json]` — ranked system-prompt source table |
|
| **Manifest** | `node scanners/manifest.mjs <path> [--json]` — ranked component-level source table with per-source load pattern + always-loaded subtotal |
|
||||||
| **What's active** | `node scanners/whats-active.mjs <path> [--json] [--verbose] [--suggest-disables]` |
|
| **What's active** | `node scanners/whats-active.mjs <path> [--json] [--verbose] [--suggest-disables]` |
|
||||||
| **Self-audit** | `node scanners/self-audit.mjs [--json] [--fix] [--check-readme]` |
|
| **Self-audit** | `node scanners/self-audit.mjs [--json] [--fix] [--check-readme]` |
|
||||||
| **Full scan** | `node scanners/scan-orchestrator.mjs <path> [--global] [--full-machine] [--no-suppress]` |
|
| **Full scan** | `node scanners/scan-orchestrator.mjs <path> [--global] [--full-machine] [--no-suppress]` |
|
||||||
|
|
@ -341,6 +436,7 @@ Six specialized agents collaborate through the audit workflow, each matched to a
|
||||||
| **implementer-agent** | Sonnet | Change execution with mandatory backups | Read, Write, Edit, Bash, Glob |
|
| **implementer-agent** | Sonnet | Change execution with mandatory backups | Read, Write, Edit, Bash, Glob |
|
||||||
| **verifier-agent** | Sonnet | Post-implementation verification | Read, Glob, Grep |
|
| **verifier-agent** | Sonnet | Post-implementation verification | Read, Glob, Grep |
|
||||||
| **feature-gap-agent** | Opus | Context-aware feature recommendations | Read, Glob, Grep, Write |
|
| **feature-gap-agent** | Opus | Context-aware feature recommendations | Read, Glob, Grep, Write |
|
||||||
|
| **optimization-lens-agent** | Opus | Mechanism-fit precision gate — judges prose-judgment lens candidates (lifecycle→hook, path→rule, never→permission), cites the best-practices register | Read, Glob, Grep, Write |
|
||||||
|
|
||||||
### Orchestration Flow
|
### Orchestration Flow
|
||||||
|
|
||||||
|
|
@ -434,7 +530,7 @@ node scanners/posture.mjs examples/optimal-setup/
|
||||||
|
|
||||||
### Self-Audit: Scanning the Scanner
|
### Self-Audit: Scanning the Scanner
|
||||||
|
|
||||||
The plugin runs all 13 scanners + the standalone plugin-health scanner on itself via `self-audit.mjs`. Test fixtures and example files are automatically excluded from scoring — a configuration plugin that ships deliberately broken examples shouldn't fail its own audit. Use `--check-readme` to verify badge counts are in sync with the filesystem.
|
The plugin runs all 16 scanners + the standalone plugin-health scanner on itself via `self-audit.mjs`. Test fixtures and example files are automatically excluded from scoring — a configuration plugin that ships deliberately broken examples shouldn't fail its own audit. Use `--check-readme` to verify badge counts are in sync with the filesystem.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node scanners/self-audit.mjs
|
node scanners/self-audit.mjs
|
||||||
|
|
@ -470,7 +566,7 @@ Shared modules used by all scanners — useful if you're reading the source or e
|
||||||
| `rollback-engine.mjs` | `listBackups()`, `restoreBackup()`, `deleteBackup()` |
|
| `rollback-engine.mjs` | `listBackups()`, `restoreBackup()`, `deleteBackup()` |
|
||||||
| `fix-cli.mjs` | CLI entry point for auto-fix |
|
| `fix-cli.mjs` | CLI entry point for auto-fix |
|
||||||
| `drift-cli.mjs` | CLI entry point for drift detection |
|
| `drift-cli.mjs` | CLI entry point for drift detection |
|
||||||
| `manifest.mjs` | CLI: ranked system-prompt source table (v5 N2) |
|
| `manifest.mjs` | CLI: ranked component-level source table w/ load-pattern accounting (v5 N2; v5.6 B) |
|
||||||
| `whats-active.mjs` | CLI: read-only active-config inventory (v3.1.0+) |
|
| `whats-active.mjs` | CLI: read-only active-config inventory (v3.1.0+) |
|
||||||
| `token-hotspots-cli.mjs` | CLI: token hotspots ranking with optional `--accurate-tokens` |
|
| `token-hotspots-cli.mjs` | CLI: token hotspots ranking with optional `--accurate-tokens` |
|
||||||
|
|
||||||
|
|
@ -491,6 +587,14 @@ Reference documents that inform the feature-gap agent and context-aware recommen
|
||||||
| `prompt-cache-patterns.md` | Token-cost dynamics (prompt-cache patterns) — patterns powering the TOK scanner |
|
| `prompt-cache-patterns.md` | Token-cost dynamics (prompt-cache patterns) — patterns powering the TOK scanner |
|
||||||
| `cache-telemetry-recipe.md` | `jq` recipe for verifying prompt-cache hit rate from session transcripts |
|
| `cache-telemetry-recipe.md` | `jq` recipe for verifying prompt-cache hit rate from session transcripts |
|
||||||
|
|
||||||
|
**Machine-readable register (`best-practices.json`).** Alongside the human-readable documents
|
||||||
|
above, `knowledge/best-practices.json` is a provenance-stamped, schema-validated register of
|
||||||
|
best-practice claims and mechanism-fit rules — each entry carries `source.url`, a `verified`
|
||||||
|
date, and a `confidence`. It is the source of truth for the optimization lens (OPT scanner +
|
||||||
|
`/config-audit optimize`); the Markdown files remain the human-readable mirror. Loaded and validated by
|
||||||
|
`scanners/lib/best-practices-register.mjs` (zero-dependency, native JSON). See
|
||||||
|
`docs/v5.7-optimization-lens-plan.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
@ -499,7 +603,7 @@ Reference documents that inform the feature-gap agent and context-aware recommen
|
||||||
node --test 'tests/**/*.test.mjs'
|
node --test 'tests/**/*.test.mjs'
|
||||||
```
|
```
|
||||||
|
|
||||||
635 tests across 36 test files (12 lib + 23 scanner + 1 hook). Test fixtures in `tests/fixtures/`. Requires Node.js 18+ (`node:test`).
|
1168 tests across 67 test files (22 lib + 35 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Requires Node.js 18+ (`node:test`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -558,6 +662,22 @@ This plugin is cautious by design — configuration files are important, and a b
|
||||||
|
|
||||||
| Version | Date | Highlights |
|
| Version | Date | Highlights |
|
||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
|
| **5.12.5** | 2026-06-26 | "Dogfood denoise" — a samle-release of the Fase-3 scanner false-positive batch (`M-BUG-2/6/7/8/10`, all dogfooding finds on the maintainer's real machine). Five scanners stop counting non-user / non-live config as the user's: **CNF** (`M-BUG-2`) excludes files under `.claude/plugins/` from conflict analysis (`isPluginBundled`) — installed plugins' bundled settings/hooks/fixtures are not a user-resolvable cascade (dogfood **339→0**, Conflicts was an F on pure plugin noise). **file-discovery** (`M-BUG-8`) adds `backups` to `SKIP_DIRS` — a `backups/` tree holds frozen copies, never live config (dogfood files-under-`/backups/` **36→0**, 717 live retained). **token estimator** (`M-BUG-6`) strips block-level `<!-- -->` HTML comments from CLAUDE.md sizing — CC strips them before injection, so they were never always-loaded tokens (dogfood ~3386→3301, ~85 tok). **CPS** (`M-BUG-7`) skips fenced/inline code and whitelists CC-stable path vars (`${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}`) before cache-buster matching (dogfood **5→2**). **SET** (`M-BUG-10`) typo-gates the unknown-settings-key finding — the CC schema is passthrough (verified against the 2.1.193 binary), so an unknown key is forward-compatible, not an error; it now flags only a near-miss of a known key (levenshtein ≤2), severity medium→low, +6 binary-verified `KNOWN_KEYS` (dogfood **6→0**). No count change (scanners **16**, agents **7**, commands **21**, hooks **4**); all five are byte-stable — frozen v5.0.0 + SC-5 + default-output snapshots untouched, no re-seed (each fixture's findings are genuinely unchanged). **1344** tests (+37). |
|
||||||
|
| **5.12.4** | 2026-06-26 | "Rooted rules" — fixes `M-BUG-9` (dogfooding find) in `scanners/rules-validator.mjs`: the RUL "Rule path pattern matches no files" check now resolves a rule's `paths:`/`globs:` pattern against the rule's **own project root** (the dir containing its `.claude/`), not the outer scan root. Previously `countGlobMatches` globbed against the scan target and `collectProjectFiles`' `depth>4` cutoff never reached deep matching files, so a live rule in a **nested repo** (e.g. a marketplace checkout under `~/.claude`) was wrongly flagged "never activates" (high) — a false F-grade for anyone with rules in a nested repo. The fix derives each rule's project root, collects+globs per root (cached), and skips the check for user-global rules (`root === $HOME`), which scope against the active project at runtime. Same scope-conflation family as `M-BUG-1/2`. No count change (scanners **16**, agents **7**, commands **21**); the fix is a no-op when `projectRoot === targetPath` (the common single-repo scan), so frozen v5.0.0 + default-output snapshots stay byte-stable. **1307** tests (+2 TDD: nested-repo false-positive + HOME guard). |
|
||||||
|
| **5.12.3** | 2026-06-26 | "Phantom agents" — fixes `M-BUG-3/4/5` (dogfooding finds) in `scanners/lib/active-config-reader.mjs`: `enumerateAgents` now counts only the agents Claude Code actually **registers**. Per the official subagents doc, an agent needs valid `name`+`description` frontmatter, and CC scans recursively and silently skips frontmatter-less files. The reader previously (`M-BUG-5`) counted every `.md` regardless of frontmatter, (`M-BUG-3`) never recursed into agent subdirs, and (`M-BUG-4`) double-counted when the project dir equals the user dir (scanning `$HOME` — root cause, also affecting rules/output-styles). Real-machine verify: user-agent count **13→0** (all 12 user agents + `REMEMBER.md` are frontmatter-less → CC registers none), HOME `project`-dup **13→0**; corrected always-loaded baseline ≈ **53** (was 66). Agent enumeration is machine-dependent and absent from the frozen snapshots, so the v5.0.0 + SC-5 + default-output snapshots stay byte-stable; no count change (scanners **16**, agents **7**, commands **21**). **1305** tests. |
|
||||||
|
| **5.12.2** | 2026-06-24 | "Honest census" — fixes `M-BUG-1` (dogfooding find): `enumeratePlugins` walked `~/.claude/plugins/marketplaces/<mkt>/plugins/` and ignored both enable-state and the polyrepo cache layout, so it **over-counted phantom agents** from disabled plugins while **missing the entire enabled polyrepo set** (whose plugins live under `cache/`). It now gates on `installed_plugins.json` + `enabledPlugins` and enumerates each plugin from its active `installPath`, with the marketplaces walk as fallback. Fixes `manifest`/`whats-active`/AGT/`token-hotspots` for any user with disabled plugins or a polyrepo marketplace. No count change (scanners **16**, agents **7**, commands **21**); `--json`/`--raw` byte-stable, frozen v5.0.0 + SC-5 + default-output snapshots untouched. Real-machine verify: agent listing 114→104, ghosts gone. **1301** tests. |
|
||||||
|
| **5.12.1** | 2026-06-24 | "Footgun guard" — Pattern H (stale plugin-cache versions, `token-hotspots`) recommended deleting stale version dirs with **no warning** that a currently-running session may still hold one of those versions for its whole lifetime. "Stale" is judged against `installed_plugins.json` (what NEW sessions load), so the recommendation could reproduce the exact failure that breaks a live session: deleting the dir pulls the files out from under the running session, which then must `/exit` + restart. The `plugin-cache-hygiene` recommendation now carries the live-session caveat. **Recommendation string only** — no new finding ID or scanner (count stays **16**, agents **7**, commands **21**), no token figures changed, so `--json`/`--raw` stay byte-stable and the frozen v5.0.0 + SC-5 + default-output snapshots are untouched. **1297** tests. |
|
||||||
|
| **5.12.0** | 2026-06-23 | "Auto-calibration" — completes the deferred B8 half (**B8b**): `--context-window auto` now **probes the configured model** instead of always falling back to advisory. New pure `modelToContextWindow()` maps known 1M-tier model IDs (Fable 5, Opus 4.8/4.7/4.6, Sonnet 4.6 — verified June 2026 — plus the explicit `[1m]` tier tag, dated/provider-prefixed IDs, and the `opus`/`sonnet`/`fable` aliases) to the 1M window; new IO helper `lib/active-model.mjs` `resolveActiveModel()` reads the model the way Claude Code resolves it (shell `ANTHROPIC_MODEL` override, then the settings cascade local > project > user). When `auto` resolves a recognized model the budget calibrates to its window (`auto-probed`, not advisory); when no model is pinned or it is unrecognized it keeps the conservative anchor and stays advisory (`auto-unresolved`) — the honest fallback. No new finding ID or scanner (count stays **16**, agents **7**, commands **21**); the default and explicit `--context-window` paths are unchanged, so `--json`/`--raw` stay byte-stable and the frozen v5.0.0 + SC-5 snapshots are untouched. **1296** tests. |
|
||||||
|
| **5.11.0** | 2026-06-23 | "Precision polish" — the two LOW-priority calibration gaps, both additive (scanner count stays **16**, agents **7**, commands **21**; `--json`/`--raw` byte-stable, frozen v5.0.0 + SC-5 untouched). **B7 — oversized skill body (`CA-SKL-003`, low):** the SKL scanner now measures the SKILL.md **body** (it already read the file in full) and flags bodies over ~5,000 tokens, recommending a supporting-file split + `context: fork`. Honestly framed as an **on-demand** cost — the body loads only when the skill is invoked, **not** every turn like the always-loaded listing — hence low severity. **B8 — context-window calibration (`--context-window`):** `CA-SKL-002` (skill-listing budget) and the CML char-budget now calibrate to a real context window via `--context-window <n>` (e.g. `1000000` stops the 200k anchor crying wolf on a 1M host) instead of always anchoring at 200k; `--context-window auto` keeps the conservative anchor but **downgrades budget findings to info/advisory** rather than firing a breach (model→window auto-probing deferred to a later B8b). No flag → byte-identical to the pre-B8 200k default. CPS is intentionally excluded (no window-anchored budget to calibrate). 1279 tests |
|
||||||
|
| **5.10.0** | 2026-06-23 | "Deferral & injection hygiene" — three additive hardening levers that extend existing scanners toward a tighter always-loaded prefix (scanner count stays **16**, agents **7**, commands **21**; `--json`/`--raw` byte-stable, frozen v5.0.0 + SC-5 untouched). **B4 — MCP tool-schema deferral (`CA-TOK-006`; tokens patterns 7→8):** Claude Code defers MCP tool schemas (names-only, ~120 tok; full schemas load on demand) by default, so `CA-TOK-006` detects config-file signals that force the FULL schemas into the always-loaded prefix every turn — `env.ENABLE_TOOL_SEARCH="false"` (high), a `"ToolSearch"` deny (high), a configured Haiku model (medium), or per-server `alwaysLoad:true` (CC v2.1.121+, high); severity scales with the aggregate forced-upfront tokens. New pure engine `lib/mcp-deferral.mjs` shared by TOK + GAP, plus a feature-gap **CLI-over-MCP** companion lever (prefer `gh`/`aws`/`gcloud`). Triggers on config files ONLY — Vertex / custom `ANTHROPIC_BASE_URL` / a runtime `/model` switch are launch state and are disclosed, never triggered; the prefix-cache-invalidation claim was NOT-CONFIRMED in docs and is not asserted. **B5 — hook `additionalContext` advisory + filter-before lever:** HKV emits an info advisory when a hook injects unfiltered output into `additionalContext`, with a feature-gap **filter-before-Claude-reads** companion citing the documented `filter-test-output.sh` pattern. **B6 — CPS `@import` volatile scan:** the cache-prefix scanner now follows `@import`s (one hop) and flags volatile content in the imported file that breaks the cached prefix — a new medium finding, keyed on the resolved file. 1257 tests |
|
||||||
|
| **5.9.0** | 2026-06-23 | "Machine-wide token lens" — the three highest-impact hardening gaps toward whole-machine token tuning. **B1 — agent-listing budget (new orchestrated scanner AGT, count 15→16):** the always-loaded agent listing (name+description re-sent every turn) is now measured — `CA-AGT-001` per-agent description bloat (advisory), `CA-AGT-002` aggregate listing over budget; both LOW and explicitly **inferred / upper-bound** (the mechanism is undocumented — the evidence discloses it rather than overstating). **B2 — machine-wide always-loaded token roll-up:** the campaign ledger now carries a token bill — `campaign refresh-tokens` does a live cross-repo sweep that counts the **shared global always-loaded layer once** + per-repo deltas, with a ranked "most expensive repos" table (the `whats-active` double-count, avoided by construction). **B3 — cache-aware filtering (folds in B0):** `~/.claude/plugins/cache` holds *both* active and stale plugin versions (installPaths point INTO it), so token-hotspots + CNF are now **version-aware** — `--exclude-cache` (default ON) keeps each plugin's active version and drops only stale ones (`installed_plugins.json`-driven), so stale versions stop polluting the hotspot ranking and inflating duplicate-hook conflicts; stale versions surface as a separate **Dead config** disk-cleanup finding (zero live-context impact). `--json`/`--raw` byte-stable; frozen v5.0.0 + SC-5 snapshots untouched. 1215 tests |
|
||||||
|
| **5.8.0** | 2026-06-23 | "Campaign motor" — a durable, machine-wide audit **campaign** that sits ABOVE individual sessions (one repo = one session; a fleet of repos = a campaign). **Ledger:** `~/.claude/config-audit/campaign-ledger.json` (outside the plugin dir → survives uninstall/upgrade) tracks a repo list + per-repo lifecycle (pending→audited→planned→implemented) + a machine-wide roll-up by status & severity; pure transforms with injected `now`. **`/config-audit campaign` (commands 20→21):** read-only report (`campaign-cli`) + human-approved writes (`campaign-write-cli`: init / add / set-status) — reports first, mutates only on explicit approval, never hand-edits the ledger. **Cross-repo backlog:** one severity-weighted prioritized pick-list (`buildBacklog`, `critical:1000/high:100/medium:10/low:1`). **Plan export + execution-by-reuse:** `campaign-export-cli --write` drops a planned repo's plan verbatim into its own `docs/`; execution reuses the existing `/config-audit implement` + `rollback` (no new execution machinery). All campaign code is `-cli`/lib → scanner count stays **15**, agents **7**, byte-stable. Plus pre-release cleanup: `knowledge-refresh` wired into the router + help; CLAUDE.md trimmed 540→134 lines (impl notes → `docs/scanner-internals.md`, config grade B→A). 1168 tests |
|
||||||
|
| **5.7.0** | 2026-06-21 | "Optimization lens" — first detector of the «optimally shaped?» axis (vs «correct?»), plus a living knowledge layer. **Register:** `knowledge/best-practices.json`, a provenance-stamped, schema-validated best-practices register (first runtime-consumed `knowledge/` file). **OPT scanner (count 14→15):** `CA-OPT-001` (LOW) a ≥6-step CLAUDE.md procedure that would fit better as a skill, citing register entry `BP-MECH-003`. **`/config-audit optimize` + `optimization-lens-agent` (opus, agents 6→7):** prose-judgment lens for lifecycle→hook (`BP-MECH-001`), unscoped path→rule (`BP-MECH-002`), "never"→permission (`BP-MECH-004`); pre-filter recall + opus precision gate. **`/config-audit knowledge-refresh` (commands 19→20):** deterministic stale-check (injected reference date, 90-day cadence) + web re-verify/poll, human-approved writes only. Last two are agent/web-driven (not byte-stable). 1091 tests |
|
||||||
|
| **5.6.0** | 2026-06-20 | "Steering-model II" — the load-pattern / compaction-survival model lands end-to-end. **Foundation:** `active-config-reader` now enumerates rules, agents, and output styles (alongside CLAUDE.md/plugins/skills/hooks/MCP), each tagged `loadPattern` (always / on-demand / external) + `survivesCompaction` from the published loading model; the frontmatter parser also reads YAML block sequences (`paths:` lists). **B (load-pattern accounting):** `manifest` reports component-level sources (the double-counting plugin roll-up is gone), tags every source with the load-pattern triple, and leads with an **always-loaded subtotal** ("tokens that enter context every turn"); `token-hotspots` annotates each ranked hotspot with its load pattern. **C (output styles):** new orchestrated **OST** scanner (count 13→**14**) — `CA-OST-001` a custom style stripping built-in coding instructions (missing `keep-coding-instructions: true`, V10), `CA-OST-002` a plugin style with `force-for-plugin: true` overriding the user's `outputStyle` (V11), `CA-OST-003` a settings `outputStyle` resolving to no known style (dead config). Doc-verified; frozen v5.0.0 snapshots preserved via strip-helpers, SC-5 regenerated. 1023 tests |
|
||||||
|
| **5.5.0** | 2026-06-20 | "Steering-model I" — two additive compaction-durability / dead-config findings (count stays **13**, `--json`/`--raw` byte-stable). Per the official "what survives compaction" model: RUL flags a large (>50-line) **path-scoped** rule not re-injected after compaction (LOW); CML flags a **nested** (subdir) CLAUDE.md not re-injected after compaction (LOW). PLH flags a plugin agent setting `hooks`/`mcpServers`/`permissionMode` — Claude Code ignores these for plugin subagents, so it's dead config (`permissionMode` = MEDIUM false-security, `hooks`/`mcpServers` = LOW). Known limitation: the frontmatter parser reads inline `paths:` but not YAML block sequences (deferred to v5.6 Foundation). 961 tests |
|
||||||
|
| **5.4.1** | 2026-06-20 | Scanner-correctness patch (count stays **13**, `--json`/`--raw` byte-stable). HKV: added `Setup`/`UserPromptExpansion`/`PostToolBatch` to the valid-event set (a valid hook using one was wrongly flagged "will never fire"), and **removed** `post-session` (the 2.1.169 `post-session` is a self-hosted-runner workspace-lifecycle hook, **not** a settings.json event — absent from `hooks.md`; verified 2026-06-20). RUL: globs-rule wording corrected — only `paths:` is documented, so the finding drops the unverified "deprecated/legacy" claim and steers to the documented field. PLH: optional `model`/`tools`/`name`/`allowed-tools` frontmatter no longer required; CLAUDE.md component-section required only for components the plugin actually ships. 954 tests |
|
||||||
|
| **5.4.0** | 2026-06-19 | Plugin-hygiene & settings-validation hardening. Three additive findings extend existing PLH and SET scanners (count stays **13**): PLH plugin-folder shadowing (`CA-PLH-015` — a `plugin.json` component-path key in the *replaces* set `commands`/`agents`/`outputStyles` pointing at a custom path while the default folder still exists) mirroring CC's `/doctor` & `claude plugin list` warning; PLH `skills:`-array validation (`CA-PLH-016` — each entry must resolve to a directory in the plugin root; flags `non-string`/`escapes-root`/`not-found`/`not-a-directory`) mirroring `claude plugin validate`; SET `autoMode` structure (only `environment`/`allow`/`soft_deny`/`hard_deny` string arrays) + dead-config (`autoMode` in shared `.claude/settings.json` is not read by CC). `--json`/`--raw` byte-stable. 949 tests |
|
||||||
|
| **5.3.0** | 2026-06-19 | Permission-rule & plugin-hygiene hardening. Five additive scanner findings extend existing scanners (count stays **13**): DIS forbidden-param rules (`Tool(param:value)` on a canonicalizing field — deny/ask = false security, allow = dead config) and ineffective allow-wildcards + `Tool(*)` deny-all; CML context-window-scaled 40.0k-char CLAUDE.md budget mirroring CC's startup warning; PLH plugin namespace collision (two plugins declaring the same `name`); feature-gap `disableBundledSkills` lever under skill-listing pressure. PLH cross-plugin command-name overlap reframed HIGH → LOW (namespacing keeps both reachable). `--json`/`--raw` byte-stable. 936 tests |
|
||||||
| **5.2.0** | 2026-06-18 | CC 2.1.114→181 compatibility + skill-listing budget. New orchestrated scanner **SKL** (`CA-SKL-001` 1,536-char listing cap, `CA-SKL-002` listing-budget sum) → 13 orchestrated scanners. Five validators refreshed for CC 2.1.114–181 settings/hook surface (`xhigh` effort, `MessageDisplay` + post-session events, 28 hook events). False positives eliminated in MCP (auto-injected/POSIX env vars, invented `trust` field) and permissions (param-aware DIS/CNF). Hermetic HOME isolation across all CLI-spawning tests. 875 tests |
|
| **5.2.0** | 2026-06-18 | CC 2.1.114→181 compatibility + skill-listing budget. New orchestrated scanner **SKL** (`CA-SKL-001` 1,536-char listing cap, `CA-SKL-002` listing-budget sum) → 13 orchestrated scanners. Five validators refreshed for CC 2.1.114–181 settings/hook surface (`xhigh` effort, `MessageDisplay` + post-session events, 28 hook events). False positives eliminated in MCP (auto-injected/POSIX env vars, invented `trust` field) and permissions (param-aware DIS/CNF). Hermetic HOME isolation across all CLI-spawning tests. 875 tests |
|
||||||
| **5.1.0** | 2026-05-01 | Plain-language UX humanizer. Default output of all 18 commands now leads with prose; findings grouped by user-impact category (Configuration mistake, Conflict, Wasted tokens, Missed opportunity, Dead config) and led by urgency phrase (Fix this now → FYI). New `--raw` flag preserves v5.0.0 verbatim output for tooling that scrapes stderr; `--json` is unchanged and byte-stable. New scanner-lib modules: `humanizer.mjs`, `humanizer-data.mjs` with TRANSLATIONS for 13 scanner prefixes. Self-audit terminal output also humanized. 792 tests (+157 humanizer-tester) |
|
| **5.1.0** | 2026-05-01 | Plain-language UX humanizer. Default output of all 18 commands now leads with prose; findings grouped by user-impact category (Configuration mistake, Conflict, Wasted tokens, Missed opportunity, Dead config) and led by urgency phrase (Fix this now → FYI). New `--raw` flag preserves v5.0.0 verbatim output for tooling that scrapes stderr; `--json` is unchanged and byte-stable. New scanner-lib modules: `humanizer.mjs`, `humanizer-data.mjs` with TRANSLATIONS for 13 scanner prefixes. Self-audit terminal output also humanized. 792 tests (+157 humanizer-tester) |
|
||||||
| **5.0.0** | 2026-05-01 | Reality-based token-optimization. 3 new scanners (CPS cache-prefix, DIS dead tools, COL plugin collisions) → 12 deterministic scanners. New `/config-audit manifest` and `--accurate-tokens` API calibration. Severity-weighted scoring (`scoringVersion: 'v5'`). MCP token estimates 15 → 500+. Plugin Hygiene as 10th quality area. Knowledge: cache-stability replaces 200-line rule, cache-telemetry recipe. **Breaking:** F2 token magnitude jump, F3 severity weighting, F5 Pattern D removed, N1 `CA-TOK-*` glob now matches CA-TOK-005. 635 tests |
|
| **5.0.0** | 2026-05-01 | Reality-based token-optimization. 3 new scanners (CPS cache-prefix, DIS dead tools, COL plugin collisions) → 12 deterministic scanners. New `/config-audit manifest` and `--accurate-tokens` API calibration. Severity-weighted scoring (`scoringVersion: 'v5'`). MCP token estimates 15 → 500+. Plugin Hygiene as 10th quality area. Knowledge: cache-stability replaces 200-line rule, cache-telemetry recipe. **Breaking:** F2 token magnitude jump, F3 severity weighting, F5 Pattern D removed, N1 `CA-TOK-*` glob now matches CA-TOK-005. 635 tests |
|
||||||
|
|
|
||||||
|
|
@ -51,11 +51,16 @@ In `--raw` mode, fall back to v5.0.0 severity prefiks and verbatim scanner title
|
||||||
5. **Identify optimizations**: Rules to globalize, missing configs, orphaned files
|
5. **Identify optimizations**: Rules to globalize, missing configs, orphaned files
|
||||||
6. **Security scan**: Aggregate secret warnings, check for insecure patterns
|
6. **Security scan**: Aggregate secret warnings, check for insecure patterns
|
||||||
7. **CLAUDE.md quality assessment**: Score each file against rubric, assign letter grades
|
7. **CLAUDE.md quality assessment**: Score each file against rubric, assign letter grades
|
||||||
8. **Generate report**: Write comprehensive markdown report — group findings by `userImpactCategory`, lead with `userActionLanguage`
|
8. **Generate report**: Compose the comprehensive markdown report — group findings by `userImpactCategory`, lead with `userActionLanguage`
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
Write to: `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`
|
Return the complete report as your final message — do not write it to a file
|
||||||
|
yourself. The Claude Code subagent harness instructs agents not to write
|
||||||
|
report/analysis files; your text output IS the deliverable. The orchestrating
|
||||||
|
command saves your returned report verbatim to
|
||||||
|
`~/.claude/config-audit/sessions/{session-id}/analysis-report.md` for the
|
||||||
|
downstream plan/interview/status phases.
|
||||||
|
|
||||||
**Output MUST NOT exceed 300 lines.** Prioritize findings by severity. Use tables, not prose.
|
**Output MUST NOT exceed 300 lines.** Prioritize findings by severity. Use tables, not prose.
|
||||||
|
|
||||||
|
|
@ -183,4 +188,4 @@ Verify report: all findings referenced, recommendations actionable, severity lev
|
||||||
|
|
||||||
- Process findings in memory (typically < 1MB total)
|
- Process findings in memory (typically < 1MB total)
|
||||||
- Generate report in single pass
|
- Generate report in single pass
|
||||||
- No file modifications (read-only except report output)
|
- No file modifications (read-only; the report is returned as your final message)
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,11 @@ Move content from one file to another.
|
||||||
|
|
||||||
Append to: `~/.claude/config-audit/sessions/{session-id}/implementation-log.md`
|
Append to: `~/.claude/config-audit/sessions/{session-id}/implementation-log.md`
|
||||||
|
|
||||||
|
**Append discipline (shared log):** other implementer agents may be writing this
|
||||||
|
log concurrently. ALWAYS append your entry with a Bash `>>` heredoc;
|
||||||
|
NEVER use the Write or Edit tool on the log file — a full-file Write silently
|
||||||
|
clobbers entries other agents appended after you read the file.
|
||||||
|
|
||||||
### Success
|
### Success
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
|
|
|
||||||
120
agents/optimization-lens-agent.md
Normal file
120
agents/optimization-lens-agent.md
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
---
|
||||||
|
name: optimization-lens-agent
|
||||||
|
description: |
|
||||||
|
Judges CLAUDE.md mechanism-fit for the v5.7 optimization lens (CA-OPT). Reads
|
||||||
|
deterministic pre-filter candidates and decides, with prose judgement, whether
|
||||||
|
each is a genuine "you use mechanism X, but Y fits this better" opportunity —
|
||||||
|
lifecycle phrasing → hook, unscoped path-specific instruction → path-scoped
|
||||||
|
rule, absolute "never" prohibition → permission. Precision-gated: cites the
|
||||||
|
best-practices register rule + source, and stays silent when unsure.
|
||||||
|
model: opus
|
||||||
|
color: orange
|
||||||
|
tools: ["Read", "Glob", "Grep", "Write"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Optimization Lens Agent
|
||||||
|
|
||||||
|
You are the **precision gate** of the optimization lens's hybrid motor. A cheap
|
||||||
|
deterministic pre-filter (`lens-prefilter`) has already surfaced candidate lines
|
||||||
|
in CLAUDE.md that *might* fit a better mechanism. Your job is to read each
|
||||||
|
candidate **in its real context** and keep only the genuine opportunities.
|
||||||
|
|
||||||
|
This is the "is the config **optimal?**" axis, not "is it **correct?**" — every
|
||||||
|
finding is a *Missed opportunity*, never a mistake. The config works as written;
|
||||||
|
you are pointing at a mechanism that would fit the content better.
|
||||||
|
|
||||||
|
## The judgement you make
|
||||||
|
|
||||||
|
For each candidate, the register rule names the better-fit mechanism. Decide
|
||||||
|
whether the line is *really* that kind of instruction:
|
||||||
|
|
||||||
|
| lensCheck | Register | Keep it ONLY if the line is… | Better mechanism |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `claude-md-lifecycle-phrasing` | BP-MECH-001 | a recurring automation the model is *told* to perform ("after every commit, run X") — something that should happen deterministically, not at the model's discretion | a **hook** (PreToolUse / PostToolUse / Stop) |
|
||||||
|
| `unscoped-path-specific-instruction` | BP-MECH-002 | a constraint that only applies when a *specific* file/path/glob is touched, sitting in root CLAUDE.md where it loads every turn regardless | a **path-scoped rule** (`.claude/rules/` with `paths:` frontmatter) |
|
||||||
|
| `never-instruction` | BP-MECH-004 | an *absolute* prohibition — something that must NEVER happen, where relying on the model to remember is the wrong guarantee | a **permission deny rule** or PreToolUse hook |
|
||||||
|
|
||||||
|
## Input
|
||||||
|
|
||||||
|
You receive an `optimize-lens` payload (JSON) with:
|
||||||
|
- `target` — the repo path.
|
||||||
|
- `deterministic` — OPT scanner findings already confirmed (CA-OPT-001:
|
||||||
|
procedure → skill). Report these **as-is**; do not re-judge them.
|
||||||
|
- `candidates` — pre-filter candidates, each with `file`, `line`, `lensCheck`,
|
||||||
|
`mechanism`, `signalText`, and a `register` block (`id`, `claim`,
|
||||||
|
`recommendation`, `severity`, `source`). Only CONFIRMED register rules reach
|
||||||
|
you.
|
||||||
|
- `register` — the full confirmed prose-judgment entries, for reference.
|
||||||
|
|
||||||
|
Always **Read the actual CLAUDE.md file(s)** named in the candidates before
|
||||||
|
judging — `signalText` is one line out of context; the surrounding lines decide
|
||||||
|
whether it is really lifecycle/path-specific/prohibition phrasing.
|
||||||
|
|
||||||
|
## Precision rules (non-negotiable)
|
||||||
|
|
||||||
|
1. **Keep only high-confidence opportunities.** When the line is ambiguous,
|
||||||
|
rhetorical, an example, a heading, or already correctly placed (e.g. it is
|
||||||
|
*inside* a path-scoped rule, or already references a hook) — **drop it**. A
|
||||||
|
missed suggestion is far cheaper than a wrong one (Verifiseringsplikt).
|
||||||
|
2. **Never invent a recommendation.** Use the `register.recommendation` and cite
|
||||||
|
`register.id` + `register.source.url`. If a candidate has no register block,
|
||||||
|
skip it.
|
||||||
|
3. **De-duplicate.** If one line yields two candidates (e.g. "never edit
|
||||||
|
src/config.ts"), pick the single mechanism that fits best and say why,
|
||||||
|
rather than emitting two findings for one line.
|
||||||
|
4. **No false urgency.** These are LOW-severity opportunities. Do not imply the
|
||||||
|
config is broken.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Write `optimization-lens-report.md` to the session directory (≤120 lines).
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Optimization Lens — mechanism-fit
|
||||||
|
|
||||||
|
**Date:** YYYY-MM-DD | **Target:** {repo}
|
||||||
|
**Confirmed opportunities:** {N kept} · **Candidates reviewed:** {M} · **Dropped (low confidence):** {M-N}
|
||||||
|
|
||||||
|
> The config works as written. These are places where a different Claude Code
|
||||||
|
> mechanism would fit the content better — usually cheaper per turn or more
|
||||||
|
> reliable.
|
||||||
|
|
||||||
|
## Procedures → skills (deterministic)
|
||||||
|
|
||||||
|
{For each `deterministic` finding — render title/recommendation verbatim, cite CA-OPT-001 + BP-MECH-003.}
|
||||||
|
|
||||||
|
## Lifecycle → hooks
|
||||||
|
|
||||||
|
{Kept BP-MECH-001 findings. For each:}
|
||||||
|
**{file}:{line}** — {one-line restatement of the line}
|
||||||
|
Why: {register.claim, condensed}
|
||||||
|
Move to: {register.recommendation}
|
||||||
|
Source: {register.source.url}
|
||||||
|
|
||||||
|
## Path-specific → scoped rules
|
||||||
|
|
||||||
|
{Kept BP-MECH-002 findings, same shape.}
|
||||||
|
|
||||||
|
## Absolute prohibitions → permissions
|
||||||
|
|
||||||
|
{Kept BP-MECH-004 findings, same shape.}
|
||||||
|
|
||||||
|
## What I deliberately left alone
|
||||||
|
|
||||||
|
{Brief, honest: candidates you dropped and why — "line 22 mentions a path but is
|
||||||
|
a cross-reference, not an instruction." This is the precision gate showing its
|
||||||
|
work. Keep to a few lines.}
|
||||||
|
```
|
||||||
|
|
||||||
|
Omit any section with zero kept findings (except keep the "left alone" note when
|
||||||
|
you dropped anything). If nothing survived the gate, say so plainly — a clean
|
||||||
|
CLAUDE.md is a good outcome, not a failure to find problems.
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Frame everything as *opportunities*, never failures.
|
||||||
|
- Cite the register rule id + source URL on every finding — provenance is the
|
||||||
|
product.
|
||||||
|
- Be concrete: name the file and line, and what the replacement mechanism is.
|
||||||
|
- Prefer dropping a borderline candidate over stretching to keep it.
|
||||||
|
- Do not recommend a mechanism the project already uses for that exact content.
|
||||||
|
|
@ -60,12 +60,21 @@ Agent(subagent_type: "config-audit:analyzer-agent")
|
||||||
raw severity. The humanizer already replaced jargon-heavy
|
raw severity. The humanizer already replaced jargon-heavy
|
||||||
title/description/recommendation strings with plain-language
|
title/description/recommendation strings with plain-language
|
||||||
equivalents — render them verbatim, do not paraphrase.
|
equivalents — render them verbatim, do not paraphrase.
|
||||||
Output to: ~/.claude/config-audit/sessions/{session-id}/analysis-report.md
|
Return the complete report as your final message. Do not write it
|
||||||
|
to a file — the orchestrating command saves it to the session directory.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 4: Present summary
|
### Step 4: Save the report
|
||||||
|
|
||||||
After the agent completes, read the generated report and show a brief summary:
|
The agent returns the complete report as its final message — the Claude Code
|
||||||
|
subagent harness instructs agents not to write report/analysis files themselves,
|
||||||
|
so the command must persist it. Write the returned report verbatim (no edits,
|
||||||
|
no truncation) to `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`
|
||||||
|
using the Write tool. Downstream phases (`plan`, `interview`, `status`) read this file.
|
||||||
|
|
||||||
|
### Step 5: Present summary
|
||||||
|
|
||||||
|
After saving the report, show a brief summary:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
### Analysis Complete
|
### Analysis Complete
|
||||||
|
|
@ -84,6 +93,6 @@ Full report: `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`
|
||||||
- **`/config-audit fix`** — Auto-fix deterministic issues right away
|
- **`/config-audit fix`** — Auto-fix deterministic issues right away
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 5: Update state
|
### Step 6: Update state
|
||||||
|
|
||||||
Update `state.yaml` with `current_phase: "analyze"`, `next_phase: "plan"`.
|
Update `state.yaml` with `current_phase: "analyze"`, `next_phase: "plan"`.
|
||||||
|
|
|
||||||
306
commands/campaign.md
Normal file
306
commands/campaign.md
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
---
|
||||||
|
name: config-audit:campaign
|
||||||
|
description: Machine-wide audit campaign — track which repos are pending/audited/planned/implemented across sessions, with a machine-wide roll-up. Human-approved writes only.
|
||||||
|
argument-hint: "[init | add <path>... | set-status <path> <status> | refresh-tokens | export <path>]"
|
||||||
|
allowed-tools: Read, Write, Edit, Bash, Glob
|
||||||
|
model: opus
|
||||||
|
---
|
||||||
|
|
||||||
|
# Config-Audit: Campaign
|
||||||
|
|
||||||
|
A single config-audit session audits **one** scope. A **campaign** sits above sessions: a
|
||||||
|
durable ledger of every repo you mean to bring up to standard, each repo's lifecycle status
|
||||||
|
(**pending → audited → planned → implemented**), and a machine-wide roll-up of findings by
|
||||||
|
severity. It persists to `~/.claude/config-audit/campaign-ledger.json` — **outside** the
|
||||||
|
plugin dir, next to `sessions/` — so it survives plugin uninstall/reinstall/upgrade and
|
||||||
|
resumes across sessions.
|
||||||
|
|
||||||
|
**The Iron rule (Verifiseringsplikt): nothing is ever auto-written.** Reporting is read-only.
|
||||||
|
Every mutation — creating the ledger, adding a repo, changing a status — is proposed first
|
||||||
|
and applied **only on explicit approval**, by invoking one deterministic write-CLI subcommand.
|
||||||
|
The command never hand-edits the ledger JSON.
|
||||||
|
|
||||||
|
This is the **THIN** campaign surface (ledger + roll-up + status + a cross-repo prioritized
|
||||||
|
backlog to pick from + plan **export**). It does not run audits or apply fixes itself: it tracks
|
||||||
|
where each repo stands, shows what to tackle next, exports a planned repo's plan into that repo's
|
||||||
|
own `docs/`, and points at the **existing** per-repo `/config-audit implement` (backup + apply +
|
||||||
|
verify) and `/config-audit rollback` for execution — Block 4c reuses that machinery, it does not
|
||||||
|
reinvent it.
|
||||||
|
|
||||||
|
## Three CLIs back this command
|
||||||
|
|
||||||
|
- **Read (report):** `scanners/campaign-cli.mjs` — loads + validates the ledger, emits the
|
||||||
|
repo list + roll-up + backlog. Never writes.
|
||||||
|
- **Write (mutate):** `scanners/campaign-write-cli.mjs` — `init` / `add` / `set-status` /
|
||||||
|
`refresh-tokens`, each a thin wrapper over the invariant-enforcing lib transforms + save.
|
||||||
|
Invoked **only** after the user approves a specific action. `refresh-tokens` is the live
|
||||||
|
cross-repo token sweep: it runs the manifest's always-loaded accounting across every tracked
|
||||||
|
repo and folds the result into the machine-wide token bill (shared global layer counted once
|
||||||
|
+ per-repo deltas).
|
||||||
|
- **Export:** `scanners/campaign-export-cli.mjs` — `--repo <path>` resolves the repo's linked
|
||||||
|
session, reads its `action-plan.md`, and assembles a `docs/config-audit-plan-<sessionId>.md`.
|
||||||
|
Read-only (a preview) by default; it writes the file **only** under `--write`, which is
|
||||||
|
invoked **only** after the user approves. The CLI writes the file byte-faithfully — the plan is
|
||||||
|
never re-typed.
|
||||||
|
|
||||||
|
All take `--ledger-file <path>` (defaults to the durable path) and `--output-file <path>`; the
|
||||||
|
write-CLI + export-CLI also take `--reference-date <YYYY-MM-DD>` (the date stamp), and the
|
||||||
|
export-CLI takes `--sessions-dir <path>` (defaults to `~/.claude/config-audit/sessions`).
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Step 1: Parse arguments
|
||||||
|
|
||||||
|
From `$ARGUMENTS`, pick the mode:
|
||||||
|
|
||||||
|
- *(empty)* or `report` → **report** (read-only). Default.
|
||||||
|
- `init` → initialize the ledger.
|
||||||
|
- `add <path>...` → add one or more repo paths.
|
||||||
|
- `add --discover <root>` → find git repos under `<root>` and let the user pick which to add.
|
||||||
|
- `set-status <path> <status>` → transition a tracked repo (`status` ∈ pending/audited/planned/implemented).
|
||||||
|
- `refresh-tokens` → live cross-repo token sweep: compute the machine-wide always-loaded bill.
|
||||||
|
- `export <path>` → export a planned repo's action plan into that repo's own `docs/`.
|
||||||
|
- `help` → show this surface and stop.
|
||||||
|
|
||||||
|
Set a shared date stamp for any write: `TODAY=$(date +%F)`.
|
||||||
|
|
||||||
|
### Step 2: Always report current state first
|
||||||
|
|
||||||
|
Whatever the mode, start by showing where the campaign stands (read-only):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-cli.mjs \
|
||||||
|
--output-file ~/.claude/config-audit/sessions/campaign-report.json 2>/dev/null; echo $?
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit **0** = a campaign exists, **1** = not initialized yet (advisory — normal first run),
|
||||||
|
**3** = real error → "The campaign ledger couldn't be read — it may be corrupt." (Stop; do not
|
||||||
|
attempt a write over a corrupt ledger.)
|
||||||
|
|
||||||
|
Read `~/.claude/config-audit/sessions/campaign-report.json` with the Read tool (per the UX
|
||||||
|
rules — never show the raw JSON). It has `initialized`, `repos[]` (each: `path, name, status,
|
||||||
|
sessionId, findingsBySeverity, tokens, updatedDate`), `rollUp {totalRepos, byStatus, bySeverity,
|
||||||
|
reposWithFindings, tokens}`, and `backlog[]` — the single cross-repo prioritized work list (each:
|
||||||
|
`path, name, status, findingsBySeverity, totalFindings, weightedScore, rank`), already sorted
|
||||||
|
DESC by severity (most critical work first).
|
||||||
|
|
||||||
|
Present it as two short tables:
|
||||||
|
|
||||||
|
**Campaign roll-up**
|
||||||
|
|
||||||
|
| Status | Repos |
|
||||||
|
|--------|-------|
|
||||||
|
| pending / audited / planned / implemented | … |
|
||||||
|
|
||||||
|
…plus a one-line severity total across audited repos (e.g. "Findings so far: 3 critical,
|
||||||
|
8 high, 5 medium, 12 low across 4 audited repos").
|
||||||
|
|
||||||
|
**Repos**
|
||||||
|
|
||||||
|
| Repo | Status | Findings (C/H/M/L) | Last updated |
|
||||||
|
|------|--------|--------------------|--------------|
|
||||||
|
|
||||||
|
**Prioritized backlog** — the one cross-repo list to pick from, highest-severity work first.
|
||||||
|
Render `backlog[]` in `rank` order (it is already sorted); omit this table entirely when the
|
||||||
|
backlog is empty (nothing outstanding — say "Backlog clear — no outstanding findings across
|
||||||
|
tracked repos."). Implemented repos and repos with no known findings are deliberately absent.
|
||||||
|
|
||||||
|
| # | Repo | Status | Findings (C/H/M/L) | Total |
|
||||||
|
|---|------|--------|--------------------|-------|
|
||||||
|
|
||||||
|
After it, point the user at the top item: "Highest priority: **`<name>`** (`<status>`) — pick it
|
||||||
|
with `/config-audit` (audit), `/config-audit plan`, or `/config-audit implement` in that repo,
|
||||||
|
then record progress here with `set-status`." The backlog is a **pick-list**, not an executor —
|
||||||
|
this command does not run audits or fixes (that is the later execution block).
|
||||||
|
|
||||||
|
**Machine-wide token bill** — render from `rollUp.tokens` (the whole-machine always-loaded
|
||||||
|
accounting). Note the shape: `sharedGlobal`, `perRepoDelta`, and `machineWide` are flat
|
||||||
|
`{always, onDemand, external, unknown}` number maps; `byRepo[]` is `{name, path, always,
|
||||||
|
onDemand, external}` already sorted DESC by always-loaded cost; `reposWithTokens` is the count.
|
||||||
|
|
||||||
|
If `reposWithTokens` is `0`, no sweep has run yet — say: "No token bill yet — run
|
||||||
|
`/config-audit campaign refresh-tokens` to compute the machine-wide always-loaded cost." and
|
||||||
|
omit the table. Otherwise lead with the headline and the once-vs-delta split:
|
||||||
|
|
||||||
|
> **Always-loaded every turn, machine-wide: ~`machineWide.always` tokens** — `sharedGlobal.always`
|
||||||
|
> paid once (global config + installed plugins, in *every* repo) + `perRepoDelta.always` across
|
||||||
|
> `reposWithTokens` repos' own project config.
|
||||||
|
|
||||||
|
Then the **most expensive repos** (their per-repo delta — what each adds beyond the shared layer):
|
||||||
|
|
||||||
|
| # | Repo | Always-loaded delta |
|
||||||
|
|---|------|---------------------|
|
||||||
|
| 1 | `<byRepo[0].name>` | `<byRepo[0].always>` |
|
||||||
|
|
||||||
|
Add one plain-language line so the number is actionable, e.g. "The shared global layer is the
|
||||||
|
biggest lever — trim `~/.claude/CLAUDE.md`, the global agent listing, or rarely-used plugins
|
||||||
|
to cut cost in every repo at once." The bill reflects the **last** sweep; re-run
|
||||||
|
`refresh-tokens` after config changes.
|
||||||
|
|
||||||
|
If `initialized` is false, say so plainly: "No campaign yet. Run `/config-audit campaign init`
|
||||||
|
to start one." Then — if the mode was `init` or `add` — continue to that step (those bootstrap
|
||||||
|
a campaign); for `report`/`set-status` on an uninitialized ledger, stop after this message.
|
||||||
|
|
||||||
|
### Step 3 (mode `init`): Initialize
|
||||||
|
|
||||||
|
If already initialized, say so and stop (no clobber). Otherwise tell the user what will happen,
|
||||||
|
then create it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs init \
|
||||||
|
--reference-date "$TODAY" \
|
||||||
|
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit **0** = created, **1** = already initialized (advisory). Confirm: "Campaign ledger created
|
||||||
|
at `~/.claude/config-audit/campaign-ledger.json`." Then suggest `add`.
|
||||||
|
|
||||||
|
### Step 4 (mode `add`): Add repos — propose, approve, write
|
||||||
|
|
||||||
|
**Gather candidates.**
|
||||||
|
- Explicit paths: use the paths given after `add`.
|
||||||
|
- `--discover <root>`: find git repos (depth-limited), e.g.
|
||||||
|
```bash
|
||||||
|
find "<root>" -maxdepth 3 -type d -name .git 2>/dev/null | sed 's:/\.git$::'
|
||||||
|
```
|
||||||
|
Present the discovered repos as a numbered list and ask **which** to add (and confirm any
|
||||||
|
that are already tracked will be skipped). Use Glob as a fallback if `find` is unavailable.
|
||||||
|
|
||||||
|
**Confirm, then write.** Show the final list and ask for explicit approval. On approval, add
|
||||||
|
them in one call (idempotent — already-tracked repos are skipped, not reset):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs add <path1> <path2> ... \
|
||||||
|
--reference-date "$TODAY" \
|
||||||
|
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
|
||||||
|
```
|
||||||
|
|
||||||
|
(For a single repo with a custom display name, add `--name "<name>"`.) Read the result file and
|
||||||
|
report what was `added` vs `skipped`, then re-show the repo table.
|
||||||
|
|
||||||
|
### Step 5 (mode `set-status`): Transition a repo — propose, approve, write
|
||||||
|
|
||||||
|
Confirm the repo is tracked (from Step 2's report) and that `status` is one of
|
||||||
|
pending/audited/planned/implemented. State the transition ("`<name>`: pending → audited") and
|
||||||
|
ask for approval.
|
||||||
|
|
||||||
|
When marking a repo **audited**, optionally attach its findings-by-severity so the machine-wide
|
||||||
|
roll-up stays meaningful. Two honest sources, in order of preference:
|
||||||
|
1. If the repo was audited in a config-audit session, read that session's finding counts and
|
||||||
|
build `{"critical":C,"high":H,"medium":M,"low":L}` — pass `--session <id>` too.
|
||||||
|
2. Otherwise, use counts the user provides. **Never invent counts** (Verifiseringsplikt) — if
|
||||||
|
none are available, transition the status without `--findings`.
|
||||||
|
|
||||||
|
On approval:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs set-status <path> <status> \
|
||||||
|
--reference-date "$TODAY" \
|
||||||
|
[--findings '{"critical":0,"high":0,"medium":0,"low":0}'] [--session <id>] \
|
||||||
|
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit **3** = invalid status, untracked repo, or no ledger → report the message plainly and do
|
||||||
|
not retry blindly. On success, read the result and re-show the updated roll-up + repo row.
|
||||||
|
|
||||||
|
### Step 6 (mode `refresh-tokens`): Sweep tokens machine-wide — propose, approve, write
|
||||||
|
|
||||||
|
The token bill in Step 2 reflects the **last** sweep. `refresh-tokens` recomputes it: for every
|
||||||
|
tracked repo it runs the manifest's always-loaded accounting and refreshes the machine-wide bill —
|
||||||
|
the shared global layer (global CLAUDE.md + installed plugins + global agents/MCP) counted **once**,
|
||||||
|
plus each repo's own project-config delta.
|
||||||
|
|
||||||
|
It writes only the ledger's token fields (never status or findings), is idempotent (a re-sweep
|
||||||
|
replaces, never accumulates), and **skips — never aborts on** — any repo that can't be read. Tell
|
||||||
|
the user it will read each tracked repo's live config (a few seconds per repo), then on approval:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs refresh-tokens \
|
||||||
|
--reference-date "$TODAY" \
|
||||||
|
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit **0** = swept (or nothing to sweep — a benign no-op on an empty campaign), **3** = no/corrupt
|
||||||
|
ledger. Read the result: `swept[]` (repos accounted for), `skipped[]` (each `{path, reason}`), and
|
||||||
|
the refreshed `rollUp.tokens`. Re-render the **Machine-wide token bill** (Step 2) with the new
|
||||||
|
numbers. If anything was skipped, name those repos plainly so the user knows the bill omits them
|
||||||
|
(honest coverage — Verifiseringsplikt).
|
||||||
|
|
||||||
|
### Step 7 (mode `export`): Export a repo's plan to its own `docs/` — preview, approve, write
|
||||||
|
|
||||||
|
"Planer følger arbeidsstedet": a planned repo's action plan belongs in **that repo's** `docs/`,
|
||||||
|
not buried in a session dir. This step copies it there, byte-faithfully.
|
||||||
|
|
||||||
|
**Preview first (read-only — never writes).** The repo must be tracked and have a linked session
|
||||||
|
that carries an `action-plan.md` (i.e. `/config-audit plan` has run there). Run without `--write`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-export-cli.mjs --repo "<path>" \
|
||||||
|
--reference-date "$TODAY" \
|
||||||
|
--output-file ~/.claude/config-audit/sessions/campaign-export.json 2>/dev/null; echo $?
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit **0** = previewable, **1** = tracked but not exportable yet, **3** = error (untracked repo,
|
||||||
|
no/corrupt ledger). Read `~/.claude/config-audit/sessions/campaign-export.json` with the Read tool.
|
||||||
|
|
||||||
|
- **Exit 1 — read `problems`** and guide, then stop (nothing to export):
|
||||||
|
- `no-session-linked` → "`<name>` has no linked audit session. Link one with
|
||||||
|
`/config-audit campaign set-status <path> <status> --session <id>`, or audit + plan it first."
|
||||||
|
- `no-action-plan` → "`<name>`'s session has no plan yet. Run `/config-audit plan` in that repo
|
||||||
|
first, mark it `planned`, then export."
|
||||||
|
- **Exit 0 — show, then ask.** Tell the user the destination (`targetPath`) and a **short** preview
|
||||||
|
— the first ~12 lines of `document` only, never the whole file, never the raw JSON (UX rules).
|
||||||
|
Ask for explicit approval to write it.
|
||||||
|
|
||||||
|
**On approval, write it** (the CLI does the faithful copy — do NOT hand-write the file):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-export-cli.mjs --repo "<path>" --write \
|
||||||
|
--reference-date "$TODAY" \
|
||||||
|
--output-file ~/.claude/config-audit/sessions/campaign-export.json 2>/dev/null; echo $?
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm: "Plan exported to `<targetPath>`." Then hand off to the **existing** execution machinery
|
||||||
|
(Block 4c reuses it — this command does not run it for you):
|
||||||
|
|
||||||
|
> To **execute**: run `/config-audit implement` in `<path>` — it backs up every changed file,
|
||||||
|
> applies the plan, and verifies. To **undo**: `/config-audit rollback`. When done, record it:
|
||||||
|
> `/config-audit campaign set-status <path> implemented`.
|
||||||
|
|
||||||
|
### Step 8: Next steps
|
||||||
|
|
||||||
|
Tailor to where the campaign stands:
|
||||||
|
|
||||||
|
- **Just initialized / few repos:** "`/config-audit campaign add --discover ~/repos` to enroll
|
||||||
|
your repos."
|
||||||
|
- **Pending repos exist:** "Run `/config-audit` in a pending repo to audit it, then
|
||||||
|
`/config-audit campaign set-status <path> audited` to record the result here."
|
||||||
|
- **Audited but not planned:** "`/config-audit plan` in that repo, then mark it `planned`."
|
||||||
|
- **Planned repos exist:** "`/config-audit campaign export <path>` to drop the plan into that
|
||||||
|
repo's own `docs/`, then `/config-audit implement` there to execute it (backup + verify)."
|
||||||
|
- **Backlog has items:** point at the top backlog repo and the natural next verb for its status
|
||||||
|
(audit → plan → export → implement).
|
||||||
|
- **No token bill yet (or config changed):** "`/config-audit campaign refresh-tokens` to compute
|
||||||
|
the machine-wide always-loaded cost — the shared global layer is the biggest lever."
|
||||||
|
- Always: the campaign survives this session — re-run `/config-audit campaign` anytime to see
|
||||||
|
the machine-wide picture.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Read-only report, human-approved writes.** `campaign-cli` never writes; every mutation —
|
||||||
|
ledger changes via `campaign-write-cli`, plan exports via `campaign-export-cli --write` — happens
|
||||||
|
only after explicit approval, exactly mirroring how `/config-audit knowledge-refresh` gates
|
||||||
|
register writes. The export-CLI's default (no `--write`) is a read-only preview.
|
||||||
|
- **Deterministic core, not byte-stable command.** The lib transforms + all three CLIs are
|
||||||
|
unit-tested and deterministic (`--reference-date` injected); this command's orchestration is
|
||||||
|
judgment-driven and deliberately **not** in the snapshot suite (like `/config-audit optimize`
|
||||||
|
and `knowledge-refresh`).
|
||||||
|
- The `-cli` suffix keeps all three CLIs out of the scan-orchestrator, so the scanner count and
|
||||||
|
the byte-stable snapshot suite are unaffected.
|
||||||
|
- **THIN scope:** ledger + roll-up (findings + machine-wide token bill) + status + a cross-repo
|
||||||
|
prioritized backlog + plan export. The token sweep reuses the manifest's existing always-loaded
|
||||||
|
accounting per repo — it does not reinvent measurement, only aggregates it machine-wide.
|
||||||
|
Execution is **not** reinvented here — `export` drops a planned repo's plan into its own `docs/`
|
||||||
|
(a durable record), and the user runs the existing `/config-audit implement` (backup + apply +
|
||||||
|
verify) + `/config-audit rollback` to execute and undo. This command tracks state and routes the
|
||||||
|
work; it does not run audits or apply fixes itself.
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
name: config-audit
|
name: config-audit
|
||||||
description: Claude Code Configuration Intelligence - audit, analyze, and optimize your configuration
|
description: Claude Code Configuration Intelligence - audit, analyze, and optimize your configuration
|
||||||
argument-hint: "[posture|tokens|manifest|feature-gap|fix|rollback|plan|implement|help|discover|analyze|interview|drift|plugin-health|whats-active|status|cleanup]"
|
argument-hint: "[posture|tokens|manifest|feature-gap|optimize|fix|rollback|plan|implement|help|discover|analyze|interview|drift|plugin-health|whats-active|campaign|knowledge-refresh|status|cleanup]"
|
||||||
allowed-tools: Read, Write, Glob, Grep, Bash, Agent, AskUserQuestion
|
allowed-tools: Read, Write, Glob, Grep, Bash, Agent, AskUserQuestion
|
||||||
model: opus
|
model: opus
|
||||||
---
|
---
|
||||||
|
|
@ -17,6 +17,7 @@ If a subcommand is provided, route to it:
|
||||||
- `tokens` → `/config-audit:tokens`
|
- `tokens` → `/config-audit:tokens`
|
||||||
- `manifest` → `/config-audit:manifest`
|
- `manifest` → `/config-audit:manifest`
|
||||||
- `feature-gap` → `/config-audit:feature-gap`
|
- `feature-gap` → `/config-audit:feature-gap`
|
||||||
|
- `optimize` → `/config-audit:optimize`
|
||||||
- `fix` → `/config-audit:fix`
|
- `fix` → `/config-audit:fix`
|
||||||
- `rollback` → `/config-audit:rollback`
|
- `rollback` → `/config-audit:rollback`
|
||||||
- `plan` → `/config-audit:plan`
|
- `plan` → `/config-audit:plan`
|
||||||
|
|
@ -28,6 +29,8 @@ If a subcommand is provided, route to it:
|
||||||
- `drift` → `/config-audit:drift`
|
- `drift` → `/config-audit:drift`
|
||||||
- `plugin-health` → `/config-audit:plugin-health`
|
- `plugin-health` → `/config-audit:plugin-health`
|
||||||
- `whats-active` → `/config-audit:whats-active`
|
- `whats-active` → `/config-audit:whats-active`
|
||||||
|
- `campaign` → `/config-audit:campaign`
|
||||||
|
- `knowledge-refresh` → `/config-audit:knowledge-refresh`
|
||||||
- `status` → `/config-audit:status`
|
- `status` → `/config-audit:status`
|
||||||
- `cleanup` → `/config-audit:cleanup`
|
- `cleanup` → `/config-audit:cleanup`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||||
| `/config-audit tokens` | prompt-cache token hotspots; optional `--accurate-tokens` API calibration |
|
| `/config-audit tokens` | prompt-cache token hotspots; optional `--accurate-tokens` API calibration |
|
||||||
| `/config-audit manifest` | Ranked table of every system-prompt token source |
|
| `/config-audit manifest` | Ranked table of every system-prompt token source |
|
||||||
| `/config-audit feature-gap` | Deep analysis of features you're not using |
|
| `/config-audit feature-gap` | Deep analysis of features you're not using |
|
||||||
|
| `/config-audit optimize` | Optimization lens — config that works but fits a better mechanism (procedure→skill, lifecycle→hook, path→rule, never→permission) |
|
||||||
| `/config-audit fix` | Auto-fix deterministic issues; a copy of every changed file is saved first so you can roll back with one command |
|
| `/config-audit fix` | Auto-fix deterministic issues; a copy of every changed file is saved first so you can roll back with one command |
|
||||||
| `/config-audit rollback` | Restore configuration from a saved copy |
|
| `/config-audit rollback` | Restore configuration from a saved copy |
|
||||||
|
|
||||||
|
|
@ -53,6 +54,8 @@ if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||||
| `/config-audit drift` | Compare current config against a saved baseline |
|
| `/config-audit drift` | Compare current config against a saved baseline |
|
||||||
| `/config-audit plugin-health` | Audit plugin structure and the metadata block at the top of each command/agent file |
|
| `/config-audit plugin-health` | Audit plugin structure and the metadata block at the top of each command/agent file |
|
||||||
| `/config-audit whats-active` | Show active plugins/skills/MCP/hooks/CLAUDE.md with token estimates |
|
| `/config-audit whats-active` | Show active plugins/skills/MCP/hooks/CLAUDE.md with token estimates |
|
||||||
|
| `/config-audit campaign` | Track a machine-wide audit campaign across repos — per-repo status + roll-up, resumable across sessions (human-approved writes) |
|
||||||
|
| `/config-audit knowledge-refresh` | Keep the built-in best-practices knowledge current — flags guidance that's gone stale and checks sources for updates (changes need your approval) |
|
||||||
|
|
||||||
### Utility
|
### Utility
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,8 @@ Agent(subagent_type: "config-audit:implementer-agent")
|
||||||
fields from the action plan (the planner already rendered them) —
|
fields from the action plan (the planner already rendered them) —
|
||||||
do not re-derive severity prose. Append result to:
|
do not re-derive severity prose. Append result to:
|
||||||
~/.claude/config-audit/sessions/{session-id}/implementation-log.md
|
~/.claude/config-audit/sessions/{session-id}/implementation-log.md
|
||||||
|
Append with Bash `>>` (heredoc) — NEVER the Write tool on this log;
|
||||||
|
parallel agents share it and a full-file Write clobbers their entries.
|
||||||
```
|
```
|
||||||
|
|
||||||
Show progress between groups using the humanized titles already present in the action plan:
|
Show progress between groups using the humanized titles already present in the action plan:
|
||||||
|
|
|
||||||
134
commands/knowledge-refresh.md
Normal file
134
commands/knowledge-refresh.md
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
---
|
||||||
|
name: config-audit:knowledge-refresh
|
||||||
|
description: Keep the best-practices register fresh — flag stale entries, poll sources for new/changed practices, with human-approved writes only
|
||||||
|
argument-hint: "[--stale-after N] [--no-candidates]"
|
||||||
|
allowed-tools: Read, Write, Edit, Bash, WebSearch, WebFetch
|
||||||
|
model: opus
|
||||||
|
---
|
||||||
|
|
||||||
|
# Config-Audit: Knowledge Refresh
|
||||||
|
|
||||||
|
The "living" part of the living knowledge base. The optimization lens (`/config-audit
|
||||||
|
optimize`) is only as good as the best-practices register it reads — and Claude Code moves
|
||||||
|
fast. This command keeps `knowledge/best-practices.json` current in two ways:
|
||||||
|
|
||||||
|
- **Stale check (deterministic):** every CONFIRMED entry carries a `source.verified` date.
|
||||||
|
An entry older than the threshold (default **90 days**) is flagged for re-verification —
|
||||||
|
its source may have changed since.
|
||||||
|
- **Candidate poll (web):** scan the CC changelog + the Anthropic "Steering Claude Code"
|
||||||
|
docs/blog for *new* best-practices the register doesn't yet hold, or *changed* guidance
|
||||||
|
that contradicts an existing entry.
|
||||||
|
|
||||||
|
**The Iron rule (Verifiseringsplikt): nothing is ever auto-written.** Every change — a
|
||||||
|
bumped `verified` date, an updated claim, a brand-new entry — is presented to the user and
|
||||||
|
applied only on explicit approval, and only after the live source has actually been
|
||||||
|
re-read. No unverified claim enters the register.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Step 1: Parse arguments
|
||||||
|
|
||||||
|
From `$ARGUMENTS`:
|
||||||
|
- `--stale-after N` → override the staleness threshold (integer days; default 90).
|
||||||
|
- `--no-candidates` → run the deterministic stale check only; skip the web poll.
|
||||||
|
|
||||||
|
Tell the user what's happening:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Knowledge Refresh
|
||||||
|
|
||||||
|
Checking the best-practices register for stale entries (sources that may need
|
||||||
|
re-verification) and polling for new Claude Code practices...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Run the stale-check CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TODAY=$(date +%F)
|
||||||
|
STALE_AFTER=""
|
||||||
|
if echo "$ARGUMENTS" | grep -qE -- '--stale-after'; then
|
||||||
|
STALE_AFTER="--stale-after $(echo "$ARGUMENTS" | sed -nE 's/.*--stale-after[ =]+([0-9]+).*/\1/p')"
|
||||||
|
fi
|
||||||
|
node ${CLAUDE_PLUGIN_ROOT}/scanners/knowledge-refresh-cli.mjs \
|
||||||
|
--reference-date "$TODAY" $STALE_AFTER \
|
||||||
|
--output-file ~/.claude/config-audit/sessions/knowledge-refresh.json 2>/dev/null; echo $?
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit code **0** = all fresh, **1** = some stale (advisory, normal), **3** = real error →
|
||||||
|
"The refresh check couldn't run — the register file may be missing or invalid."
|
||||||
|
|
||||||
|
### Step 3: Read the payload + present stale entries
|
||||||
|
|
||||||
|
Read `~/.claude/config-audit/sessions/knowledge-refresh.json` with the Read tool. It has
|
||||||
|
`counts {total, stale, fresh}`, a `stale[]` array (each: `id, verified, ageDays, url,
|
||||||
|
claim`), `referenceDate`, and `staleAfterDays`.
|
||||||
|
|
||||||
|
Present the stale entries as a markdown table (per the UX rules — never show the raw JSON):
|
||||||
|
|
||||||
|
| Entry | Claim (short) | Verified | Age (days) | Source |
|
||||||
|
|-------|---------------|----------|------------|--------|
|
||||||
|
|
||||||
|
If `counts.stale === 0`, say so plainly: "✓ All N register entries were re-verified within
|
||||||
|
the last {staleAfterDays} days." Then continue to the candidate poll (unless `--no-candidates`).
|
||||||
|
|
||||||
|
### Step 4: Candidate + source-change poll (web — skip if `--no-candidates`)
|
||||||
|
|
||||||
|
Tell the user this takes a moment ("Polling the changelog + Anthropic docs, ~20-40s...").
|
||||||
|
|
||||||
|
1. **Re-verify each stale entry.** `WebFetch` the entry's `source.url` and check whether the
|
||||||
|
claim it backs is **still accurate**. Three outcomes:
|
||||||
|
- *Still holds* → propose bumping `source.verified` to today (no claim change).
|
||||||
|
- *Changed* → propose an updated `claim`/`recommendation` quoting the new source text.
|
||||||
|
- *Cannot verify* (page gone, paywalled, contradicts) → propose **nothing**; flag it
|
||||||
|
"needs manual review" (Verifiseringsplikt: never bump a date you couldn't confirm).
|
||||||
|
2. **Look for new practices.** `WebSearch` the CC changelog and the "Steering Claude Code"
|
||||||
|
blog/docs for steering/config guidance not already represented by an entry's `lensCheck`.
|
||||||
|
For each genuine new practice, draft a **candidate** entry (next free `BP-<TOPIC>-NNN` id,
|
||||||
|
`confidence: "confirmed"` only if a primary source confirms it — otherwise mark `inferred`
|
||||||
|
and do **not** present it as user-facing).
|
||||||
|
|
||||||
|
### Step 5: Present everything for approval — write nothing yet
|
||||||
|
|
||||||
|
Group the proposals and ask the user to approve per item:
|
||||||
|
|
||||||
|
- **Re-verify (date bump):** "{id} — source re-read, claim still holds → bump verified to {today}?"
|
||||||
|
- **Update (claim drift):** show the old vs. new claim + the quoted source line.
|
||||||
|
- **New candidate:** show the drafted entry (id, claim, mechanism, recommendation, source).
|
||||||
|
- **Needs manual review:** list, with why it couldn't be auto-verified. (No write offered.)
|
||||||
|
|
||||||
|
Be explicit: **"I will not change any file until you approve specific items."**
|
||||||
|
|
||||||
|
### Step 6: Apply approved writes (only the approved ones)
|
||||||
|
|
||||||
|
For each approved item:
|
||||||
|
1. Edit `knowledge/best-practices.json` — bump `source.verified`, update the `claim`/
|
||||||
|
`recommendation`, or append the new entry. Keep the file's 2-space JSON formatting.
|
||||||
|
2. If a `knowledge/*.md` mirror states the same fact, update it too so the human-readable
|
||||||
|
mirror doesn't drift from the register.
|
||||||
|
3. **Validate before declaring done** — re-run the register schema check and confirm zero errors:
|
||||||
|
```bash
|
||||||
|
node --test ${CLAUDE_PLUGIN_ROOT}/tests/lib/best-practices-register.test.mjs 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
If validation fails, revert that edit and report it — never leave the register invalid.
|
||||||
|
|
||||||
|
Report exactly what changed (ids + fields), and what was deferred to manual review.
|
||||||
|
|
||||||
|
### Step 7: Next steps
|
||||||
|
|
||||||
|
- `/config-audit optimize` — the lens now reads the refreshed register; re-run it to pick up
|
||||||
|
any new or changed mechanism-fit rules.
|
||||||
|
- Re-run `/config-audit knowledge-refresh --no-candidates` anytime for a quick staleness scan
|
||||||
|
without the web poll.
|
||||||
|
- Commit the register change (`knowledge/best-practices.json` + any `.md` mirror) with a
|
||||||
|
`chore(knowledge):` message so the provenance bump is in git history.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Deterministic core, web-driven shell.** The stale classification is byte-stable and
|
||||||
|
unit-tested (`tests/lib/knowledge-refresh.test.mjs`, `tests/scanners/knowledge-refresh-cli.test.mjs`);
|
||||||
|
the candidate poll + writes are web/judgment-driven and deliberately **not** byte-stable
|
||||||
|
(mirrors `/config-audit optimize`).
|
||||||
|
- **Read-only CLI.** `knowledge-refresh-cli.mjs` never writes the register; all writes happen
|
||||||
|
here, in the command, after approval.
|
||||||
|
- The `-cli` suffix keeps it out of the scan-orchestrator, so the scanner count and the
|
||||||
|
snapshot suite are unaffected.
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: config-audit:manifest
|
name: config-audit:manifest
|
||||||
description: Show ranked token-source manifest — every CLAUDE.md, plugin, skill, MCP server, and hook ordered DESC by estimated tokens
|
description: Show ranked token-source manifest — every CLAUDE.md, rule, agent, skill, output style, MCP server, and hook ordered DESC by estimated tokens, each tagged with its load pattern (always-loaded vs on-demand vs external), plus an always-loaded subtotal
|
||||||
argument-hint: "[path] [--json]"
|
argument-hint: "[path] [--json]"
|
||||||
allowed-tools: Read, Bash
|
allowed-tools: Read, Bash
|
||||||
model: sonnet
|
model: sonnet
|
||||||
|
|
@ -10,6 +10,14 @@ model: sonnet
|
||||||
|
|
||||||
Produce a ranked, single-table view of every token source loaded for a given repo path. Where `whats-active` shows separate tables per category, `manifest` collapses everything into one ordered list — making it easy to see what's costing the most regardless of category.
|
Produce a ranked, single-table view of every token source loaded for a given repo path. Where `whats-active` shows separate tables per category, `manifest` collapses everything into one ordered list — making it easy to see what's costing the most regardless of category.
|
||||||
|
|
||||||
|
Every source is tagged with its **load pattern**, derived from the published Claude Code loading model:
|
||||||
|
|
||||||
|
- **always** — enters context every turn before you type (project/user CLAUDE.md, unscoped rules, agents, output styles, MCP tool schemas). This is the cost that matters most: it is paid on *every* request.
|
||||||
|
- **on-demand** — loaded only when needed (skill bodies on invoke, path-scoped rules on a matching file read).
|
||||||
|
- **external** — runs outside the context window entirely (hooks).
|
||||||
|
|
||||||
|
The **always-loaded subtotal** is the headline number. Sources are component-level (a plugin contributes via its skills/rules/agents/output styles/hooks/MCP, each listed once — there is no coarse "plugin" roll-up, which would double-count).
|
||||||
|
|
||||||
## UX Rules (MANDATORY — from `.claude/rules/ux-rules.md`)
|
## UX Rules (MANDATORY — from `.claude/rules/ux-rules.md`)
|
||||||
|
|
||||||
1. **Never show raw JSON or stderr output.** Always use `--output-file` + `2>/dev/null`.
|
1. **Never show raw JSON or stderr output.** Always use `--output-file` + `2>/dev/null`.
|
||||||
|
|
@ -51,21 +59,28 @@ Do NOT render the table in JSON mode.
|
||||||
|
|
||||||
### Step 4: Read JSON and render
|
### Step 4: Read JSON and render
|
||||||
|
|
||||||
Use the Read tool on `$TMPFILE`. Extract `meta.repoPath`, `total`, and `sources[]`. Render the top 20 sources (or fewer if the manifest is shorter):
|
Use the Read tool on `$TMPFILE`. Extract `meta.repoPath`, `total`, `summary`, and `sources[]`. Lead with the **always-loaded subtotal** (the headline), then render the top 20 sources (or fewer if the manifest is shorter):
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
**Token-source manifest for `<repoPath>`** — ~{total} tokens at startup
|
**Token-source manifest for `<repoPath>`** — ~{total} tokens total
|
||||||
|
|
||||||
| Rank | Kind | Name | Source | Tokens |
|
- 🔴 **~{summary.always.tokens} tokens enter context every turn** before you type ({summary.always.count} always-loaded sources)
|
||||||
|------|------|------|--------|--------|
|
- 🟡 ~{summary.onDemand.tokens} tokens on-demand ({summary.onDemand.count} sources — loaded only when invoked / matched)
|
||||||
| 1 | {kind} | `<name>` | {source} | ~{estimated_tokens} |
|
- ⚪ ~{summary.external.tokens} tokens external ({summary.external.count} sources — hooks, run outside context)
|
||||||
| ... | ... | ... | ... | ... |
|
|
||||||
|
|
||||||
|
| Rank | Kind | Name | Source | Tokens | Load |
|
||||||
|
|------|------|------|--------|--------|------|
|
||||||
|
| 1 | {kind} | `<name>` | {source} | ~{estimated_tokens} | {load} |
|
||||||
|
| ... | ... | ... | ... | ... | ... |
|
||||||
|
|
||||||
|
_Load column: **always** / **on-demand** / **external**. Append `°` when `derivationConfidence` is `inferred` (no primary-doc row pins it exactly)._
|
||||||
_Estimates assume ~4 chars/token (Claude ballpark). Real token count varies ±15%._
|
_Estimates assume ~4 chars/token (Claude ballpark). Real token count varies ±15%._
|
||||||
```
|
```
|
||||||
|
|
||||||
If `sources.length > 20`, follow the table with: _"Showing top 20 of {N} sources. Run with `--json` to see the full list."_
|
If `sources.length > 20`, follow the table with: _"Showing top 20 of {N} sources. Run with `--json` to see the full list."_
|
||||||
|
|
||||||
|
When narrating, prioritize the always-loaded subtotal: a large **always** source is worse than an equally large **on-demand** one, because it is paid on every request. Call out any single always-loaded source that dwarfs the rest.
|
||||||
|
|
||||||
### Step 5: Suggest next steps
|
### Step 5: Suggest next steps
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
|
|
@ -75,7 +90,7 @@ If `sources.length > 20`, follow the table with: _"Showing top 20 of {N} sources
|
||||||
- `/config-audit feature-gap` — what *could* improve here, grouped by impact
|
- `/config-audit feature-gap` — what *could* improve here, grouped by impact
|
||||||
```
|
```
|
||||||
|
|
||||||
Tone:
|
Tone (key on the **always-loaded subtotal** — the every-turn cost — not the grand total):
|
||||||
- High total (>50k): empathetic — "That's a heavy startup cost; tokens bullet anything you'd otherwise spend on the actual conversation."
|
- High always-loaded (>40k): empathetic — "That's a heavy per-turn cost; it taxes every request before you've typed a word. Look at the largest always-loaded sources first."
|
||||||
- Moderate (10–50k): neutral — "Reasonable. Skim the top 5 to see if anything is unexpectedly large."
|
- Moderate (10–40k): neutral — "Reasonable. Skim the top always-loaded sources to see if anything is unexpectedly large."
|
||||||
- Low (<10k): encouraging — "Tight setup. The model has plenty of room for the actual work."
|
- Low (<10k): encouraging — "Tight setup. The model has plenty of room for the actual work each turn."
|
||||||
|
|
|
||||||
123
commands/optimize.md
Normal file
123
commands/optimize.md
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
---
|
||||||
|
name: config-audit:optimize
|
||||||
|
description: Optimization lens — config that works but would fit a better mechanism (procedure→skill, lifecycle→hook, path→rule, never→permission)
|
||||||
|
argument-hint: "[path]"
|
||||||
|
allowed-tools: Read, Write, Glob, Grep, Bash, Agent
|
||||||
|
model: opus
|
||||||
|
---
|
||||||
|
|
||||||
|
# Config-Audit: Optimization Lens
|
||||||
|
|
||||||
|
The "is the config **optimal?**" axis (vs. the health scanners' "is it
|
||||||
|
**correct?**"). It finds configuration that *works* but uses a mechanism a
|
||||||
|
better one would fit — and frames every one as a *Missed opportunity*, never a
|
||||||
|
mistake.
|
||||||
|
|
||||||
|
Mechanism-fit rules come from the provenance-stamped best-practices register
|
||||||
|
(`knowledge/best-practices.json`); only CONFIRMED rules are surfaced. The motor
|
||||||
|
is hybrid: a cheap deterministic pre-filter finds candidates, then the opus
|
||||||
|
`optimization-lens-agent` judges each in context (precision-gated).
|
||||||
|
|
||||||
|
## What the user gets
|
||||||
|
|
||||||
|
- **Procedures → skills** (deterministic, CA-OPT-001)
|
||||||
|
- **Lifecycle phrasing → hooks** (BP-MECH-001)
|
||||||
|
- **Unscoped path-specific instructions → path-scoped rules** (BP-MECH-002)
|
||||||
|
- **Absolute "never" prohibitions → permissions / hooks** (BP-MECH-004)
|
||||||
|
|
||||||
|
Each finding cites its register rule + source URL. A clean CLAUDE.md returns "no
|
||||||
|
opportunities" — that is a good result, not a failure.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Step 1: Determine target
|
||||||
|
|
||||||
|
Split `$ARGUMENTS` into a path (first non-flag argument; default: current working
|
||||||
|
directory) and flags. Recognized flags: `--global` (include the user `~/.claude`
|
||||||
|
cascade in discovery).
|
||||||
|
|
||||||
|
Tell the user:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Optimization Lens
|
||||||
|
|
||||||
|
Looking for configuration that works but would fit a better Claude Code mechanism...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Run the lens CLI
|
||||||
|
|
||||||
|
Generate a session ID (`YYYYMMDD_HHmmss`) if no active session exists.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.claude/config-audit/sessions/{session-id} 2>/dev/null
|
||||||
|
GLOBAL_FLAG=""
|
||||||
|
if echo "$ARGUMENTS" | grep -q -- "--global"; then GLOBAL_FLAG="--global"; fi
|
||||||
|
node ${CLAUDE_PLUGIN_ROOT}/scanners/optimize-lens-cli.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/optimize-lens.json $GLOBAL_FLAG 2>/dev/null; echo $?
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit code 0 is normal. Only exit code 3 is a real error → "The lens couldn't run.
|
||||||
|
Check that the path exists and contains a CLAUDE.md."
|
||||||
|
|
||||||
|
### Step 3: Read the payload
|
||||||
|
|
||||||
|
Read `~/.claude/config-audit/sessions/{session-id}/optimize-lens.json` with the
|
||||||
|
Read tool. It has `deterministic` (already-confirmed OPT findings), `candidates`
|
||||||
|
(pre-filter candidates with register provenance), `register`, and `counts`.
|
||||||
|
|
||||||
|
**Early exit:** if `counts.deterministic === 0` and `counts.candidates === 0`,
|
||||||
|
skip the agent and tell the user plainly:
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ No mechanism-fit opportunities found.
|
||||||
|
|
||||||
|
Your CLAUDE.md holds facts, not procedures/automation/prohibitions that would be
|
||||||
|
better as skills, hooks, rules, or permissions. Nothing to change here.
|
||||||
|
```
|
||||||
|
|
||||||
|
Then go to Step 5.
|
||||||
|
|
||||||
|
### Step 4: Spawn the precision gate
|
||||||
|
|
||||||
|
Tell the user what's happening and set expectations:
|
||||||
|
|
||||||
|
```
|
||||||
|
Found {counts.candidates} candidate line(s) + {counts.deterministic} deterministic finding(s).
|
||||||
|
Asking the optimization-lens agent to judge each in context (~20-40 seconds)...
|
||||||
|
```
|
||||||
|
|
||||||
|
Spawn the `optimization-lens-agent` (Agent tool) with:
|
||||||
|
- the full payload from Step 3 (deterministic + candidates + register),
|
||||||
|
- the session directory path so it can write `optimization-lens-report.md`.
|
||||||
|
|
||||||
|
The agent reads the actual CLAUDE.md, drops low-confidence candidates, and keeps
|
||||||
|
only genuine opportunities — each citing its register rule + source.
|
||||||
|
|
||||||
|
### Step 5: Present results
|
||||||
|
|
||||||
|
Read the agent's `optimization-lens-report.md` and present it formatted
|
||||||
|
(markdown tables / grouped sections). Follow the UX rules: never show raw JSON or
|
||||||
|
scanner progress; lead with a one-sentence summary of what was found before the
|
||||||
|
detail. Make clear these are LOW-severity *opportunities*.
|
||||||
|
|
||||||
|
If the agent kept nothing from the candidates (all dropped) but there were
|
||||||
|
deterministic findings, show those; if it kept nothing at all, show the clean
|
||||||
|
result from Step 3.
|
||||||
|
|
||||||
|
### Step 6: Next steps
|
||||||
|
|
||||||
|
End with context-sensitive next steps, explaining WHY each is useful:
|
||||||
|
|
||||||
|
- `/config-audit plan` — turn the kept opportunities into an action plan with
|
||||||
|
backups before you change anything.
|
||||||
|
- `/config-audit feature-gap` — the complementary lens: features you *don't* use
|
||||||
|
yet (this command is about mechanisms you *do* use that could fit better).
|
||||||
|
- Re-run `/config-audit optimize` anytime after editing CLAUDE.md.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This command is **agent-driven and not byte-stable** — its output is a
|
||||||
|
human-facing report, deliberately outside the deterministic snapshot suite.
|
||||||
|
- The deterministic half (CA-OPT-001) also rides in the normal orchestrated
|
||||||
|
audit; this command adds the prose-judgment half on top.
|
||||||
|
- No files are modified. To act on a finding, use `/config-audit plan` →
|
||||||
|
`/config-audit implement` (backup + rollback) or edit by hand.
|
||||||
|
|
@ -28,6 +28,7 @@ Complementary to `/config-audit whats-active`:
|
||||||
Split `$ARGUMENTS` into a path and flags. Path is the first non-flag argument. Default to `.` (current working directory). Recognized flags:
|
Split `$ARGUMENTS` into a path and flags. Path is the first non-flag argument. Default to `.` (current working directory). Recognized flags:
|
||||||
|
|
||||||
- `--global` — also include the user-level `~/.claude/` cascade
|
- `--global` — also include the user-level `~/.claude/` cascade
|
||||||
|
- `--no-exclude-cache` — include stale `~/.claude/plugins/cache` versions in the ranking. **By default they are excluded** (cache-aware filtering, default ON): the cache holds superseded plugin versions that load on *zero* turns, and counting them used to crowd the top-10 with dead config. The active version of each plugin (per `installed_plugins.json`) is always kept — only stale versions are filtered. Use `--no-exclude-cache` to see the full on-disk walk.
|
||||||
- `--json` — emit raw JSON instead of rendered tables (power-user mode; bypasses the humanizer for byte-stable v5.0.0 output)
|
- `--json` — emit raw JSON instead of rendered tables (power-user mode; bypasses the humanizer for byte-stable v5.0.0 output)
|
||||||
- `--raw` — pass-through to the scanner; produces v5.0.0 verbatim JSON (bypasses the humanizer). Use when piping into v5.0.0-baseline diff tooling.
|
- `--raw` — pass-through to the scanner; produces v5.0.0 verbatim JSON (bypasses the humanizer). Use when piping into v5.0.0-baseline diff tooling.
|
||||||
- `--with-telemetry-recipe` — include `telemetry_recipe_path` in the JSON output, pointing to `knowledge/cache-telemetry-recipe.md`. Use this when you want to verify a structural fix actually improved cache hit rate (manual jq recipe, opt-in)
|
- `--with-telemetry-recipe` — include `telemetry_recipe_path` in the JSON output, pointing to `knowledge/cache-telemetry-recipe.md`. Use this when you want to verify a structural fix actually improved cache hit rate (manual jq recipe, opt-in)
|
||||||
|
|
@ -42,7 +43,7 @@ Default mode (no `--json`, no `--raw`) emits a humanized JSON envelope: each fin
|
||||||
TMPFILE="/tmp/config-audit-tokens-$$.json"
|
TMPFILE="/tmp/config-audit-tokens-$$.json"
|
||||||
RAW_FLAG=""
|
RAW_FLAG=""
|
||||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs <path> --output-file "$TMPFILE" [--global] $RAW_FLAG 2>/dev/null; echo $?
|
node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs <path> --output-file "$TMPFILE" [--global] [--no-exclude-cache] $RAW_FLAG 2>/dev/null; echo $?
|
||||||
```
|
```
|
||||||
|
|
||||||
**Exit code handling:**
|
**Exit code handling:**
|
||||||
|
|
@ -62,10 +63,14 @@ Do NOT render tables in JSON mode.
|
||||||
Use the Read tool on `$TMPFILE`. Extract:
|
Use the Read tool on `$TMPFILE`. Extract:
|
||||||
|
|
||||||
- `total_estimated_tokens` — top-line number
|
- `total_estimated_tokens` — top-line number
|
||||||
- `hotspots[]` — top 10 ranked sources
|
- `hotspots[]` — top 10 ranked sources; each carries a **load pattern** (`loadPattern` ∈ always / on-demand / external, plus `survivesCompaction` / `derivationConfidence`)
|
||||||
- `findings[]` — prompt-cache pattern findings (CA-TOK-001..003); each finding in default mode carries humanizer fields (`userImpactCategory`, `userActionLanguage`, `relevanceContext`) alongside the v5.0.0 fields
|
- `findings[]` — prompt-cache pattern findings; each finding in default mode carries humanizer fields (`userImpactCategory`, `userActionLanguage`, `relevanceContext`) alongside the v5.0.0 fields
|
||||||
- `counts` — severity breakdown
|
- `counts` — severity breakdown
|
||||||
|
|
||||||
|
A hotspot's **load pattern** matters as much as its size: an **always**-loaded source (CLAUDE.md, MCP tool schemas) is paid on *every* turn, an **on-demand** one (skill body, path-scoped rule) only when invoked/matched, and an **external** one (hooks, harness-config files like settings.json/.mcp.json) costs no per-turn context tokens at all. A big always-loaded hotspot is the most worth trimming.
|
||||||
|
|
||||||
|
**The stale plugin-cache finding is different from the rest.** All other TOK findings are about per-turn token cost. The *"Old plugin versions are sitting on disk"* finding (category `plugin-cache-hygiene`, impact **Dead config**, `--global` only) is a pure **disk-cleanup** item with **zero live-context impact** — the listed versions are never loaded. Render it as housekeeping, not a token problem: don't conflate its disk bytes with the per-turn token numbers above it.
|
||||||
|
|
||||||
Render as markdown. Group findings by `userImpactCategory` (e.g., "Wasted tokens" vs "Configuration mistake") rather than re-deriving severity prose; lead each line with `userActionLanguage` ("Fix this now", "Fix soon", "Optional cleanup", etc.) so the urgency phrasing stays consistent with the rest of the toolchain. The humanizer already replaced jargon-heavy `title`/`description`/`recommendation` strings with plain-language equivalents — render them verbatim.
|
Render as markdown. Group findings by `userImpactCategory` (e.g., "Wasted tokens" vs "Configuration mistake") rather than re-deriving severity prose; lead each line with `userActionLanguage` ("Fix this now", "Fix soon", "Optional cleanup", etc.) so the urgency phrasing stays consistent with the rest of the toolchain. The humanizer already replaced jargon-heavy `title`/`description`/`recommendation` strings with plain-language equivalents — render them verbatim.
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
|
|
@ -73,9 +78,11 @@ Render as markdown. Group findings by `userImpactCategory` (e.g., "Wasted tokens
|
||||||
|
|
||||||
### Top hotspots (ranked by estimated tokens)
|
### Top hotspots (ranked by estimated tokens)
|
||||||
|
|
||||||
| Rank | Source | Tokens | Recommendations |
|
| Rank | Source | Tokens | Load | Recommendations |
|
||||||
|------|--------|--------|-----------------|
|
|------|--------|--------|------|-----------------|
|
||||||
| {rank} | `{source}` | ~{estimated_tokens} | {recommendations joined as `· ` bullets} |
|
| {rank} | `{source}` | ~{estimated_tokens} | {loadPattern} | {recommendations joined as `· ` bullets} |
|
||||||
|
|
||||||
|
_Load column: **always** (every turn) / **on-demand** (on invoke/match) / **external** (out-of-context). Append `°` when `derivationConfidence` is `inferred`._
|
||||||
|
|
||||||
### Findings, grouped by impact
|
### Findings, grouped by impact
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,69 @@
|
||||||
> `.mcp.json`-dokumentasjon FØR endring — agenten kan ta feil i begge retninger. Manuelt forhåndsverifisert av
|
> `.mcp.json`-dokumentasjon FØR endring — agenten kan ta feil i begge retninger. Manuelt forhåndsverifisert av
|
||||||
> hovedkontekst: `xhigh` (settings-validator.mjs:66), manglende hook-events, 4.7-hardkoding, feature-evolution v2.1.111.
|
> hovedkontekst: `xhigh` (settings-validator.mjs:66), manglende hook-events, 4.7-hardkoding, feature-evolution v2.1.111.
|
||||||
|
|
||||||
|
## v5.4.0 reconciliation (2026-06-19 — supersedes the v5.3.0 block + rows below)
|
||||||
|
|
||||||
|
The six M-effort candidates deferred at v5.3.0 were re-verified against HEAD (`fe686b6`) on both
|
||||||
|
axes: **code-state** (still open?) and **CC premise** (real? — primary-source where risky).
|
||||||
|
See `docs/v5.4.0-release-plan.md` for the full per-candidate evidence.
|
||||||
|
|
||||||
|
**Operator GO 2026-06-19: "Option A"** → ship #1, #5, #4. No new scanner (badge stays 13).
|
||||||
|
|
||||||
|
| # | Candidate (row) | Code-state @HEAD | CC premise | Verdict |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | PLH shadow-folder (164) | OPEN (parses only name/desc/version) | CONFIRMED ~2.1.140 | **ship-5.4** → `CA-PLH-015` |
|
||||||
|
| 5 | PLH `skills:`-array dirs (165) | OPEN (no `parsed.skills` read) | CONFIRMED ~2.1.145 `plugin validate` | **ship-5.4** → `CA-PLH-016` |
|
||||||
|
| 4 | autoMode.hard_deny structure (179) | OPEN (key known, no nested val) | CONFIRMED (primary source) | **ship-5.4** → `CA-SET-NNN` |
|
||||||
|
| 2 | acceptEdits-writes shell/build (176) | OPEN (0 matches) | CONFIRMED ~2.1.160, file-list unpinned | **defer** (needs primary-source field list) |
|
||||||
|
| 6 | nested-.claude closest-wins (139/166) | OPEN (no scanner) | CONFIRMED ~2.1.178 | **defer** (NEW scanner, badge bump → own release) |
|
||||||
|
| 3 | Read-deny hides Glob/Grep (175) | PARTIAL (`permission-rules.mjs:158-159` hint only) | **REFUTED** by primary source | **wontfix** |
|
||||||
|
|
||||||
|
**Row 175 correction (Verifiseringsplikt).** The row was framed **backwards**. The CC permissions
|
||||||
|
doc states: *"Claude makes a best-effort attempt to apply `Read` rules to all built-in tools that
|
||||||
|
read files like Grep and Glob"* — so a Read deny **already** covers Glob/Grep. A "Read-deny is
|
||||||
|
bypassable → false security" finding would be a false positive (same failure mode as the invented
|
||||||
|
MCP `trust` field). The only real Read-deny bypass is a Bash subprocess (python/node script that
|
||||||
|
opens files itself), which is documented behavior, not a config mistake. The Windows-path half (CC
|
||||||
|
normalizes `C:\…` → `/c/…` before matching) is real but narrow/low-value → rolling maintenance.
|
||||||
|
|
||||||
|
The L-priority `update-knowledge` rows (env vars, model nuance, hook-output fields, plugin/skill
|
||||||
|
doc, nested-.claude doc) remain rolling knowledge maintenance — fold in opportunistically.
|
||||||
|
|
||||||
|
## v5.3.0 reconciliation (2026-06-19 — supersedes stale rows below)
|
||||||
|
|
||||||
|
This matrix was the **v5.2.0** plan. Verified against HEAD (`9b828fa`) during the v5.3.0 Session A
|
||||||
|
audit. **Read this block first** — individual rows below predate the v5.2.0 + 8-commit work.
|
||||||
|
|
||||||
|
**CLOSED in v5.2.0** (entire HIGH-priority false-positive cluster — verified in current code):
|
||||||
|
- `settings-validator.mjs` — all 11 keys + `xhigh` are in `KNOWN_KEYS`/`VALID_EFFORT_LEVELS`
|
||||||
|
(incl. `footerLinksRegexes`, `agent`, `parentSettingsBehavior`, `sandbox`, `enforceAvailableModels`,
|
||||||
|
`fallbackModel`, `disableBundledSkills`, `pluginSuggestionMarketplaces`, `requiredMin/MaxVersion`,
|
||||||
|
`allowAllClaudeAiMcps`, `wheelScrollAccelerationEnabled`). All `settings.json`/`model lineup`/
|
||||||
|
`permissions` now-wrong settings rows → CLOSED.
|
||||||
|
- `hook-validator.mjs` — 28 events incl. `MessageDisplay` + `post-session`; knowledge says 28. CLOSED.
|
||||||
|
- `mcp-config-validator.mjs` — POSIX/auto-injected env allowlisted; `trust` removed. CLOSED.
|
||||||
|
- `claude-md-linter.mjs` — HIGH@500 → MEDIUM token-cost reframe, context-window aware. CLOSED.
|
||||||
|
- DIS/CNF — parameter-aware identity (Agent(model:…)/WebFetch(domain:…) no longer collapsed). CLOSED.
|
||||||
|
|
||||||
|
**CLOSED by the 8 unreleased commits (v5.3.0 candidates):**
|
||||||
|
- disableBundledSkills recommendation (row 167) → `dfe9049`.
|
||||||
|
- permissions `'*'` deny-all + allow non-MCP glob (row 138) → `03949c6`.
|
||||||
|
- CLAUDE.md char budget on top of the reframe → `b0bf8c5` (new `CA-CML`).
|
||||||
|
- DIS forbidden-param check (rows 137/139, beyond matrix) → `d678765`.
|
||||||
|
- PLH namespace collision (search-first; **NOT** the row-129 shadow-folder check) → `c6c5f17`.
|
||||||
|
- SKL aggregate skill-listing budget (row 169) shipped in v5.2.0 (`CA-SKL-002`); `0a631e3` extracted
|
||||||
|
the shared `context-window.mjs`.
|
||||||
|
|
||||||
|
**STILL OPEN → all DEFER to v5.4 (none are bugs/false-positives; all M-effort enhancements):**
|
||||||
|
row 129 PLH shadow-folder · row 141 acceptEdits-prompts-on-config-writes · row 140 Read-deny-hides-
|
||||||
|
Glob/Grep + Windows path · row 144 autoMode.hard_deny structure · row 130 PLH `skills:`-array ·
|
||||||
|
rows 104/131 nested `.claude` closest-wins detection. Plus the L-priority `update-knowledge` rows
|
||||||
|
(env vars, model nuance, hook-output fields, plugin/skill doc) → rolling knowledge maintenance.
|
||||||
|
|
||||||
|
**v5.3.0 ship-list = EMPTY** (operator GO 2026-06-19, "Release-only"). 3 knowledge-backing entries
|
||||||
|
(`disableBundledSkills`, 40k char-budget, forbidden-param — absent from corpus, grep-verified)
|
||||||
|
fold into the v5.3.0 docs step. See `docs/v5.3.0-release-plan.md` § "v5.3.0 scope decision".
|
||||||
|
|
||||||
## Executive summary
|
## Executive summary
|
||||||
|
|
||||||
config-audits kunnskapsbase og scanner-kjent-sett er frosset ved ~v2.1.111; CC har shippet til v2.1.181 (Opus 4.8-æra). Av 130 verifiserte rader er det mest akutte en **klynge aktive false positives** — gyldig, dokumentert konfig flagges som feil i dag:
|
config-audits kunnskapsbase og scanner-kjent-sett er frosset ved ~v2.1.111; CC har shippet til v2.1.181 (Opus 4.8-æra). Av 130 verifiserte rader er det mest akutte en **klynge aktive false positives** — gyldig, dokumentert konfig flagges som feil i dag:
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ User-impact category (added to each finding as `userImpactCategory`, derived fro
|
||||||
|
|
||||||
| Label | Scanners |
|
| Label | Scanners |
|
||||||
|-------|----------|
|
|-------|----------|
|
||||||
| Configuration mistake | CML, SET, HKV, RUL, MCP, IMP, PLH |
|
| Configuration mistake | CML, SET, HKV, RUL, MCP, IMP, PLH, OST |
|
||||||
| Conflict | CNF, COL |
|
| Conflict | CNF, COL |
|
||||||
| Wasted tokens | TOK, CPS |
|
| Wasted tokens | TOK, CPS |
|
||||||
| Dead config | DIS |
|
| Dead config | DIS |
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,19 @@ Scanner CLI: `node scanners/scan-orchestrator.mjs <path> [--global] [--full-mach
|
||||||
|---------|--------|---------|
|
|---------|--------|---------|
|
||||||
| `claude-md-linter.mjs` | CML | Structure, length, sections, @imports, duplicates, TODOs |
|
| `claude-md-linter.mjs` | CML | Structure, length, sections, @imports, duplicates, TODOs |
|
||||||
| `settings-validator.mjs` | SET | Schema, unknown/deprecated keys, type mismatches, permissions |
|
| `settings-validator.mjs` | SET | Schema, unknown/deprecated keys, type mismatches, permissions |
|
||||||
| `hook-validator.mjs` | HKV | Format, script existence, event validity, timeouts |
|
| `hook-validator.mjs` | HKV | Format, script existence, event validity, timeouts, verbose-stdout (low), unfiltered `additionalContext` injection (info advisory, v5.10 B5) |
|
||||||
| `rules-validator.mjs` | RUL | Glob matching, orphan rules, deprecated fields, unscoped rules |
|
| `rules-validator.mjs` | RUL | Glob matching, orphan rules, deprecated fields, unscoped rules |
|
||||||
| `mcp-config-validator.mjs` | MCP | Server types, env vars, unknown fields |
|
| `mcp-config-validator.mjs` | MCP | Server types, env vars, unknown fields |
|
||||||
| `import-resolver.mjs` | IMP | Broken @imports, circular refs, deep chains, tilde paths |
|
| `import-resolver.mjs` | IMP | Broken @imports, circular refs, deep chains, tilde paths |
|
||||||
| `conflict-detector.mjs` | CNF | Settings conflicts, permission contradictions, hook duplicates |
|
| `conflict-detector.mjs` | CNF | Settings conflicts, permission contradictions, hook duplicates |
|
||||||
| `feature-gap-scanner.mjs` | GAP | 25 feature checks across 4 tiers — shown as opportunities, not grades |
|
| `feature-gap-scanner.mjs` | GAP | 25 feature checks across 4 tiers — shown as opportunities, not grades |
|
||||||
| `token-hotspots.mjs` | TOK | Cache-breaking volatile content, redundant tool permissions, deep import chains, oversized cascade, bloated SKILL.md descriptions, MCP tool-schema budget (prompt-cache patterns) |
|
| `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) |
|
| `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 | Tools listed in BOTH `permissions.deny` AND `permissions.allow` — deny wins, allow entries are dead config |
|
| `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 |
|
| `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) |
|
| `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/`)
|
## Scanner Lib (`scanners/lib/`)
|
||||||
|
|
||||||
|
|
@ -42,7 +44,7 @@ Scanner CLI: `node scanners/scan-orchestrator.mjs <path> [--global] [--full-mach
|
||||||
| `active-config-reader.mjs` | Read-only inventory: readActiveConfig(), detectGitRoot(), walkClaudeMdCascade(), readClaudeJsonProjectSlice() (longest-prefix match), enumeratePlugins(), enumerateSkills(), readActiveHooks(), readActiveMcpServers() (with cache → package.json tool-count fallback), estimateTokens() (v5: `'mcp'` kind = 500 + toolCount × 200) |
|
| `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 |
|
| `tokenizer-api.mjs` | Anthropic `count_tokens` wrapper for `--accurate-tokens` (v5 N5); 5s AbortController timeout, exponential 429 backoff, key masking |
|
||||||
| `humanizer.mjs` | Plain-language output translator (v5.1.0): `humanizeFinding`, `humanizeFindings`, `humanizeEnvelope`, `computeRelevanceContext`. Pure functions; never mutate inputs. Adds `userImpactCategory`, `userActionLanguage`, `relevanceContext` fields and replaces title/description/recommendation when a translation exists. Bypassed by `--raw` and `--json` paths. |
|
| `humanizer.mjs` | Plain-language output translator (v5.1.0): `humanizeFinding`, `humanizeFindings`, `humanizeEnvelope`, `computeRelevanceContext`. Pure functions; never mutate inputs. Adds `userImpactCategory`, `userActionLanguage`, `relevanceContext` fields and replaces title/description/recommendation when a translation exists. Bypassed by `--raw` and `--json` paths. |
|
||||||
| `humanizer-data.mjs` | TRANSLATIONS table for 14 scanner prefixes (CML/SET/HKV/RUL/MCP/IMP/CNF/COL/TOK/CPS/DIS/GAP/PLH/SKL). Three-step lookup: exact title → regex pattern → `_default` → fall through to original |
|
| `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/`)
|
## Action Engines (`scanners/`)
|
||||||
|
|
||||||
|
|
@ -53,8 +55,8 @@ Scanner CLI: `node scanners/scan-orchestrator.mjs <path> [--global] [--full-mach
|
||||||
| `fix-cli.mjs` | CLI: `node fix-cli.mjs <path> [--apply] [--json] [--global]` |
|
| `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]` |
|
| `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 |
|
| `whats-active.mjs` | CLI: `node whats-active.mjs <path> [--json] [--verbose] [--suggest-disables]` — read-only active-config inventory |
|
||||||
| `token-hotspots-cli.mjs` | CLI: `node token-hotspots-cli.mjs <path> [--json] [--global] [--output-file path] [--accurate-tokens] [--with-telemetry-recipe]` — prompt-cache token hotspots ranking with optional API calibration |
|
| `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 system-prompt token-source table (v5 N2) |
|
| `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
|
## Standalone Scanner
|
||||||
|
|
||||||
|
|
@ -75,3 +77,566 @@ Scanner CLI: `node scanners/scan-orchestrator.mjs <path> [--global] [--full-mach
|
||||||
| `gap-closure-templates.md` | Config-specific templates for closing gaps |
|
| `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 |
|
| `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) |
|
| `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).
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
**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).
|
||||||
|
|
|
||||||
157
docs/v5.13-model-routing-effort-deadref-plan.md
Normal file
157
docs/v5.13-model-routing-effort-deadref-plan.md
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
# v5.13 Plan — Model Routing, Effort Awareness, Dead References
|
||||||
|
|
||||||
|
Derived from an external video analysis ("The Model Isn't the Moat", 2026-07) cross-checked
|
||||||
|
against primary sources and against what config-audit already encodes. Every claim acted on
|
||||||
|
here was verified against Anthropic's own docs; video-only claims are explicitly rejected below.
|
||||||
|
|
||||||
|
## Source verification (done 2026-07-14)
|
||||||
|
|
||||||
|
| Claim from video | Verdict | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| Orchestrator + cheaper worker models is a supported, recommended pattern | VERIFIED | code.claude.com/docs/en/sub-agents ("Control costs by routing tasks to faster, cheaper models like Haiku"), code.claude.com/docs/en/workflows |
|
||||||
|
| Reasoning effort is tunable per settings / session / launch / **per-agent frontmatter** / SDK; levels `low, medium, high, xhigh, max` | VERIFIED | code.claude.com/docs/en/model-config#adjust-effort-level, sub-agents doc |
|
||||||
|
| Leaked Fable 5 system prompt principles ("partial recognition ≠ current knowledge"; "a prompt implying a file is present doesn't mean one is"; answer-first-then-one-question; tool-call scaling 1 / 3–5 / 5–10) | VERIFIED near-verbatim, **provenance unconfirmed** (third-party leak repo, not Anthropic-confirmed) | github.com/asgeirtj/system_prompts_leaks `Anthropic/claude-fable-5.md` |
|
||||||
|
| "Fable 5 on low ≈ Opus 4.8 on high, slightly higher cost/quality" score-vs-cost chart | **CONTRADICTED** — no such chart/statement on Anthropic's pages; GPT-5.5 appears only in a testimonial | anthropic.com/news/claude-fable-5-mythos-5 |
|
||||||
|
|
||||||
|
## Already covered — no action
|
||||||
|
|
||||||
|
| Video idea | Existing coverage |
|
||||||
|
|---|---|
|
||||||
|
| "Process is the moat" (config/harness > raw model) | The plugin's entire thesis |
|
||||||
|
| Extract repeated procedure into a skill | BP-MECH-003 + CA-OPT-001 (`optimization-lens-scanner.mjs:121`) |
|
||||||
|
| CLAUDE.md size/ownership discipline | BP-SIZE-001 + CA-CML line/size checks (`claude-md-linter.mjs:109/:120/:140`) |
|
||||||
|
| Check that referenced imports exist | CA-IMP broken `@import` (`lib/import-resolver.mjs:88`) — but **only** `@import`, see Chunk 3 |
|
||||||
|
| Plugin's own agents are model-routed | Agents table already pins sonnet for mechanical, opus for judgment |
|
||||||
|
|
||||||
|
## Gaps → chunks
|
||||||
|
|
||||||
|
Verified gap summary (register-mapper sweep, 2026-07-14): no scanner audits per-agent
|
||||||
|
`model:`/`effort:` frontmatter; effort has validity-check only (`settings-validator.mjs:195`),
|
||||||
|
no recommendation; no dead-reference check for prose file mentions in CLAUDE.md; no
|
||||||
|
adversarial/failure-mode requirement in planner-agent; `fix-engine.mjs:26` effort list
|
||||||
|
omits `xhigh` (settings-validator has all five).
|
||||||
|
|
||||||
|
### Chunk 1 — Register entries: model routing + effort (dogfoods `knowledge-refresh`)
|
||||||
|
|
||||||
|
Add to `knowledge/best-practices.json` via the knowledge-refresh flow (human-approved write):
|
||||||
|
|
||||||
|
- **BP-MODEL-001** (`category: model-fit`): subagents doing mechanical/read-only work can pin a
|
||||||
|
cheaper model via `model:` frontmatter; orchestrator keeps the strong model. Source:
|
||||||
|
code.claude.com/docs/en/sub-agents → `confidence: confirmed`.
|
||||||
|
- **BP-MODEL-002** (`category: model-fit`): reasoning effort is tunable at five levels in five
|
||||||
|
places (settings `effortLevel`, `/effort`, `--effort`, per-agent `effort` frontmatter, SDK);
|
||||||
|
default `high`; higher effort is not universally better for simple tasks. Source:
|
||||||
|
code.claude.com/docs/en/model-config → `confidence: confirmed`.
|
||||||
|
|
||||||
|
Schema per `scanners/lib/best-practices-register.mjs:42-102` (id/claim/confidence/source.url/
|
||||||
|
source.verified required). This chunk doubles as the DEL B dogfood of `/config-audit
|
||||||
|
knowledge-refresh` (each chunk is also a plugin test).
|
||||||
|
|
||||||
|
### Chunk 2 — fix-engine effort hygiene (tiny, TDD)
|
||||||
|
|
||||||
|
`fix-engine.mjs:26` `VALID_EFFORT_LEVELS = ['low','medium','high','max']` — missing `xhigh`.
|
||||||
|
Consequence: nearest-match "fix" for a typo like `xhig` corrects to `high`, not `xhigh`.
|
||||||
|
Red test first: `findNearestEffortLevel('xhig') === 'xhigh'`. Align list with
|
||||||
|
`settings-validator.mjs:75`.
|
||||||
|
|
||||||
|
### Chunk 3 — CA-CML dead prose references (new deterministic check)
|
||||||
|
|
||||||
|
The strongest video-derived principle ("a prompt implying a file is present doesn't mean one
|
||||||
|
is") applied to CLAUDE.md quality: flag file paths mentioned in CLAUDE.md **prose** that do not
|
||||||
|
exist on disk. Today only `@import` targets are existence-checked; stale pointers like
|
||||||
|
`docs/foo.md` or `scripts/bar.sh` rot silently and burn always-loaded tokens on misdirection.
|
||||||
|
|
||||||
|
Conservative v1 to control false positives:
|
||||||
|
- Only backtick-quoted tokens that look like relative file paths (contain `/` or a known
|
||||||
|
extension), resolved against the CLAUDE.md's own directory.
|
||||||
|
- Skip URLs, globs (`*`), placeholders (`{...}`, `<...>`, `$VAR`, `${...}`), absolute and
|
||||||
|
`~/` paths (machine-specific), and paths under `.gitignore`d dirs if cheap to determine.
|
||||||
|
- Severity: low. New CA-CML-NNN (verify next free NNN at implementation — IDs are dynamic).
|
||||||
|
|
||||||
|
Byte-stability: follow [[adding-scanner-byte-stability]] steps for a new finding type in an
|
||||||
|
EXISTING scanner — frozen `tests/snapshots/v5.0.0/` must stay untouched; default-output
|
||||||
|
snapshots regenerate (`UPDATE_SNAPSHOT=1`) only if a fixture actually carries the new type;
|
||||||
|
humanizer step 7 (M-16/M-17 lessons): `TRANSLATIONS`-static entry for the new RAW title
|
||||||
|
(CML category mapping already exists).
|
||||||
|
|
||||||
|
### Chunk 4 — feature-gap + inventory: model/effort awareness
|
||||||
|
|
||||||
|
- New T3 opportunity check in `feature-gap-scanner.mjs`: authored agents
|
||||||
|
(`isAuthoredConfig`, M-BUG-13 lesson) where **no** agent sets `model:` or `effort:` →
|
||||||
|
"all agents inherit the session model/effort — mechanical agents can be routed cheaper /
|
||||||
|
effort-calibrated" citing BP-MODEL-001/002. Fires only when authored agents exist
|
||||||
|
(M-BUG-15 lesson: no enhancement-check on empty collections). Opportunity framing, never
|
||||||
|
failure — deliberate max-model setups are a valid choice; finding is suppressable
|
||||||
|
(`.config-audit-ignore`).
|
||||||
|
- `whats-active` / `manifest`: surface `model`/`effort` per agent in the inventory tables.
|
||||||
|
- Humanizer wiring step 7 for the new GAP finding; verify via direct `scan()` output, not the
|
||||||
|
self-suppressed default output ([[agent-commands-need-scanner-scoping]]).
|
||||||
|
- feat commit → docs-gate: README + CLAUDE.md diffs required.
|
||||||
|
|
||||||
|
### Chunk 5 — planner-agent adversarial gate (do AFTER DEL B pipeline dogfood)
|
||||||
|
|
||||||
|
Add a required "Failure modes" section to `agents/planner-agent.md`'s action-plan contract:
|
||||||
|
before an action plan is emitted, list what could go wrong per change + rollback trigger.
|
||||||
|
Mirrors the video's scoping-vs-devil's-advocate distinction; currently absent (zero
|
||||||
|
adversarial requirements in agents/). **Sequencing constraint:** DEL B step 3.2 judges
|
||||||
|
planner-agent against a fasit — change the agent only after that dogfood pass, or the
|
||||||
|
fasit target moves mid-evaluation.
|
||||||
|
|
||||||
|
## Explicitly rejected (do not revisit without new evidence)
|
||||||
|
|
||||||
|
1. **"Fable low ≈ Opus high" cost/score framing** — contradicted by Anthropic's own pages.
|
||||||
|
Never encode in register, copy, or recommendations.
|
||||||
|
2. **Tool-call-count effort scaling (1 / 3–5 / 5–10) as a register entry** — source is an
|
||||||
|
unconfirmed third-party leak → would be `confidence: inferred`, never surfaced. Not worth
|
||||||
|
carrying.
|
||||||
|
3. **Cost/intelligence/"taste" model-routing table generator** — subjective scores don't fit
|
||||||
|
the deterministic, provenance-gated design. BP-MODEL-001 covers the actionable core.
|
||||||
|
4. **"Fable mode" skill** — a user-level skill, not configuration auditing. Out of plugin scope.
|
||||||
|
5. **CLAUDE.md prose contradiction detection** — real gap (CA-CNF only covers
|
||||||
|
settings/permissions/hooks) but not video-driven; keep this plan surgical.
|
||||||
|
|
||||||
|
## Known tension (named, not resolved here)
|
||||||
|
|
||||||
|
The operator's own global policy is Opus/max-effort for ALL subagents, never Haiku — the
|
||||||
|
opposite of Chunk 4's recommendation. Both are legitimate: the docs-backed routing advice
|
||||||
|
optimizes cost at equal quality for the general user; the operator deliberately buys maximum
|
||||||
|
quality. Chunk 4's copy must respect that (opportunity framing + suppressability), and on this
|
||||||
|
machine the finding will simply be suppressed or ignored. The plugin serves general users;
|
||||||
|
the operator's setup is not the target of the check.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Global, after every chunk:
|
||||||
|
- `node --test 'tests/**/*.test.mjs'` → green (baseline 1359/0; count grows with new tests)
|
||||||
|
- `git status --porcelain tests/snapshots/v5.0.0/` → empty (frozen untouched)
|
||||||
|
- TDD: red test exists and fails BEFORE each production change
|
||||||
|
|
||||||
|
Per chunk:
|
||||||
|
- **C1:** `node --test tests/lib/best-practices-register.test.mjs` green;
|
||||||
|
`node scanners/knowledge-refresh-cli.mjs` classifies BP-MODEL-001/002 as fresh
|
||||||
|
- **C2:** `findNearestEffortLevel('xhig')` → `xhigh` (red first); `grep xhigh scanners/fix-engine.mjs` non-empty
|
||||||
|
- **C3:** fixture CLAUDE.md referencing `docs/missing.md` → finding fires; existing file /
|
||||||
|
URL / glob / placeholder / `~/` path → silent; humanized output has non-contradictory copy
|
||||||
|
- **C4:** authored-agent fixture without model/effort → opportunity fires; with either set →
|
||||||
|
silent; zero authored agents → silent; `userImpactCategory` ≠ `Other` end-to-end via direct scan()
|
||||||
|
- **C5:** dogfood plan run produces a Failure-modes section; DEL B 3.2 fasit judged BEFORE the change
|
||||||
|
|
||||||
|
## Key assumptions (test before/at implementation)
|
||||||
|
|
||||||
|
1. **Per-agent `effort` frontmatter is official** — verified 2026-07-14 against
|
||||||
|
code.claude.com/docs/en/sub-agents + /model-config; re-fetch both pages at implementation
|
||||||
|
(docs move).
|
||||||
|
2. **New finding type in existing scanner leaves frozen snapshots untouched** — M-17 precedent
|
||||||
|
says yes when no v5.0.0 fixture carries the type; verify by running the suite and inspecting
|
||||||
|
which snapshots differ before committing.
|
||||||
|
3. **Next free CA-CML/CA-GAP finding numbers** — IDs are built dynamically; grep tests +
|
||||||
|
snapshots for the highest used NNN before assigning.
|
||||||
|
|
||||||
|
## Sequencing vs DEL B (one plan, no relitigation)
|
||||||
|
|
||||||
|
This plan does NOT preempt the active DEL B sequence. Recommended order:
|
||||||
|
1. DEL B step 3 pipeline dogfood (`analyze → plan → implement → rollback`) — unchanged, next.
|
||||||
|
2. Batch patch release M-11→M-17 — unchanged.
|
||||||
|
3. v5.13 chunks 1→5 (chunk 1 doubles as the `knowledge-refresh` dogfood already queued in
|
||||||
|
DEL B "Resten"; chunk 5 explicitly waits for step 3.2). Release as minor v5.13.0 via
|
||||||
|
`release-plugin.mjs` when all chunks land.
|
||||||
240
docs/v5.3.0-release-plan.md
Normal file
240
docs/v5.3.0-release-plan.md
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
# v5.3.0 Release — Multi-Session Plan
|
||||||
|
|
||||||
|
_Status: PLANNED. No version bump yet. `plugin.json` = 5.2.0. All work below is gated on
|
||||||
|
explicit operator GO per session. Plans live next to STATE per the continuity system._
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Release **v5.3.0** (minor, backward-compatible) covering the work that has accumulated on
|
||||||
|
`main` since the v5.2.0 release (`1576909`, 2026-06-18), with a **fully updated README +
|
||||||
|
CHANGELOG**. The features are already implemented and on `main`; the bulk of this release is
|
||||||
|
documentation, changelog narrative, version sync, and a tag — plus a decision on whether any
|
||||||
|
remaining CC-gap items should ship in 5.3.0 first.
|
||||||
|
|
||||||
|
## Pre-verified facts (do NOT re-derive)
|
||||||
|
|
||||||
|
- Last release: **v5.2.0** = commit `1576909`, 2026-06-18. Prior: v5.1.0 (2026-05-01).
|
||||||
|
- Unreleased commits on `main` since v5.2.0 (8):
|
||||||
|
| Commit | Type | 5.3.0 changelog section |
|
||||||
|
|--------|------|-------------------------|
|
||||||
|
| `0a631e3` | refactor(skl): extract skill-listing budget to shared lib | Internal |
|
||||||
|
| `dfe9049` | feat(feature-gap): `disableBundledSkills` under listing pressure | Added |
|
||||||
|
| `03949c6` | feat(dis): ineffective allow wildcards; `Tool(*)` = deny-all | Added |
|
||||||
|
| `b0bf8c5` | feat(cml): context-window-scaled CLAUDE.md char budget | Added |
|
||||||
|
| `d678765` | feat(dis): forbidden-param permission rules | Added |
|
||||||
|
| `c6c5f17` | feat(plh): plugin namespace collision (same declared name) | Added |
|
||||||
|
| `0874188` | fix(plh): cross-plugin command overlap HIGH→LOW | **Changed** |
|
||||||
|
| `96743ec` | fix(readme): add SKL row to scanner table `[skip-docs]` | Fixed (docs) |
|
||||||
|
- **Scanner count unchanged: 13.** The 7 features extended EXISTING scanners (CML/DIS/PLH/GAP) —
|
||||||
|
no new scanner file. So the `scanners-13` badge, the table (now 13 rows after `96743ec`), and
|
||||||
|
`countScannerShape` need **no change** for 5.3.0. Do not bump the scanner badge.
|
||||||
|
- **One behavior change to call out** (`0874188`): the cross-plugin command-name finding went
|
||||||
|
**HIGH → LOW** and was reframed from "conflict" to "ambiguity" (namespacing keeps both
|
||||||
|
commands reachable). Scoring impact: any config with cross-plugin command-name overlap scores
|
||||||
|
slightly higher now. Not a `--json`-shape break; no test asserted the old HIGH (verified
|
||||||
|
2026-06-19). Document under **Changed**, not Breaking.
|
||||||
|
- Suite at HEAD: **936/936**. self-audit: configGrade A 97, pluginGrade A 100, readmeCheck.passed.
|
||||||
|
- Edit-target files (from the v5.2.0 release pattern, `docs/v5.2.0-release-plan.md`):
|
||||||
|
`.claude-plugin/plugin.json`, `README.md` (badges + What's New + version-history row + TOC),
|
||||||
|
`CHANGELOG.md` (`## [5.3.0]` block above `## [5.2.0]`), and `docs/cc-2.1.x-gap-matrix.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session A — Release-readiness audit & scope decision (read/plan; 1 doc output)
|
||||||
|
|
||||||
|
**No code changes.** Decide exact 5.3.0 scope and surface the "build more first?" question.
|
||||||
|
|
||||||
|
1. **Reconcile `docs/cc-2.1.x-gap-matrix.md` against current code.** STATE flags it as stale.
|
||||||
|
For each matrix row, mark which of the 7 feature commits closed it; list every still-open gap.
|
||||||
|
2. **Classify each still-open gap** → `ship-in-5.3.0` / `defer-to-5.4` / `wontfix`, with a
|
||||||
|
one-line rationale and effort tag. **Operator GO required** on the ship-list before any build.
|
||||||
|
3. **Draft the CHANGELOG narrative** — map all 8 unreleased commits to Added/Changed/Fixed/Internal
|
||||||
|
bullets (table above is the starting point). Draft the "What's New in v5.3.0" prose.
|
||||||
|
4. **Confirm version = 5.3.0** (minor) and write the Changed-note for the PLH severity downgrade.
|
||||||
|
5. **Knowledge review** — check whether any `knowledge/*.md` corpus file needs an update for the
|
||||||
|
7 features (e.g. permission-rule or plugin-namespacing corpus). List files to touch, if any.
|
||||||
|
|
||||||
|
**Output:** updated `cc-2.1.x-gap-matrix.md`; a `## v5.3.0 scope decision` block appended to THIS
|
||||||
|
file (ship-list + operator GO + draft changelog bullets + knowledge-touch list).
|
||||||
|
|
||||||
|
**Verifisering (testbar):**
|
||||||
|
- Every one of the 8 unreleased commits appears in exactly one draft changelog bullet.
|
||||||
|
- Every still-open matrix gap has a `ship/defer/wontfix` verdict recorded.
|
||||||
|
- Operator GO on the ship-list is recorded verbatim in the scope-decision block.
|
||||||
|
- `git log 1576909..HEAD` count matches the number of mapped commits (no commit dropped).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session B — (conditional) implement approved remaining features
|
||||||
|
|
||||||
|
**Runs only if Session A's ship-list is non-empty.** If empty → skip entirely; 5.3.0 is a
|
||||||
|
docs-and-release-only version.
|
||||||
|
|
||||||
|
- One feature per chunk, **/tdd: failing test FIRST** (Iron Law), then minimal implementation.
|
||||||
|
- Per feature commit: self-audit gates + docs-gate (README **and** CLAUDE ≥3 substantive lines, or
|
||||||
|
`[skip-docs]` for non-feature commits). Stage docs in a separate Bash call before commit.
|
||||||
|
- If a feature adds a **new scanner**, THEN (and only then) bump the `scanners-` badge, the table,
|
||||||
|
and `countScannerShape` — and note it for Session C's "What's New".
|
||||||
|
- Checkpoint STATE + this plan after each feature (chunk-before-compaction).
|
||||||
|
|
||||||
|
**Verifisering (per feature):** new tests RED→GREEN; full suite N/N; self-audit configGrade ≥ A,
|
||||||
|
pluginGrade ≥ A, readmeCheck.passed; contamination-grep + gitleaks clean on any snapshot/fixture touch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session C — Release v5.3.0 (version sync + docs + tag + push)
|
||||||
|
|
||||||
|
1. **Bump** `.claude-plugin/plugin.json` 5.2.0 → 5.3.0. (Version lives ONLY here per STATE.)
|
||||||
|
2. **README:**
|
||||||
|
- `version-` badge → 5.3.0; `tests-` badge → current exact case count.
|
||||||
|
- Replace "What's New in v5.2.0" with **What's New in v5.3.0** (+ update the TOC anchor at the top).
|
||||||
|
- Insert a new **version-history table row** above the `**5.2.0**` row.
|
||||||
|
- Sweep for stale prose. **Scanner count stays 13** unless Session B added a scanner.
|
||||||
|
3. **CHANGELOG.md:** insert `## [5.3.0] - <release-date>` block above `## [5.2.0]`
|
||||||
|
(Added / Changed / Fixed / Internal / Test count / Verification — mirror the 5.2.0 block).
|
||||||
|
4. **Knowledge/docs:** apply any updates Session A flagged.
|
||||||
|
5. **Marketplace cross-repo (CONDITIONAL — verify first):** check whether the marketplace catalog
|
||||||
|
(`../.claude-plugin/marketplace.json` or the marketplace README) references config-audit's
|
||||||
|
version or feature list. If yes → update there too. Separate repo → **same push-window rules,
|
||||||
|
separate push**. (Not yet confirmed to exist; Session C verifies before assuming.)
|
||||||
|
6. **Gates (all must pass before commit):**
|
||||||
|
- `node --test 'tests/**/*.test.mjs'` → N/N green.
|
||||||
|
- `node scanners/self-audit.mjs --json --check-readme` → readmeCheck.passed, configGrade A,
|
||||||
|
pluginGrade A, scanner count consistent with badge+table.
|
||||||
|
- SC-5 snapshot byte-equal (or re-seeded + contamination-grep clean); gitleaks clean.
|
||||||
|
7. **Commit** `release: v5.3.0 — <one-line summary>`, **tag** `v5.3.0`, **push** (plugin repo;
|
||||||
|
marketplace repo if step 5 applies) inside the push window.
|
||||||
|
|
||||||
|
**Verifisering (testbar):**
|
||||||
|
- `git tag --list v5.3.0` returns `v5.3.0`.
|
||||||
|
- README `version-` badge value == `.claude-plugin/plugin.json` `version`.
|
||||||
|
- `self-audit --check-readme` → `readmeCheck.passed: true`, badge tests == suite case count.
|
||||||
|
- `git log -1` subject starts with `release: v5.3.0`.
|
||||||
|
- If marketplace updated: its config-audit entry shows 5.3.0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key assumptions (test, don't trust)
|
||||||
|
|
||||||
|
- **5.3.0 is non-breaking.** Verify: `json-backcompat` + `raw-backcompat` + snapshot tests green
|
||||||
|
(they are at HEAD); the PLH severity change alters a severity VALUE, not the `--json` shape, and
|
||||||
|
no test/consumer asserts the old HIGH. If Session B adds anything that changes the JSON shape or
|
||||||
|
scoring math materially → re-classify as a Breaking note (like the 5.0.0 row).
|
||||||
|
- **docs-gate** blocks any commit lacking ≥3 substantive lines in BOTH README and CLAUDE — use
|
||||||
|
`[skip-docs]` for pure release/doc commits, or make both edits substantive. (Proven 2026-06-19.)
|
||||||
|
- **self-audit `--check-readme`** only checks badge NUMBERS vs filesystem, NOT prose-table
|
||||||
|
completeness — so manually re-verify the scanner table and "What's New" by eye (the SKL-row gap
|
||||||
|
`96743ec` slipped exactly because the gate is number-only).
|
||||||
|
|
||||||
|
## Out of scope (do not start without separate GO)
|
||||||
|
|
||||||
|
- New CC-gap features beyond Session A's approved ship-list.
|
||||||
|
- Any scanner refactor not required by an approved feature.
|
||||||
|
- Marketplace-wide changes beyond the config-audit catalog entry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v5.3.0 scope decision (Session A output, 2026-06-19)
|
||||||
|
|
||||||
|
### Operator GO (recorded verbatim)
|
||||||
|
> **"Release-only, ship-list tom"** — Session B SKIP; knowledge-backing (disableBundledSkills,
|
||||||
|
> 40k char-budget, forbidden-param) folded into Session C; open M-gaps → v5.4 backlog.
|
||||||
|
|
||||||
|
**Ship-list = EMPTY.** Session B is skipped entirely. v5.3.0 is a docs + knowledge + release
|
||||||
|
of the work already on `main`. No new feature build.
|
||||||
|
|
||||||
|
### Reconciliation result (matrix vs. current code)
|
||||||
|
The gap-matrix was the **v5.2.0** plan. Verified against HEAD: the entire HIGH-priority
|
||||||
|
false-positive cluster is **already CLOSED** — `settings-validator` (all 11 keys + `xhigh`),
|
||||||
|
`hook-validator` (28 events incl. `MessageDisplay`/`post-session`), `mcp-config-validator`
|
||||||
|
(POSIX/auto-injected env + `trust` removed), `claude-md-linter` (HIGH→MEDIUM reframe), DIS/CNF
|
||||||
|
(param-aware). The 8 unreleased commits add incrementally on top. **No active false positive or
|
||||||
|
behavior bug remains unshipped.** Full row-by-row verdicts appended to `cc-2.1.x-gap-matrix.md`.
|
||||||
|
|
||||||
|
### Still-open gaps → verdicts (all defer; none are bugs)
|
||||||
|
| Gap (matrix row) | Effort | Verdict |
|
||||||
|
|---|---|---|
|
||||||
|
| PLH shadow-folder: plugin.json key shadows default component folder (129) | M | defer-5.4 |
|
||||||
|
| permissions: acceptEdits prompts on shell-startup/build-config writes (141) | M | defer-5.4 |
|
||||||
|
| permissions: Read-deny hides Glob/Grep; Windows backslash/case path (140) | M | defer-5.4 |
|
||||||
|
| settings: autoMode.hard_deny nested-structure validation (144) | M | defer-5.4 |
|
||||||
|
| PLH: validate `skills:` array entries are dirs within plugin (130) | M | defer-5.4 |
|
||||||
|
| CLAUDE.md: nested `.claude` closest-wins conflict detection (104, 131) | M | defer-5.4 |
|
||||||
|
| Knowledge L-rows: env vars, model-lineup nuance, hook-output fields, plugin/skill doc rows | S | defer (rolling knowledge maintenance) |
|
||||||
|
|
||||||
|
### Draft CHANGELOG narrative (all 9 commits `1576909..HEAD` accounted for)
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
- **DIS forbidden-param rules** (`d678765`) — flags `Tool(param:value)` whose key is the tool's
|
||||||
|
own canonicalizing field (`command`/`file_path`/`path`/`notebook_path`/`url`); CC ignores these
|
||||||
|
and emits a startup warning. Severity by intent: **deny/ask = false security (medium)**,
|
||||||
|
**allow = dead config (low)**. Valid forms (`Bash(npm:*)`, `WebFetch(domain:host)`,
|
||||||
|
`Agent(model:opus)`) never flagged. Predicate `forbiddenParamRule` in `permission-rules.mjs`.
|
||||||
|
- **DIS ineffective allow wildcards + `Tool(*)` deny-all** (`03949c6`) — flags unanchored
|
||||||
|
tool-name globs in `permissions.allow` (`*`, `B*`, `mcp__*`) that CC silently skips (low);
|
||||||
|
treats `Tool(*)` as deny-all (`Bash(*)` ≡ `Bash`) so a bare allow killed by it is reported
|
||||||
|
as dead config. Valid `mcp__<server>__*` never flagged.
|
||||||
|
- **CML context-window-scaled char budget** (`b0bf8c5`) — new `CA-CML` finding mirroring CC's
|
||||||
|
startup warning *"Large CLAUDE.md will impact performance (X chars > 40.0k)"*. Anchors on the
|
||||||
|
conservative 200k window; discloses the relaxed ~200,000-char figure at 1M context. Severity
|
||||||
|
**medium** (token cost). Char-keyed, complementary to the 200/500-line checks. Window constants
|
||||||
|
in shared `scanners/lib/context-window.mjs`.
|
||||||
|
- **PLH plugin namespace collision** (`c6c5f17`) — flags 2+ discovered plugins declaring the same
|
||||||
|
`name` in `plugin.json`. Namespaces collapse; CC picks an undocumented winner; the loser's
|
||||||
|
commands/skills/agents go silently unreachable. Severity **medium** (dead config),
|
||||||
|
`category: 'plugin-hygiene'`, COL-shaped `details.namespaces`. Keys on declared `name`, not
|
||||||
|
`basename(dir)`; name-less plugins excluded.
|
||||||
|
- **feature-gap `disableBundledSkills` lever** (`dfe9049`) — conditional recommendation to set
|
||||||
|
`disableBundledSkills` when the active skill listing is over budget; remediation companion to
|
||||||
|
SKL `CA-SKL-002`.
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- **PLH cross-plugin command-name overlap: HIGH → LOW** (`0874188`) — reframed from "conflict" to
|
||||||
|
"ambiguity". Commands are namespaced (`/name:command`) so both stay reachable; only a same-`name`
|
||||||
|
plugin collision loses components (covered by the new namespace-collision finding). Now
|
||||||
|
group-first (one finding per command name listing every namespace), COL-shaped
|
||||||
|
`details.namespaces`. The old HIGH `Cross-plugin command name conflict` finding **and its
|
||||||
|
humanizer entry are removed**. Scoring impact: configs with cross-plugin command overlap score
|
||||||
|
slightly higher. **Not a `--json`-shape break** — no test/consumer asserted the old HIGH.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- **README scanner table** (`96743ec`) — added the missing SKL row (12 → 13 rows); the prose table
|
||||||
|
lagged the badge/self-audit count (the `--check-readme` gate is number-only and didn't catch it).
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
- **Extract skill-listing budget to shared lib** (`0a631e3`) — `scanners/lib/context-window.mjs`
|
||||||
|
as single source of truth for the 200k/1M window constants, re-exported by
|
||||||
|
`skill-listing-budget.mjs` and consumed by the new CML char-budget finding.
|
||||||
|
|
||||||
|
**Excluded from changelog:** `9b828fa` `docs(plan): v5.3.0 multi-session release plan [skip-docs]`
|
||||||
|
— the plan meta-commit itself, not a product change. (9 commits total = 8 mapped + 1 excluded.)
|
||||||
|
|
||||||
|
### What's New in v5.3.0 (draft prose)
|
||||||
|
> **v5.3.0 — permission-rule & plugin-hygiene hardening.** Five additive scanner findings extend
|
||||||
|
> the permission, CLAUDE.md, and plugin surfaces: DIS now catches forbidden-parameter rules and
|
||||||
|
> ineffective allow-wildcards that Claude Code silently ignores; CML mirrors CC's own 40.0k-char
|
||||||
|
> "large CLAUDE.md" startup warning, context-window scaled; PLH flags two plugins that declare the
|
||||||
|
> same name (one silently shadows the other); and feature-gap recommends `disableBundledSkills`
|
||||||
|
> when the skill listing is over budget. The cross-plugin command-name finding is reframed from a
|
||||||
|
> HIGH conflict to a LOW ambiguity to match Claude Code's namespacing. Scanner count stays **13**
|
||||||
|
> (all extend existing scanners). `--json`/`--raw` remain byte-stable.
|
||||||
|
|
||||||
|
### Version & non-breaking confirmation
|
||||||
|
- **5.3.0** (minor). Additive findings + one severity downgrade (PLH HIGH→LOW). No envelope/JSON
|
||||||
|
shape change; `json-backcompat` + `raw-backcompat` + SC-5 snapshot green at HEAD. The PLH change
|
||||||
|
alters a severity VALUE, not the shape. **Document the downgrade under Changed, not Breaking.**
|
||||||
|
|
||||||
|
### Knowledge-touch list (apply in Session C step 4)
|
||||||
|
Three short backing entries for the v5.3.0 features (rows currently absent from the corpus —
|
||||||
|
verified by grep, 0 files each). Pure docs, no scanner/test impact:
|
||||||
|
1. `knowledge/prompt-cache-patterns.md` — `disableBundledSkills` as a token-efficiency lever
|
||||||
|
(hides bundled-skill descriptions from the system prompt). (matrix row 167)
|
||||||
|
2. `knowledge/claude-code-capabilities.md` *or* `prompt-cache-patterns.md` — the CC 40.0k-char
|
||||||
|
"Large CLAUDE.md will impact performance" startup warning + 2.1.169 context-window scaling
|
||||||
|
(backs the new CML finding).
|
||||||
|
3. `knowledge/claude-code-capabilities.md` — `Tool(param:value)` permission semantics: param
|
||||||
|
matching is deny/ask-only, and the canonicalizing-field rules CC ignores (backs DIS
|
||||||
|
forbidden-param). (matrix rows 137, 139)
|
||||||
|
|
||||||
|
Everything else in the matrix (env-var rows, model-lineup nuance, hook-output fields, plugin/skill
|
||||||
|
doc rows) → **defer** to rolling knowledge maintenance; not required for 5.3.0 correctness.
|
||||||
258
docs/v5.4.0-release-plan.md
Normal file
258
docs/v5.4.0-release-plan.md
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
# v5.4.0 Release — Multi-Session Plan
|
||||||
|
|
||||||
|
_Status: PLANNED. No version bump yet. `plugin.json` = 5.3.0. All work below is gated on
|
||||||
|
explicit operator GO per session. Plans live next to STATE per the continuity system.
|
||||||
|
Mirrors the structure of `docs/v5.3.0-release-plan.md`._
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Release **v5.4.0** (minor, backward-compatible): three **net-new additive scanner findings**
|
||||||
|
that extend the plugin-hygiene (PLH) and settings (SET) surfaces, mirroring real Claude Code
|
||||||
|
warnings/validation that config-audit does not yet detect. Unlike v5.3.0 (which documented work
|
||||||
|
already on `main`), **the features here do not exist yet** — Session B is the core build, not a
|
||||||
|
conditional. No bugs/false-positives are involved; all three are enhancements with confirmed CC
|
||||||
|
premises.
|
||||||
|
|
||||||
|
**Theme:** *plugin-hygiene & settings-validation hardening.*
|
||||||
|
|
||||||
|
## Pre-verified facts (do NOT re-derive)
|
||||||
|
|
||||||
|
- Last release: **v5.3.0** = commit `fe686b6`, tag `v5.3.0`, 2026-06-19. `git rev-list --count
|
||||||
|
v5.3.0..HEAD` = **0** → working tree is at the tag; v5.4.0 builds net-new code.
|
||||||
|
- Suite at HEAD: **936/936** (clean tree, unchanged since the v5.3.0 release). self-audit at
|
||||||
|
v5.3.0: configGrade A 97, pluginGrade A 100, readmeCheck.passed.
|
||||||
|
- **Scanner count = 13** (authoritative source `countScannerShape`, `scanners/self-audit.mjs:56`;
|
||||||
|
README badge `scanners-13`). All three approved features **extend existing scanners** (PLH, SET)
|
||||||
|
— **no new scanner file**, so the badge, the scanner table, and `countScannerShape` need **no
|
||||||
|
change**. (An early grep of `export async function scan` counted 15 shapes — that set includes
|
||||||
|
lib/non-badge shapes like CNF/IMP/SAL and is NOT the badge count. Do not bump the badge.)
|
||||||
|
- PLH currently emits `CA-PLH-001`..`CA-PLH-014` (014 = namespace collision, 013 = command-name
|
||||||
|
ambiguity, both shipped v5.3.0). Next free: `CA-PLH-015`, `CA-PLH-016`.
|
||||||
|
- Edit-target files (v5.3.0 release pattern): `.claude-plugin/plugin.json`, `README.md`
|
||||||
|
(badges + What's New + version-history row + TOC), `CHANGELOG.md` (`## [5.4.0]` above `[5.3.0]`),
|
||||||
|
`docs/cc-2.1.x-gap-matrix.md`, the two target scanners, their tests, and 3 knowledge entries.
|
||||||
|
- **CC premises primary-source verified** (code.claude.com/docs, 2026-06-19) — see Session A
|
||||||
|
reconciliation. The matrix is NOT ground truth; row 175 was found to be framed backwards.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session A — Reconciliation & scope decision (read/plan; this session) ✅ DONE
|
||||||
|
|
||||||
|
**No code changes.** Re-reconcile the gap-matrix against HEAD, verify each candidate is still open
|
||||||
|
AND that its CC premise is real, classify ship/defer/wontfix, get operator GO on the ship-list,
|
||||||
|
draft the changelog. Output recorded below + a `## v5.4.0 reconciliation` block appended to
|
||||||
|
`docs/cc-2.1.x-gap-matrix.md`.
|
||||||
|
|
||||||
|
**Verifisering (testbar):**
|
||||||
|
- Every v5.4 candidate has a recorded code-state verdict (IMPLEMENTED/OPEN/PARTIAL) with file:line.
|
||||||
|
- Every candidate has a CC-premise verdict (CONFIRMED/REFUTED/UNVERIFIABLE) with a source.
|
||||||
|
- Ship-list has a recorded operator GO before any build. ✅
|
||||||
|
- The refuted premise (#3) is corrected in the gap-matrix. ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session B — Implement the 3 approved features (CORE build)
|
||||||
|
|
||||||
|
**One feature per chunk. Iron Law: failing test FIRST (/tdd), then minimal implementation.**
|
||||||
|
Re-confirm each feature's exact CC behavior against primary docs at the start of its chunk
|
||||||
|
(Verifiseringsplikt — premises were verified in Session A but the exact field lists / warning text
|
||||||
|
must be pinned before coding). Checkpoint STATE + this plan after each feature.
|
||||||
|
|
||||||
|
### Feature 1 — PLH shadow-folder (`CA-PLH-015`) ✅ DONE (commit `7abc5a1`)
|
||||||
|
- **Verifiseringsplikt correction (2026-06-19):** the field set was pinned against
|
||||||
|
`code.claude.com/docs/.../path-behavior-rules`. Only the **replaces** category truly shadows:
|
||||||
|
shipped set = `commands`/`agents`/`outputStyles`. **`skills` excluded** — it *adds to* the
|
||||||
|
default `skills/` scan (both load, never a shadow); **`hooks`/`mcpServers`/`lspServers`
|
||||||
|
excluded** — own merge rules, not a folder-shadow. Experimental `themes`/`monitors` omitted
|
||||||
|
(docs warn their schema may change). Explicit-address exception honored
|
||||||
|
(`"commands": ["./commands/x.md"]` not flagged). +5 tests (936→941), suite green, self-audit
|
||||||
|
A/A, count 13, gitleaks clean. Pushed.
|
||||||
|
- **What:** flag when a `plugin.json` component-path key (`commands`/`agents`/`outputStyles`)
|
||||||
|
points at a **custom path** while the **default folder of that name also exists** in the plugin,
|
||||||
|
so the default is silently ignored. Mirrors CC's warning in `/doctor` & `claude plugin list`
|
||||||
|
(~2.1.140). _(Original plan listed `commands/agents/skills/hooks` — corrected above.)_
|
||||||
|
- **Where:** `scanners/plugin-health-scanner.mjs` (per-plugin loop; the scanner already reads
|
||||||
|
`plugin.json` but only checks `name`/`description`/`version`). Add component-path-key parsing.
|
||||||
|
- **Severity:** MEDIUM — silently-shadowed components = dead config (a whole command/skill/agent
|
||||||
|
set goes unreachable). `category: 'plugin-hygiene'`.
|
||||||
|
- **Tests:** fixture plugin with both `"commands": "custom/"` AND a `commands/` folder → flagged;
|
||||||
|
fixture with only one of the two → not flagged; fixture with no component keys → not flagged.
|
||||||
|
|
||||||
|
### Feature 2 — PLH `skills:`-array validation (`CA-PLH-016`) ✅ DONE (commit `76d5eda`)
|
||||||
|
- **Verifiseringsplikt correction (2026-06-19):** the plan's claim that the CC error "suggests the
|
||||||
|
parent directory when an entry points at a file" is **not** in the primary docs — dropped. The
|
||||||
|
finding asserts only the four primary-source-verified conditions. Escape backed by docs'
|
||||||
|
path-traversal rule (*"Installed plugins cannot reference files outside their directory … such as
|
||||||
|
`../shared-utils`"*). string|array normalized, so a non-string top-level value (e.g. `42`) is
|
||||||
|
caught as one non-string entry. +3 tests (941→944), suite green, self-audit A/A, count 13.
|
||||||
|
- **What:** when `plugin.json` has a `skills:` field (string|array), validate each entry resolves to
|
||||||
|
an existing **directory inside the plugin root**. One finding per bad entry, `problem` ∈
|
||||||
|
{`non-string`, `escapes-root`, `not-found`, `not-a-directory`}. Mirrors `claude plugin validate`.
|
||||||
|
- **Where:** `scanners/plugin-health-scanner.mjs` (read `parsed.skills` after the parse-success
|
||||||
|
guard).
|
||||||
|
- **Severity:** MEDIUM (broken/partly-broken plugin manifest). `category: 'plugin-hygiene'`.
|
||||||
|
- **Tests:** fixture `skills: ["valid-dir", "points-to-file.md", "missing", "../escape"]` → one
|
||||||
|
finding per bad entry, none for `valid-dir`; fixture with no `skills:` key → not flagged.
|
||||||
|
|
||||||
|
### Feature 3 — SET autoMode structure + dead-config (`CA-SET-NNN`) ✅ DONE (commit `3633571`)
|
||||||
|
- **Premise CONFIRMED against primary source** (`code.claude.com/docs/en/auto-mode-config`) — the
|
||||||
|
plan was correct this time (unlike #1/#2). 4 sub-keys verbatim; `"$defaults"` valid; exact scope
|
||||||
|
exclusion quote confirmed. **Per-file-scope gate PASSED:** `ConfigFile` already carries `scope`
|
||||||
|
(`classifyScope` → `'project'` for shared `.claude/settings.json`), so the dead-config sub-check
|
||||||
|
shipped (no fallback to structure-only needed). SET is in the orchestrator → **SC-5 re-checked,
|
||||||
|
byte-equal** (snapshot fixture has no autoMode). +5 tests (944→949), suite green, self-audit A/A,
|
||||||
|
count 13, gitleaks clean. Fixtures force-added (`.claude/` gitignored).
|
||||||
|
- **What (two sub-checks):**
|
||||||
|
1. **Structure validation** — `autoMode`, if present, must be an object whose only keys are
|
||||||
|
`environment`, `allow`, `soft_deny`, `hard_deny`, each a **string array** (entries are prose
|
||||||
|
rules; `"$defaults"` is a valid literal). Flag unknown sub-keys and wrong value types.
|
||||||
|
2. **Dead-config** — `autoMode` placed in **shared project settings** (`.claude/settings.json`)
|
||||||
|
is **ignored by CC** ("Not read from shared project settings" — official docs). Flag it as
|
||||||
|
dead config; valid scopes are user (`~/.claude/settings.json`), local
|
||||||
|
(`.claude/settings.local.json`), and managed.
|
||||||
|
- **Where:** `scanners/settings-validator.mjs` (`autoMode` is already in `KNOWN_KEYS:21` but has no
|
||||||
|
TYPE_CHECKS / nested validation). The dead-config sub-check needs the settings file's **scope**;
|
||||||
|
confirm the scanner already knows per-file scope (it validates user/project/local files).
|
||||||
|
- **Severity:** structure = LOW/MEDIUM (malformed config); dead-config = LOW (dead config).
|
||||||
|
- **Tests:** valid autoMode in user settings → clean; unknown sub-key / non-array value → flagged;
|
||||||
|
autoMode in shared `.claude/settings.json` → dead-config finding.
|
||||||
|
|
||||||
|
**Per-feature gates (all must pass before that feature's commit):**
|
||||||
|
- New tests RED→GREEN; full suite N/N green.
|
||||||
|
- `node scanners/self-audit.mjs --json --check-readme` → configGrade ≥ A, pluginGrade ≥ A,
|
||||||
|
readmeCheck.passed, scanner count still 13.
|
||||||
|
- Any snapshot/fixture touch → SC-5 re-seed + contamination-grep clean; gitleaks clean.
|
||||||
|
- **docs-gate** (`feat:` commits): README **and** CLAUDE.md each get ≥3 substantive lines
|
||||||
|
documenting the new finding — OR `[skip-docs]`. Prefer real docs (each finding deserves a
|
||||||
|
CLAUDE.md "Scanner" note + README mention). Stage docs in a separate Bash call before commit.
|
||||||
|
- Humanizer: each new finding needs a `userImpactCategory` mapping (dead config / conflict /
|
||||||
|
configuration mistake) — verify `--json`/`--raw` stay byte-stable for *existing* findings
|
||||||
|
(`json-backcompat` + `raw-backcompat` green); new findings are additive.
|
||||||
|
|
||||||
|
**Scanner count stays 13.** No badge/table/`countScannerShape` change (no new scanner file).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session C — Release v5.4.0 (version sync + docs + tag + push)
|
||||||
|
|
||||||
|
1. **Bump** `.claude-plugin/plugin.json` 5.3.0 → 5.4.0. (Version lives ONLY here.)
|
||||||
|
2. **README:** `version-` badge → 5.4.0; `tests-` badge → exact suite count after Session B
|
||||||
|
(will rise — new tests added). Replace "What's New in v5.3.0" → **v5.4.0** (+ TOC anchor).
|
||||||
|
Insert a new version-history row above `**5.3.0**`. **Scanner count stays 13.**
|
||||||
|
3. **CHANGELOG.md:** insert `## [5.4.0] - <date>` above `## [5.3.0]` (Added / Test count /
|
||||||
|
Verification — mirror the 5.3.0 block; this release is Added-only + Internal if any).
|
||||||
|
4. **Knowledge:** apply the 3 backing entries (list below).
|
||||||
|
5. **Marketplace cross-repo (`../catalog/`, separate repo):** bump the config-audit `ref`/version
|
||||||
|
in `marketplace.json` + README **after** the plugin tag is pushed. Same push-window rules,
|
||||||
|
separate push. **Cross-repo edit requires operator GO.**
|
||||||
|
6. **Gates:** suite N/N; self-audit configGrade A, pluginGrade A, readmeCheck.passed, count 13;
|
||||||
|
SC-5 byte-equal or re-seeded + contamination-grep clean; gitleaks clean (both repos).
|
||||||
|
7. **Commit** `release: v5.4.0 — <one-line>`, **tag** `v5.4.0`, **push** (in push window).
|
||||||
|
|
||||||
|
**Verifisering (testbar):** `git tag --list v5.4.0` returns it; README `version-` badge ==
|
||||||
|
`plugin.json` version; `self-audit --check-readme` passed + badge tests == suite count;
|
||||||
|
`git log -1` subject starts `release: v5.4.0`; marketplace entry shows 5.4.0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key assumptions (test, don't trust)
|
||||||
|
|
||||||
|
- **5.4.0 is non-breaking.** Three additive findings; no envelope/JSON-shape change. Verify
|
||||||
|
`json-backcompat` + `raw-backcompat` + SC-5 snapshot stay green; new findings appear only in
|
||||||
|
configs that trigger them. If anything changes the JSON shape or existing-finding output → it is
|
||||||
|
a Breaking note, not Added.
|
||||||
|
- **settings-validator knows per-file scope.** Feature 3's dead-config sub-check needs to know a
|
||||||
|
settings file is the *shared project* one. **Test this assumption first in Feature 3's chunk** —
|
||||||
|
if the scanner doesn't already carry scope per file, either thread it through or drop the
|
||||||
|
dead-config sub-check to structure-only (still ships).
|
||||||
|
- **docs-gate blocks `feat:` commits** lacking ≥3 substantive lines in BOTH README and CLAUDE —
|
||||||
|
proven 2026-06-19. (`release:`/`fix:`/`chore:`/`docs:` pass freely.)
|
||||||
|
- **self-audit `--check-readme` is number-only** (badge vs filesystem) — it does NOT catch prose
|
||||||
|
gaps. Manually eyeball the scanner table + "What's New" (the SKL-row gap slipped exactly here).
|
||||||
|
|
||||||
|
## Knowledge-touch list (Session C step 4)
|
||||||
|
|
||||||
|
Three short backing entries (mirrors v5.3.0's 3-entry pattern; grep-verify each is absent first):
|
||||||
|
1. `knowledge/claude-code-capabilities.md` — plugin component-path keys shadow default folders;
|
||||||
|
CC warns in `/doctor` & `claude plugin list` (~2.1.140/142). Backs `CA-PLH-015`.
|
||||||
|
2. `knowledge/claude-code-capabilities.md` — `claude plugin validate` requires `skills:` entries to
|
||||||
|
be directories (~2.1.145). Backs `CA-PLH-016`.
|
||||||
|
3. `knowledge/claude-code-capabilities.md` *or* `configuration-best-practices.md` — `autoMode`
|
||||||
|
schema (`environment`/`allow`/`soft_deny`/`hard_deny` string arrays, `"$defaults"` literal) and
|
||||||
|
the "not read from shared project settings" rule. Backs `CA-SET` autoMode finding.
|
||||||
|
|
||||||
|
Everything else in the matrix (env-var rows, model-lineup nuance, hook-output fields, nested
|
||||||
|
.claude doc) → **defer** to rolling knowledge maintenance.
|
||||||
|
|
||||||
|
## Out of scope (do not start without separate GO)
|
||||||
|
|
||||||
|
- **#2 acceptEdits-on-shell/build-config** (deferred — needs the exact CC special-cased file-category
|
||||||
|
list primary-source-confirmed before it's buildable). Candidate for a later release.
|
||||||
|
- **#6 nested-.claude closest-wins** (deferred — a NEW scanner: ancestor-chain walker + grouping +
|
||||||
|
badge bump 13→14. Heaviest item; natural headline for its own release, e.g. v5.5.0).
|
||||||
|
- **#3 Read-deny/Glob-Grep** — **wontfix** (premise refuted by primary source; see reconciliation).
|
||||||
|
- Any scanner refactor not required by the 3 approved features; marketplace-wide changes beyond the
|
||||||
|
config-audit catalog entry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v5.4.0 scope decision (Session A output, 2026-06-19)
|
||||||
|
|
||||||
|
### Operator GO (recorded verbatim)
|
||||||
|
> **"Option A"** — ship #1 PLH shadow-folder + #5 PLH skills:-array + #4 autoMode structure/
|
||||||
|
> dead-config. No new scanner (badge stays 13). #2 and #6 deferred, #3 wontfix.
|
||||||
|
|
||||||
|
**Ship-list (3 features):** `CA-PLH-015` shadow-folder · `CA-PLH-016` skills:-array · `CA-SET-NNN`
|
||||||
|
autoMode structure + dead-config. All extend existing scanners.
|
||||||
|
|
||||||
|
### Reconciliation result (6 candidates vs HEAD, code-state + CC-premise)
|
||||||
|
|
||||||
|
| # | Candidate (matrix row) | Code-state @HEAD | CC premise | Verdict |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | PLH shadow-folder (164) | OPEN — `plugin-health-scanner` parses only name/desc/version | CONFIRMED (~2.1.140; `/doctor`+`plugin list` warning) | **ship-5.4** |
|
||||||
|
| 5 | PLH `skills:`-array dirs (165) | OPEN — does not read `parsed.skills` | CONFIRMED (~2.1.145 `claude plugin validate`) | **ship-5.4** |
|
||||||
|
| 4 | autoMode.hard_deny structure (179) | OPEN — key in `KNOWN_KEYS:21`, no TYPE_CHECKS/nested val | CONFIRMED (primary source: 4 string-array sub-keys; not read from shared project settings) | **ship-5.4** |
|
||||||
|
| 2 | acceptEdits-writes shell/build (176) | OPEN — 0 matches in `scanners/` | CONFIRMED (~2.1.160) but exact file-category list unpinned | **defer** (needs primary-source field list) |
|
||||||
|
| 6 | nested-.claude closest-wins (139/166) | OPEN — no scanner; CML walks only the CLAUDE.md cascade | CONFIRMED (~2.1.178) | **defer** (NEW scanner, badge bump) |
|
||||||
|
| 3 | Read-deny hides Glob/Grep (175) | PARTIAL — hint in `permission-rules.mjs:158-159`, but `dominates()` treats Read/Glob/Grep as distinct | **REFUTED** — docs: "Claude makes a best-effort attempt to apply `Read` rules to … Grep and Glob"; Read-deny already covers them | **wontfix** |
|
||||||
|
|
||||||
|
**Matrix correction (Verifiseringsplikt):** row 175's framing was **backwards**. CC's permissions
|
||||||
|
doc states Read deny rules already apply (best-effort) to Glob/Grep, so a "Read-deny is bypassable"
|
||||||
|
finding would be a false positive — the same failure mode as the invented MCP `trust` field. The
|
||||||
|
only real Read-deny bypass is via Bash subprocesses (a python/node script that opens files), which
|
||||||
|
is already documented behavior, not a config mistake to flag. The Windows-path half (CC normalizes
|
||||||
|
`C:\` → `/c/`) is real but narrow/low-value and is folded into "defer rolling maintenance".
|
||||||
|
|
||||||
|
### Draft CHANGELOG narrative (finalize after Session B)
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
- **PLH plugin-folder shadowing** (`CA-PLH-015`) — flags a `plugin.json` component-path key
|
||||||
|
(`commands`/`agents`/`skills`/`hooks`) that points at a custom path while the default folder of
|
||||||
|
that name also exists, so one silently shadows the other. Mirrors Claude Code's `/doctor` &
|
||||||
|
`claude plugin list` warning. Severity **medium** (dead config), `category: 'plugin-hygiene'`.
|
||||||
|
- **PLH `skills:`-array validation** (`CA-PLH-016`) — validates each entry in a `plugin.json`
|
||||||
|
`skills:` array resolves to a directory inside the plugin; flags non-string entries, missing
|
||||||
|
paths, file-not-directory, and path-escape. Mirrors `claude plugin validate`. Severity
|
||||||
|
**medium**, `category: 'plugin-hygiene'`.
|
||||||
|
- **SET autoMode structure + dead-config** (`CA-SET-NNN`) — validates `autoMode` contains only
|
||||||
|
the four known sub-keys (`environment`/`allow`/`soft_deny`/`hard_deny`), each a string array,
|
||||||
|
and flags `autoMode` placed in shared project settings (`.claude/settings.json`), which Claude
|
||||||
|
Code ignores. Structure = low/medium, dead-config = low.
|
||||||
|
|
||||||
|
**Internal / Test count / Verification** — fill in Session C (mirror the 5.3.0 block).
|
||||||
|
|
||||||
|
Scanner count stays **13** (all extend existing PLH/SET scanners). `--json`/`--raw` byte-stable.
|
||||||
|
|
||||||
|
### What's New in v5.4.0 (draft prose)
|
||||||
|
> **v5.4.0 — plugin-hygiene & settings-validation hardening.** Three additive findings extend the
|
||||||
|
> plugin and settings surfaces: PLH now flags a plugin.json component-path key that silently shadows
|
||||||
|
> the default folder of the same name (mirroring Claude Code's `/doctor` warning), and validates
|
||||||
|
> that `skills:`-array entries resolve to real directories (mirroring `claude plugin validate`); the
|
||||||
|
> settings validator now checks the structure of `autoMode` and flags it when placed in shared
|
||||||
|
> project settings, where Claude Code ignores it. Scanner count stays **13** (all extend existing
|
||||||
|
> scanners). `--json`/`--raw` remain byte-stable.
|
||||||
|
|
||||||
|
### Version & non-breaking confirmation
|
||||||
|
- **5.4.0** (minor). Additive findings only; no envelope/JSON-shape change. `json-backcompat` +
|
||||||
|
`raw-backcompat` + SC-5 snapshot green at HEAD; new findings are additive (appear only in configs
|
||||||
|
that trigger them).
|
||||||
245
docs/v5.5-steering-model-plan.md
Normal file
245
docs/v5.5-steering-model-plan.md
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
# v5.5+ Plan — Steering-Model Coverage
|
||||||
|
|
||||||
|
_Status: PLAN (awaiting GO per feature). Created 2026-06-20. Plans live next to the work (continuity rule)._
|
||||||
|
|
||||||
|
## Why this plan exists
|
||||||
|
|
||||||
|
A "seven ways to steer Claude Code" framing (CLAUDE.md / rules / skills / sub-agents /
|
||||||
|
hooks / output styles / mechanism-fit) was used as a **check-against** reference — not a
|
||||||
|
spec. Checking it against the live docs (`code.claude.com/docs`, map updated 2026-06-19)
|
||||||
|
both corrected the video **and** surfaced gaps in config-audit's own coverage.
|
||||||
|
|
||||||
|
The unifying insight: every steering mechanism has a different **loading model** and
|
||||||
|
**compaction-survival** profile, and the docs now publish both explicitly. config-audit
|
||||||
|
already audits *structure* well; it does **not** yet model *when a thing is loaded* or
|
||||||
|
*whether it survives compaction* — which is exactly the "wrong mechanism = wasted tokens /
|
||||||
|
unloaded instruction" axis. This plan closes that.
|
||||||
|
|
||||||
|
The small correctness items the check surfaced (HKV hook-events, RUL globs wording) are
|
||||||
|
**already landed** on `main` (`b6a62d7`) and bound for the pending **v5.4.1** patch. This
|
||||||
|
document covers only the **new functionality** (v5.5+), greenlit by the operator: A, B, C,
|
||||||
|
D, E.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification log (Verifiseringsplikt)
|
||||||
|
|
||||||
|
Every claim this plan builds on, with source and status. `CONFIRMED` = stated in primary
|
||||||
|
docs; `REFUTED` = docs contradict; `UNVERIFIED` = not found in primary docs (do not assert).
|
||||||
|
|
||||||
|
| # | Claim | Status | Source |
|
||||||
|
|---|-------|--------|--------|
|
||||||
|
| V1 | Project-root CLAUDE.md + unscoped rules are **re-injected from disk after compaction** | CONFIRMED | `context-window.md#what-survives-compaction` |
|
||||||
|
| V2 | Rules with `paths:` frontmatter are **lost after compaction** until a matching file is read again | CONFIRMED | `context-window.md#what-survives-compaction` |
|
||||||
|
| V3 | Nested (subdir) CLAUDE.md is **lost after compaction** until a file in that dir is read again | CONFIRMED | `context-window.md`, `memory.md` |
|
||||||
|
| V4 | Path-scoped rules trigger on **Read** of a matching file (not every tool use) | CONFIRMED | `memory.md#path-specific-rules` |
|
||||||
|
| V5 | The only documented rule-scoping frontmatter field is **`paths`** (not `globs`) | CONFIRMED | `memory.md#path-specific-rules` |
|
||||||
|
| V6 | `.claude/rules/` is an **official** feature; all `.md` auto-discovered recursively; unscoped = always-on | CONFIRMED | `memory.md#organize-rules-with-claude/rules/` |
|
||||||
|
| V7 | Skill **name+description load every turn**; body loads on invoke | CONFIRMED | `skills.md`, `features-overview.md` |
|
||||||
|
| V8 | Skill listing description cap = **1,536 chars** default of `maxSkillDescriptionChars` (configurable, v2.1.105+) | CONFIRMED | `skills.md`, `settings.md` |
|
||||||
|
| V9 | Output styles **still exist** (not deprecated); only the standalone `/output-style` command was removed (v2.1.91) → use `/config` | CONFIRMED | `output-styles.md`, changelog |
|
||||||
|
| V10 | Output styles **modify the system prompt** (add to end); a **custom** style without `keep-coding-instructions: true` **removes built-in software-engineering instructions** | CONFIRMED | `output-styles.md` |
|
||||||
|
| V11 | `force-for-plugin: true` auto-applies a plugin's style, **overriding the user's `outputStyle`** | CONFIRMED | `output-styles.md` |
|
||||||
|
| V12 | CLAUDE.md is "**a user message after the system prompt**", whereas output styles are a system-prompt mechanism | CONFIRMED | `output-styles.md` (comparison table) |
|
||||||
|
| V13 | Output styles are "the most expensive" steering mechanism | **UNVERIFIED** — docs say cost rises but prompt-cache mitigates; no ranking | `output-styles.md` |
|
||||||
|
| V14 | Sub-agent runs in isolated context; only a summary returns | CONFIRMED | `sub-agents.md` |
|
||||||
|
| V15 | For **plugin** subagents, `hooks` / `mcpServers` / `permissionMode` frontmatter are **silently ignored** | CONFIRMED | `sub-agents.md` |
|
||||||
|
| V16 | Agent `skills:` frontmatter **preloads full skill content at startup** | CONFIRMED | `sub-agents.md` |
|
||||||
|
| V17 | Required agent frontmatter = **only `name` + `description`** | CONFIRMED | `sub-agents.md` |
|
||||||
|
| V18 | Hooks: **~30 events** (not "five"); hook scripts run outside context, but injected `additionalContext` IS saved to transcript (subject to compaction) | CONFIRMED | `hooks.md` |
|
||||||
|
| V19 | An official **mechanism-fit comparison table** exists (output style vs CLAUDE.md vs `--append-system-prompt` vs agents vs skills) | CONFIRMED | `output-styles.md` |
|
||||||
|
| U1 | hook event `post-session` (kebab) | **RESOLVED → REFUTED** (2026-06-20). The 2.1.169 changelog `post-session` is a **self-hosted-runner** workspace-lifecycle hook, NOT a settings.json hook event; absent from `hooks.md` (all 30 events PascalCase). **Removed** from HKV `VALID_EVENTS` in v5.4.1. | `hooks.md`, changelog 2.1.169 |
|
||||||
|
| U2 | A plugin.json `outputStyles` path-override key (PLH `SHADOWING_PATH_FIELDS`) | **RESOLVED → CONFIRMED** (2026-06-20). `outputStyles` (camelCase) is a documented plugin.json key in the **replaces** category (default `output-styles/` ignored when set). PLH is correct — no change. See `[[plugin-json-path-behavior]]`. | `plugins-reference.md` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The five features
|
||||||
|
|
||||||
|
Design constraints throughout: deterministic where possible; additive findings that do
|
||||||
|
**not** fire on the SC-5 snapshot fixture stay byte-stable (the v5.3/v5.4 pattern); every
|
||||||
|
new finding gets a stable title (fix-engine + humanizer couple on title — see `b6a62d7`).
|
||||||
|
|
||||||
|
### Foundation (prerequisite for B/A/C/E): enumeration
|
||||||
|
|
||||||
|
`active-config-reader.mjs` today enumerates CLAUDE.md cascade, hooks, MCP, skills, plugins —
|
||||||
|
but **not** rules, agents, or output styles (confirmed by the coverage scan). B, and parts
|
||||||
|
of A/C/E, need these enumerated with a `loadPattern` tag. This is the first build step.
|
||||||
|
|
||||||
|
- Add enumeration for: `.claude/rules/*.md` (+ `~/.claude/rules/`), agents (`.claude/agents/`,
|
||||||
|
`~/.claude/agents/`, plugin agents), output styles (`.claude/output-styles/`,
|
||||||
|
`~/.claude/output-styles/`).
|
||||||
|
- Tag each source kind with `loadPattern ∈ { always, on-demand, external }` and
|
||||||
|
`survivesCompaction ∈ { yes, no, n/a }` derived from V1–V3, V6, V7, V18.
|
||||||
|
- **Test:** fixture with root+subdir CLAUDE.md, scoped+unscoped rule, project agent, output
|
||||||
|
style → assert each is enumerated with the correct `loadPattern`/`survivesCompaction`.
|
||||||
|
|
||||||
|
### A — Durability / compaction-survival findings
|
||||||
|
|
||||||
|
**Problem.** A must-always-hold instruction placed where it does **not** survive compaction
|
||||||
|
(nested CLAUDE.md per V3, path-scoped rule per V2) silently disappears mid-session after a
|
||||||
|
`/compact`. The docs publish exactly which mechanisms survive (V1–V3) — so this is
|
||||||
|
doc-grounded, not a guess.
|
||||||
|
|
||||||
|
**Shape (additive, no new scanner — count stays 13).**
|
||||||
|
- **RUL** new finding `CA-RUL-NNN` — a **large** path-scoped rule (e.g. > 50 lines, reuse the
|
||||||
|
existing unscoped-size heuristic) carries an informational note: its content is **lost after
|
||||||
|
compaction** until a matching file is re-read (V2/V4). Severity **low** (awareness, not a bug).
|
||||||
|
- **CML** new finding `CA-CML-NNN` — a **nested** (non-root) CLAUDE.md of meaningful size
|
||||||
|
notes it is **not re-injected after compaction** (V3). Severity **low**.
|
||||||
|
- Keep it strictly **structural** (size + location), never semantic ("is this instruction
|
||||||
|
critical?") — that stays deterministic.
|
||||||
|
|
||||||
|
**Key assumption + test.** Discovery distinguishes root vs nested CLAUDE.md and scoped vs
|
||||||
|
unscoped rules. → Test with a fixture asserting the finding fires for nested/scoped+large and
|
||||||
|
**not** for root/unscoped.
|
||||||
|
|
||||||
|
**Byte-stability.** Additive; snapshot fixture has no nested CLAUDE.md / large scoped rule →
|
||||||
|
SC-5 byte-stable (re-verify).
|
||||||
|
|
||||||
|
### B — Load-pattern accounting in `tokens` + `manifest`
|
||||||
|
|
||||||
|
**Problem.** `manifest.mjs` and TOK rank all sources uniformly by `estimated_tokens`. A skill
|
||||||
|
listing entry (paid **every turn**, V7) ranks identically to a skill body (paid **on invoke**).
|
||||||
|
The docs give a precise per-mechanism loading + survival model (V1–V3, V7, V18) that we can
|
||||||
|
surface. This is the **core** of the "wrong mechanism = wasted tokens" thesis.
|
||||||
|
|
||||||
|
**Shape.**
|
||||||
|
- Add a `loadPattern` (and `survivesCompaction`) column to `manifest` output and to TOK
|
||||||
|
source records, derived from the foundation enumeration.
|
||||||
|
- Add an **always-loaded subtotal** to the manifest summary: "≈X tokens enter context every
|
||||||
|
turn before you type" (project-root CLAUDE.md + unscoped rules + skill/agent listing +
|
||||||
|
output style + MCP schemas), vs an on-demand subtotal.
|
||||||
|
- Optional follow-up: cost-benefit hint (a large `always` source dwarfing the on-demand pool).
|
||||||
|
|
||||||
|
**Key assumption + test.** Source-kind → `loadPattern` is a deterministic mapping. → Test:
|
||||||
|
manifest on a mixed fixture yields the correct always-subtotal; `--json` includes the new field.
|
||||||
|
|
||||||
|
**Byte-stability — RISK.** B changes the **manifest/tokens output format** → **not**
|
||||||
|
byte-stable. Requires snapshot regen (SC-5) and a decision on `--json` back-compat (add field
|
||||||
|
vs version the schema). This is the highest-format-risk item; sequence it deliberately.
|
||||||
|
|
||||||
|
### C — Output-style scanner (new `CA-OST`, count 13 → 14)
|
||||||
|
|
||||||
|
**Problem.** Output styles are live (V9) and the **most surprising** surface: a custom style
|
||||||
|
silently strips built-in software-engineering instructions (V10), and `force-for-plugin`
|
||||||
|
overrides the user's choice (V11). config-audit does not scan them at all today (GAP/SET/PLH
|
||||||
|
only touch adoption / a settings key / plugin folder-shadow).
|
||||||
|
|
||||||
|
**Shape (new scanner — output styles are a genuinely new file surface, so a new scanner is
|
||||||
|
warranted, unlike the additive v5.3/v5.4 work).**
|
||||||
|
- `CA-OST-001` — **custom** output style missing `keep-coding-instructions: true` → flags that
|
||||||
|
built-in SWE instructions are removed (V10). Severity **medium** (silently changes coding
|
||||||
|
behavior). The headline footgun.
|
||||||
|
- `CA-OST-002` — `force-for-plugin: true` in a project/user style → it overrides the user's
|
||||||
|
`outputStyle` (V11). Severity **low**.
|
||||||
|
- `CA-OST-003` — settings `outputStyle` value resolving to a non-existent style → dead config.
|
||||||
|
Severity **low/medium**. (Could live in SET instead; decide during build.)
|
||||||
|
- Active output-style token cost → feed B's `always` accounting (V10: system-prompt, every turn).
|
||||||
|
- **Not** asserting V13 ("most expensive") anywhere — unverified.
|
||||||
|
|
||||||
|
**Key assumption + test.** Discovery can find `.claude/output-styles/*.md` and
|
||||||
|
`~/.claude/output-styles/*.md` (new discovery type). → Test: fixture with a custom style
|
||||||
|
missing the flag fires `CA-OST-001`; a built-in style reference does not.
|
||||||
|
|
||||||
|
**Byte-stability.** New scanner; fixture-gated → SC-5 byte-stable if it does not fire on the
|
||||||
|
snapshot project (re-verify). Self-audit scanner-count badge moves 13 → 14 (update README +
|
||||||
|
CLAUDE.md inventory + the "count stays 13" lore).
|
||||||
|
|
||||||
|
### D — Mechanism-fit detector (heuristic — lowest precision, sequence last)
|
||||||
|
|
||||||
|
**Problem.** The docs publish a mechanism-fit table (V19) and the architectural distinction
|
||||||
|
that CLAUDE.md is a per-turn user message while output styles are system-prompt (V12). Common
|
||||||
|
mismatch: an "every time / before each" instruction in CLAUDE.md that should be a **hook**; a
|
||||||
|
path-specific instruction in root CLAUDE.md that should be a **path-scoped rule** (V4/V6).
|
||||||
|
|
||||||
|
**Shape (heuristic; additive to CML or a small new check).**
|
||||||
|
- `CA-???-001` — imperative lifecycle phrasing in CLAUDE.md (`every time`, `before each`,
|
||||||
|
`always run`, `after you …`, `whenever you …`) describing a tool/lifecycle action → suggest
|
||||||
|
a hook. Framed as **Missed opportunity**, severity **low**, never "Fix this now".
|
||||||
|
- `CA-???-002` — path-specific phrasing in **root** CLAUDE.md (`in src/**`, `for *.ts files`)
|
||||||
|
→ suggest a path-scoped rule.
|
||||||
|
|
||||||
|
**Key assumption + test — THE RISK.** Phrase heuristics must have low false-positive rate. →
|
||||||
|
Test with a positive corpus (clear mismatches → flagged) **and** a negative corpus (normal
|
||||||
|
project conventions → silent). Must be suppressible (`.config-audit-ignore`). If precision is
|
||||||
|
poor in testing, **ship behind a flag or defer** — do not ship a noisy heuristic.
|
||||||
|
|
||||||
|
**Byte-stability.** Additive; risk it fires on the snapshot's CLAUDE.md → check carefully,
|
||||||
|
may need snapshot regen.
|
||||||
|
|
||||||
|
### E — Agent-listing cost + plugin-agent dead-config (additive to PLH; feeds B)
|
||||||
|
|
||||||
|
**Problem.** Agents' name+description load for delegation (every turn), like skills — but
|
||||||
|
unlike skills they're unmetered. And `hooks`/`mcpServers`/`permissionMode` in a **plugin**
|
||||||
|
agent's frontmatter are silently ignored (V15) — dead config, and a false sense of
|
||||||
|
isolation/security if `permissionMode` is among them.
|
||||||
|
|
||||||
|
**Shape (additive to PLH).**
|
||||||
|
- `CA-PLH-NNN` — plugin agent declaring `hooks` / `mcpServers` / `permissionMode` → dead config
|
||||||
|
(V15). Severity **low** generally; **medium** for `permissionMode` (false security).
|
||||||
|
- Agent description cost → feed B's `always` accounting. **No hard cap claimed** — research
|
||||||
|
found no documented agent-description limit (unlike skills' 1,536, V8). Treat as cost signal,
|
||||||
|
not a limit.
|
||||||
|
- `skills:` preload in agent frontmatter → informational note: full skill content injected at
|
||||||
|
startup (V16).
|
||||||
|
|
||||||
|
**Key assumption + test.** PLH can tell a **plugin** agent from a user/project agent (V15
|
||||||
|
only applies to plugin subagents). → Test: plugin-agent fixture with `permissionMode` fires
|
||||||
|
medium dead-config; identical user-level agent does **not**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & phased rollout
|
||||||
|
|
||||||
|
```
|
||||||
|
Foundation (enumeration: rules, agents, output styles + loadPattern/survivesCompaction)
|
||||||
|
├── A (durability) additive RUL+CML doc-grounded, low risk
|
||||||
|
├── E (plugin-agent dead) additive PLH doc-grounded, low risk
|
||||||
|
├── C (output-style) new CA-OST (13→14) doc-grounded, new surface
|
||||||
|
└── B (load-pattern) manifest/tokens format doc-grounded, FORMAT risk
|
||||||
|
D (mechanism-fit) heuristic independent, precision risk
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommended phasing** (chunk-work rule — session-sized, checkpoint STATE between):
|
||||||
|
|
||||||
|
- **v5.5.0 "steering-model I"** — A + E only. **DONE on main** (E `f75ed56`, A `f3aadb5`,
|
||||||
|
2026-06-20). Additive to RUL/CML/PLH, doc-grounded, byte-stable, count stays 13. **Foundation
|
||||||
|
was dropped from v5.5.0**: on inspection A/E are additive to scanners that already read the
|
||||||
|
files and do NOT consume the `active-config-reader` enumeration — that serves B, so it moves to
|
||||||
|
v5.6. (Release-cut of v5.5.0 is a separate GO step.)
|
||||||
|
- **v5.6.0 "steering-model II"** — **Foundation** (active-config-reader enumeration +
|
||||||
|
`loadPattern`/`survivesCompaction`) + B (load-pattern accounting, manifest format change +
|
||||||
|
snapshot regen + `--json` back-compat decision) + C (new `CA-OST`, count → 14). Also fix the
|
||||||
|
frontmatter-parser block-sequence limitation (inline `paths:` only today) as part of Foundation.
|
||||||
|
- **v5.7.0 (optional)** — D, only if the negative-corpus test shows acceptable precision;
|
||||||
|
otherwise behind a flag or dropped.
|
||||||
|
|
||||||
|
(Operator may prefer a single larger v5.5 — but B's format change and D's precision risk argue
|
||||||
|
for separating them from the low-risk additive batch.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance criteria (whole programme)
|
||||||
|
|
||||||
|
- [ ] Every new finding's primary claim traces to a `CONFIRMED` row above (no `UNVERIFIED`
|
||||||
|
assertions in user-facing text — V13 and U1/U2 must be resolved or omitted).
|
||||||
|
- [ ] `node --test 'tests/**/*.test.mjs'` green; README test badge == suite count
|
||||||
|
(`self-audit --check-readme` `passed: true`).
|
||||||
|
- [ ] `self-audit` stays **A / A**, no critical/high on the plugin itself.
|
||||||
|
- [ ] Each new finding has: a positive fixture (fires) **and** a negative fixture (silent).
|
||||||
|
- [ ] fix-engine + humanizer-data entries added/updated for any new finding title (title
|
||||||
|
coupling — see `b6a62d7`).
|
||||||
|
- [ ] SC-5 snapshot: byte-stable for additive findings (A/E); regenerated + reviewed for B
|
||||||
|
(and D if it touches the snapshot project).
|
||||||
|
- [ ] Scanner-count lore updated everywhere if C lands (README badge, CLAUDE.md inventory,
|
||||||
|
`docs/scanner-internals.md`, the "count stays 13" notes).
|
||||||
|
- [x] U1 (`post-session`) and U2 (plugin `outputStyles` key) resolved (2026-06-20): U1 refuted
|
||||||
|
→ removed from HKV in v5.4.1; U2 confirmed → PLH unchanged.
|
||||||
|
|
||||||
|
## Open decisions for the operator
|
||||||
|
|
||||||
|
1. **Phasing**: three releases (recommended) vs one v5.5 with everything?
|
||||||
|
2. **C placement**: new `CA-OST` scanner (count → 14) vs folding output-style checks into
|
||||||
|
SET+a file check (keeps 13)? (Recommend new scanner — distinct surface.)
|
||||||
|
3. **D**: build now (heuristic, precision-gated) vs defer until A/B/C/E prove the theme?
|
||||||
|
4. **B `--json`**: add `loadPattern` field in place (mild back-compat risk) vs schema version bump?
|
||||||
126
docs/v5.7-optimization-lens-plan.md
Normal file
126
docs/v5.7-optimization-lens-plan.md
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
# v5.7 — Optimization Lens + Living Knowledge Base (Plan)
|
||||||
|
|
||||||
|
> Outcome of the 2026-06-20 vision discussion. This is the **first concrete realization**
|
||||||
|
> of the operator's "F1-tuning" north star (see auto-memory `config-audit-vision`). The
|
||||||
|
> shift is from *"is the config **correct**?"* (today's health scanners) to *"is the config
|
||||||
|
> **optimal / best-practice-tuned**?"*.
|
||||||
|
>
|
||||||
|
> **Status: GO-ready design. Implementation is a SEPARATE GO, chunk by chunk.** The
|
||||||
|
> discussion session deliberately stopped before code (per STATE.md). This doc mirrors the
|
||||||
|
> verification-protocol format of `docs/v5.5-steering-model-plan.md`.
|
||||||
|
|
||||||
|
## The four building blocks (full vision)
|
||||||
|
|
||||||
|
The vision decomposes into four blocks. v5.7 builds **Fase 1** (blocks 1+2 lite). Blocks
|
||||||
|
3+4 are **Fase 2** (deferred, own GO).
|
||||||
|
|
||||||
|
| # | Block | Phase | One-liner |
|
||||||
|
|---|-------|-------|-----------|
|
||||||
|
| 1 | **Optimization lens** | **Fase 1** | New finding family: "you USE mechanism X, but Y fits this content better" (mechanism-fit). |
|
||||||
|
| 2 | **Living knowledge base** | **Fase 1** | Structured, provenance-stamped register the lens reads + semi-auto refresh. |
|
||||||
|
| 3 | Machine-wide campaign | Fase 2 | A durable ledger above sessions: audit N repos over many sessions, resumable, machine-wide roll-up. |
|
||||||
|
| 4 | Durable backlog + execution | Fase 2 | One cross-repo prioritized backlog (critical/high/med/low) the user picks from; per-repo plans exported to each repo's `docs/`. |
|
||||||
|
|
||||||
|
**Why Fase 1 first (operator-confirmed):** the value of a machine-wide campaign (Fase 2)
|
||||||
|
depends on the lens being good. Building the campaign shell on today's correctness-only
|
||||||
|
scanners would underdeliver the vision. So: establish + validate the lens on ONE repo,
|
||||||
|
then scale.
|
||||||
|
|
||||||
|
## Decisions locked (2026-06-20)
|
||||||
|
|
||||||
|
- **Sequence:** Fase 1 (lens + knowledge) first, then Fase 2 (campaign + backlog). _(operator)_
|
||||||
|
- **Knowledge format:** **structured register** (YAML/JSON) the lens reads directly;
|
||||||
|
markdown kept as a human-readable mirror. _(operator, over markdown-only)_
|
||||||
|
- **Lens engine:** **new finding family** with a **hybrid motor** — deterministic
|
||||||
|
pre-filter → opus analyzer that judges mechanism-fit and cites the register rule;
|
||||||
|
precision-gated. _(operator, over extend-feature-gap / pure-deterministic-scanner)_
|
||||||
|
|
||||||
|
## Fase 1 — two deliverables
|
||||||
|
|
||||||
|
### Leveranse A — Living knowledge register
|
||||||
|
|
||||||
|
Today `knowledge/*.md` (8 files) is prose with a `Source: … verified DATE` header, and the
|
||||||
|
v5.5 V-rows are an ad-hoc table. The foundation exists; what's missing is a
|
||||||
|
**machine-consumable** form with provenance per claim.
|
||||||
|
|
||||||
|
- **Format:** one register (e.g. `knowledge/best-practices.yaml`), one entry = one
|
||||||
|
best-practice rule, with fields:
|
||||||
|
`id / claim / mechanism / recommendation / source-url / verified-date / confidence
|
||||||
|
{confirmed|inferred|unverified} / lens-check (which detector consumes it)`.
|
||||||
|
- **Markdown stays** as the readable mirror; the v5.5 V-rows are **migrated into** the
|
||||||
|
register (formalizing the existing claim→source→CONFIRMED protocol).
|
||||||
|
- **"Living" =** `/config-audit knowledge-refresh` (semi-auto, **human-approved writes**):
|
||||||
|
polls CC changelog + Anthropic docs/blog, flags `stale` (older than N days / source
|
||||||
|
changed) and `candidate` (new practice found), presents for approval. Mirrors the
|
||||||
|
`architect` plugin's kb-update poll. **No unverified claim is ever auto-written**
|
||||||
|
(Verifiseringsplikt).
|
||||||
|
|
||||||
|
### Leveranse B — Optimization lens (new family, e.g. `CA-OPT`)
|
||||||
|
|
||||||
|
A new finding layer that reads CLAUDE.md / rules / skills / hooks and checks each against
|
||||||
|
the register's mechanism-fit rules. Content is already source-anchored from the Anthropic
|
||||||
|
"Steering Claude Code" blog (read 2026-06-20):
|
||||||
|
|
||||||
|
| Signal in config | Best-practice rule | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| Lifecycle phrasing ("after every commit, do X") in CLAUDE.md | → hook (deterministic) | blog |
|
||||||
|
| Path-specific instruction, unscoped | → path-scoped rule (`paths:`) | blog |
|
||||||
|
| 30-line procedure in CLAUDE.md | → skill | blog |
|
||||||
|
| "Never do X" as an instruction | → permission/hook (an instruction is the wrong tool for absolute prohibitions) | blog |
|
||||||
|
| Custom output-style missing `keep-coding-instructions` | (already covered by CA-OST-001) | blog/docs |
|
||||||
|
|
||||||
|
This is **v5.7 D "mechanism-fit" promoted to a real family**, driven by the register
|
||||||
|
instead of hardcoded rules.
|
||||||
|
|
||||||
|
- **Hybrid motor:** cheap deterministic pre-filter (line counts, lifecycle keywords,
|
||||||
|
path-specificity) → opus analyzer agent (sibling of `feature-gap-agent`) that judges fit
|
||||||
|
and cites the register rule. **Precision-gated** (emit only on high confidence).
|
||||||
|
- **Overlap with `feature-gap`:** feature-gap = "you DON'T use feature X"; optimization
|
||||||
|
lens = "you USE mechanism X, but Y fits THIS content better." Decision: **separate
|
||||||
|
family** to keep those two intents clean.
|
||||||
|
- Findings fold into existing posture/report/plan flow → **Fase 2 campaign inherits them
|
||||||
|
for free**.
|
||||||
|
|
||||||
|
## Proposed chunking (one GO'd session each; per `chunk-work-before-compaction`)
|
||||||
|
|
||||||
|
1. **Chunk 1 — Register foundation.** Structured register format + schema validation +
|
||||||
|
migrate existing `knowledge/` + v5.5 V-rows into it + tests. (No output change → byte-stable.)
|
||||||
|
2. **Chunk 2 — The lens (`CA-OPT`).** Deterministic pre-filter + opus analyzer agent +
|
||||||
|
humanizer/scoring wiring + fixtures + byte-stability strip. (New family → additive.)
|
||||||
|
3. **Chunk 3 — `knowledge-refresh`.** Semi-auto poller: `--dry-run`, stale/candidate
|
||||||
|
flagging, human-approved writes. (The "living" part.)
|
||||||
|
|
||||||
|
Dependency order: Chunk 1 → Chunk 2 (lens reads register) → Chunk 3 (keeps register fresh).
|
||||||
|
|
||||||
|
## Verification (per plan-quality rule)
|
||||||
|
|
||||||
|
- **Register:** schema validates; every entry has source + verified-date;
|
||||||
|
`knowledge-refresh --dry-run` lists stale/candidate **without writing**.
|
||||||
|
- **Lens:** a fixture repo with KNOWN mechanism-fit problems (procedure-in-CLAUDE.md,
|
||||||
|
unscoped path-rule) → lens flags **exactly** those; **zero false positives** on a clean
|
||||||
|
fixture. Precision target stated explicitly (lens is precision-gated).
|
||||||
|
- **Byte-stability:** new `CA-OPT` family is additive → follow the **post-B2 "preserve
|
||||||
|
frozen + strip"** regime (do NOT re-seed); regen only SC-5 default-output. New scanner
|
||||||
|
family also bumps `scanners_ok` on the deterministic fixture → mirror the `strip-added-scanner`
|
||||||
|
precedent from CA-OST (v5.6 C).
|
||||||
|
|
||||||
|
## Fase 2 — deferred (own GO, after Fase 1 validated)
|
||||||
|
|
||||||
|
- **Block 3 — Machine-wide campaign:** a durable campaign ledger above sessions (repo
|
||||||
|
list + per-repo status pending/audited/planned/implemented + machine-wide roll-up by
|
||||||
|
severity), resumable across sessions. Start **thin** (ledger + roll-up), not full
|
||||||
|
orchestration.
|
||||||
|
- **Block 4 — Durable backlog + execution:** make persistence explicit + versioned/
|
||||||
|
migratable; one cross-repo prioritized backlog the user picks from; per-repo plans
|
||||||
|
optionally exported to each repo's own `docs/` ("planer følger arbeidsstedet"); reuse
|
||||||
|
existing backup/rollback for execution.
|
||||||
|
- **Persistence note:** sessions already live in `~/.claude/config-audit/sessions/`
|
||||||
|
(OUTSIDE the plugin dir → survive uninstall/reinstall/upgrade — verified). Fase 2 adds
|
||||||
|
an explicit version field + migration so upgrades don't break the ledger.
|
||||||
|
|
||||||
|
## Synergy / open threads
|
||||||
|
|
||||||
|
- **`/repo-init` synergy:** once the lens exists, it becomes the quality meter for what
|
||||||
|
`/repo-init` produces — evaluate repo-init output with the same lens.
|
||||||
|
- **Open:** final ID prefix for the family (`CA-OPT` proposed); register file format
|
||||||
|
(YAML vs JSON); `knowledge-refresh` poll cadence + which sources beyond changelog/blog.
|
||||||
141
knowledge/best-practices.json
Normal file
141
knowledge/best-practices.json
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"note": "Machine-readable best-practices register. SOURCE OF TRUTH for the optimization lens (v5.7 CA-OPT). Human-readable mirror lives in knowledge/*.md. Every entry is provenance-stamped (source.url + source.verified) and carries a confidence; only CONFIRMED claims are consumed user-facing (Verifiseringsplikt). Curated manually + by /config-audit knowledge-refresh (human-approved). Seeded from docs/v5.5-steering-model-plan.md V-rows + the Anthropic 'Steering Claude Code' blog.",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"id": "BP-MECH-001",
|
||||||
|
"claim": "Lifecycle automation phrased as an instruction in CLAUDE.md (\"every time\", \"before each\", \"always run X after Y\") should be a hook — a behavior the model chooses to follow is not deterministic.",
|
||||||
|
"mechanism": "hook",
|
||||||
|
"appliesTo": "claude-md",
|
||||||
|
"recommendation": "Move the behavior to a PreToolUse/PostToolUse/Stop hook so it runs deterministically, outside the model's discretion.",
|
||||||
|
"confidence": "confirmed",
|
||||||
|
"severity": "low",
|
||||||
|
"category": "mechanism-fit",
|
||||||
|
"lensCheck": "claude-md-lifecycle-phrasing",
|
||||||
|
"source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BP-MECH-002",
|
||||||
|
"claim": "A file- or path-specific constraint placed in root CLAUDE.md or an unscoped rule should be a path-scoped rule (paths: frontmatter), so it loads only when a matching file is touched.",
|
||||||
|
"mechanism": "rule",
|
||||||
|
"appliesTo": "claude-md",
|
||||||
|
"recommendation": "Move it to .claude/rules/ with a paths: frontmatter; unscoped instructions cost tokens every turn whether relevant or not.",
|
||||||
|
"confidence": "confirmed",
|
||||||
|
"severity": "low",
|
||||||
|
"category": "mechanism-fit",
|
||||||
|
"lensCheck": "unscoped-path-specific-instruction",
|
||||||
|
"source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BP-MECH-003",
|
||||||
|
"claim": "A multi-step procedure (deploy/release checklist) in CLAUDE.md should be a skill — CLAUDE.md is for facts Claude should hold all the time; procedures belong in skills.",
|
||||||
|
"mechanism": "skill",
|
||||||
|
"appliesTo": "claude-md",
|
||||||
|
"recommendation": "Extract the procedure into .claude/skills/; its body then loads only on invoke instead of every turn.",
|
||||||
|
"confidence": "confirmed",
|
||||||
|
"severity": "low",
|
||||||
|
"category": "mechanism-fit",
|
||||||
|
"lensCheck": "procedure-in-claude-md",
|
||||||
|
"source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BP-MECH-004",
|
||||||
|
"claim": "An absolute prohibition phrased as a \"never do X\" instruction is the wrong tool; for something that absolutely must not happen, use permissions or a PreToolUse hook.",
|
||||||
|
"mechanism": "permission",
|
||||||
|
"appliesTo": "claude-md",
|
||||||
|
"recommendation": "Enforce hard prohibitions via permission deny rules or a PreToolUse hook (exit code 2 denies the call), not prose instructions.",
|
||||||
|
"confidence": "confirmed",
|
||||||
|
"severity": "low",
|
||||||
|
"category": "mechanism-fit",
|
||||||
|
"lensCheck": "never-instruction",
|
||||||
|
"source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BP-MECH-005",
|
||||||
|
"claim": "A custom output style without keep-coding-instructions: true removes Claude Code's built-in software-engineering instructions when active.",
|
||||||
|
"mechanism": "output-style",
|
||||||
|
"appliesTo": "output-style",
|
||||||
|
"recommendation": "Set keep-coding-instructions: true, or prefer a built-in style (Explanatory / Learning / Proactive) before writing a custom one.",
|
||||||
|
"confidence": "confirmed",
|
||||||
|
"severity": "medium",
|
||||||
|
"category": "mechanism-fit",
|
||||||
|
"lensCheck": "CA-OST-001",
|
||||||
|
"source": { "url": "https://code.claude.com/docs/en/output-styles", "title": "Output styles", "verified": "2026-06-20" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BP-LOAD-001",
|
||||||
|
"claim": "Project-root CLAUDE.md and unscoped rules are re-injected from disk after compaction (they survive a /compact).",
|
||||||
|
"appliesTo": "claude-md",
|
||||||
|
"confidence": "confirmed",
|
||||||
|
"category": "loading-model",
|
||||||
|
"lensCheck": null,
|
||||||
|
"source": { "url": "https://code.claude.com/docs/en/context-window", "title": "Context window — what survives compaction", "verified": "2026-06-20" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BP-LOAD-002",
|
||||||
|
"claim": "Path-scoped rules are lost after compaction until a matching file is read again, and they trigger on Read of a matching file (not on every tool use).",
|
||||||
|
"appliesTo": "rule",
|
||||||
|
"confidence": "confirmed",
|
||||||
|
"category": "loading-model",
|
||||||
|
"lensCheck": null,
|
||||||
|
"source": { "url": "https://code.claude.com/docs/en/memory", "title": "Memory — path-specific rules", "verified": "2026-06-20" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BP-LOAD-003",
|
||||||
|
"claim": "A nested (non-root) CLAUDE.md is lost after compaction until a file in its directory is read again.",
|
||||||
|
"appliesTo": "claude-md",
|
||||||
|
"confidence": "confirmed",
|
||||||
|
"category": "loading-model",
|
||||||
|
"lensCheck": null,
|
||||||
|
"source": { "url": "https://code.claude.com/docs/en/context-window", "title": "Context window — what survives compaction", "verified": "2026-06-20" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BP-LOAD-004",
|
||||||
|
"claim": "A skill's name + description load every turn; its body loads only on invoke.",
|
||||||
|
"appliesTo": "skill",
|
||||||
|
"confidence": "confirmed",
|
||||||
|
"category": "loading-model",
|
||||||
|
"lensCheck": null,
|
||||||
|
"source": { "url": "https://code.claude.com/docs/en/skills", "title": "Skills", "verified": "2026-06-20" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BP-LOAD-005",
|
||||||
|
"claim": "Hook scripts run outside the model context, but any additionalContext they inject is saved to the transcript and is therefore subject to compaction.",
|
||||||
|
"appliesTo": "hook",
|
||||||
|
"confidence": "confirmed",
|
||||||
|
"category": "loading-model",
|
||||||
|
"lensCheck": null,
|
||||||
|
"source": { "url": "https://code.claude.com/docs/en/hooks", "title": "Hooks", "verified": "2026-06-20" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BP-LOAD-006",
|
||||||
|
"claim": "A subagent runs in an isolated, fresh context window; only its final summary returns to the main session (parent instructions are not auto-injected).",
|
||||||
|
"appliesTo": "agent",
|
||||||
|
"confidence": "confirmed",
|
||||||
|
"category": "loading-model",
|
||||||
|
"lensCheck": null,
|
||||||
|
"source": { "url": "https://code.claude.com/docs/en/sub-agents", "title": "Subagents", "verified": "2026-06-20" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BP-SIZE-001",
|
||||||
|
"claim": "Keep CLAUDE.md under 200 lines; give it an owner and review changes to it like code. Every line costs tokens whether relevant or not.",
|
||||||
|
"appliesTo": "claude-md",
|
||||||
|
"recommendation": "Trim CLAUDE.md to facts; move procedures to skills and path-specific rules to .claude/rules/.",
|
||||||
|
"confidence": "confirmed",
|
||||||
|
"severity": "medium",
|
||||||
|
"category": "size-budget",
|
||||||
|
"lensCheck": "CA-CML-001",
|
||||||
|
"source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BP-SIZE-002",
|
||||||
|
"claim": "The skill-listing description cap is 1,536 characters (maxSkillDescriptionChars, configurable, v2.1.105+); the name + description load every turn.",
|
||||||
|
"appliesTo": "skill",
|
||||||
|
"confidence": "confirmed",
|
||||||
|
"severity": "low",
|
||||||
|
"category": "size-budget",
|
||||||
|
"lensCheck": "CA-SKL-002",
|
||||||
|
"source": { "url": "https://code.claude.com/docs/en/skills", "title": "Skills", "verified": "2026-06-20" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,21 @@
|
||||||
| `/code-review` bundled review skill | v2.1.147 | Renamed from the former cleanup skill; reports correctness bugs at a chosen effort (`/code-review high`), `--comment` posts inline PR comments. |
|
| `/code-review` bundled review skill | v2.1.147 | Renamed from the former cleanup skill; reports correctness bugs at a chosen effort (`/code-review high`), `--comment` posts inline PR comments. |
|
||||||
| `/config key=value` | v2.1.181 | Apply any setting from the prompt (e.g. `/config thinking=false`) — interactive, `-p`, and Remote Control. Lets `/config-audit` settings recommendations be applied without hand-editing settings.json. |
|
| `/config key=value` | v2.1.181 | Apply any setting from the prompt (e.g. `/config thinking=false`) — interactive, `-p`, and Remote Control. Lets `/config-audit` settings recommendations be applied without hand-editing settings.json. |
|
||||||
|
|
||||||
|
## v5.3.0 scanner-backing facts (verified 2026-06-19)
|
||||||
|
|
||||||
|
| Fact | Source | Backs |
|
||||||
|
|------|--------|-------|
|
||||||
|
| **"Large CLAUDE.md will impact performance (X chars > 40.0k)"** — Claude Code emits this startup warning when a CLAUDE.md exceeds ~40,000 chars. CC 2.1.169 scales the threshold with the model's context window: ~40.0k at a 200k-context model, relaxing to ~200,000 chars at 1M context. The figure is a **char** count, not a line count — complementary to the 200/500-line adherence guidance (a file can be long by lines yet under budget, or short by lines yet over it). | CC changelog 2.1.169 + live startup-warning text | CML `CA-CML` char-budget finding — anchors on the conservative 200k window (fires earliest, since the user's window is unobservable) and discloses the relaxed 1M figure. Window constants in `scanners/lib/context-window.mjs`. Severity medium (token cost, not an adherence cliff). |
|
||||||
|
| **`Tool(param:value)` permission matching is deny/ask-only.** The `param:value` form is honored only in `permissions.deny` and `permissions.ask`; in `permissions.allow` it matches nothing (dead config). Separately, a rule whose key is the tool's own *canonicalizing field* — `command` (Bash/PowerShell), `file_path` (Read/Edit/Write), `path` (Grep/Glob), `notebook_path` (NotebookEdit), `url` (WebFetch) — is **ignored** by CC, which emits a startup warning. Valid parameter forms (`Bash(npm:*)`, `WebFetch(domain:host)`, `Agent(model:opus)`) are honored. | code.claude.com/docs/en/permissions | DIS forbidden-param finding (`forbiddenParamRule` in `permission-rules.mjs`): deny/ask = false security (medium, the block never applies), allow = dead config (low). |
|
||||||
|
|
||||||
|
## v5.4.0 scanner-backing facts (verified 2026-06-19)
|
||||||
|
|
||||||
|
| Fact | Source | Backs |
|
||||||
|
|------|--------|-------|
|
||||||
|
| **plugin.json component-path keys in the *replaces* set shadow their default folder.** `commands`, `agents`, and `outputStyles` each replace a default folder (`commands/`, `agents/`, `output-styles/`); setting one to a custom path while the same-named default folder still exists makes the folder silently ignored — Claude Code warns in `/doctor`, `claude plugin list`, and the `/plugin` detail view. **`skills` is different — it *adds to* the default `skills/` scan (both load, never a shadow).** `hooks`/`mcpServers`/`lspServers` have their own merge rules (not folder shadows). A custom path that resolves *into* the default folder (e.g. `"commands": ["./commands/x.md"]`) keeps the folder scanned (explicit-address exception). | code.claude.com/docs path-behavior-rules; CC `/doctor` + `claude plugin list` + `/plugin` detail (v2.1.140+) | PLH `CA-PLH-015` plugin-folder shadowing — `SHADOWING_PATH_FIELDS` = `commands`/`agents`/`outputStyles` only; `addressesDefaultDir` exception. Severity medium, `category: 'plugin-hygiene'`. |
|
||||||
|
| **`claude plugin validate` requires each `skills:` entry to be a directory inside the plugin.** A `plugin.json` `skills` field (string or array) must point at an existing directory within the plugin root; installed plugins **cannot reference files outside their directory** (no `../shared-utils` path traversal). | `claude plugin validate` (~2.1.145); code.claude.com/docs path-traversal rule | PLH `CA-PLH-016` `skills:`-array validation — normalizes string→`[string]`, flags `non-string` / `escapes-root` / `not-found` / `not-a-directory`; escape detection is plugin-root containment. Severity medium. |
|
||||||
|
| **`autoMode` is a four-sub-key object and is not read from shared project settings.** Its only valid sub-keys are `environment`, `allow`, `soft_deny`, `hard_deny`, each a **string array** (entries are prose rules; the literal `"$defaults"` is valid). The classifier **does not read `autoMode` from shared project settings** (`.claude/settings.json`), so a checked-in repo cannot inject its own allow rules; valid scopes are user (`~/.claude/settings.json`), local (`.claude/settings.local.json`), and managed. | code.claude.com/docs/en/auto-mode-config | SET `CA-SET` autoMode — structure (object + four string-array sub-keys; `not-an-object`/`unknown-subkey`/`not-string-array`) = medium; dead-config (`shared-project-scope`, keyed on `file.scope === 'project'`) = low. |
|
||||||
|
|
||||||
## Official Configuration Guidance (Anthropic)
|
## Official Configuration Guidance (Anthropic)
|
||||||
|
|
||||||
These principles are backed by official docs and verified community reports. Use them to ground recommendations.
|
These principles are backed by official docs and verified community reports. Use them to ground recommendations.
|
||||||
|
|
|
||||||
|
|
@ -56,3 +56,14 @@ uncertainty band.
|
||||||
| medium | Materially inflates token cost per turn (cache miss, schema bloat) |
|
| medium | Materially inflates token cost per turn (cache miss, schema bloat) |
|
||||||
| low | Detectable inefficiency that compounds across long sessions |
|
| low | Detectable inefficiency that compounds across long sessions |
|
||||||
| info | Informational signal — no action required, may indicate room for optimisation |
|
| info | Informational signal — no action required, may indicate room for optimisation |
|
||||||
|
|
||||||
|
## Skill-listing budget lever: disableBundledSkills
|
||||||
|
|
||||||
|
When the active skill listing exceeds its token budget (SKL `CA-SKL-002`), the
|
||||||
|
`disableBundledSkills` setting is a direct token-efficiency lever: it removes the descriptions
|
||||||
|
of plugin-bundled skills from the skill listing injected into the system prompt, shrinking the
|
||||||
|
per-turn baseline. Unlike trimming individual descriptions (`CA-SKL-001`), it is a single switch
|
||||||
|
that drops the entire bundled-skill surface at once — appropriate when the bundled skills are not
|
||||||
|
in active use. feature-gap surfaces it as a conditional recommendation, the remediation companion
|
||||||
|
to `CA-SKL-002`. Sibling levers: `skillOverrides` (selectively re-enable specific skills) and
|
||||||
|
per-skill description trimming.
|
||||||
|
|
|
||||||
116
scanners/agent-listing-scanner.mjs
Normal file
116
scanners/agent-listing-scanner.mjs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
/**
|
||||||
|
* AGT Scanner — Agent-listing always-loaded token budget
|
||||||
|
*
|
||||||
|
* Claude Code injects a listing of every active agent's name+description into the
|
||||||
|
* system prompt so it knows which subagents it can delegate to. That listing is
|
||||||
|
* re-sent on EVERY turn, whether or not a delegation happens — so with many
|
||||||
|
* installed agents it is a large always-loaded cost (on a heavily-plugged machine
|
||||||
|
* the dominant single always-loaded source).
|
||||||
|
*
|
||||||
|
* Detection:
|
||||||
|
* CA-AGT-NNN per-agent description over the soft bloat cap (low, advisory)
|
||||||
|
* CA-AGT-NNN aggregate agent-listing estimate exceeds the listing budget (low)
|
||||||
|
*
|
||||||
|
* Per-agent advisories are emitted FIRST (mirroring SKL 001→002). They are a
|
||||||
|
* soft bloat heuristic (the 500-char threshold TOK pattern F uses for SKILL.md
|
||||||
|
* descriptions), NOT a truncation finding — agents have no verified per-
|
||||||
|
* description cap, so nothing is dropped; the description is simply re-sent in
|
||||||
|
* full every turn.
|
||||||
|
*
|
||||||
|
* INTELLECTUAL-HONESTY CONTRACT (the reason this is `low`, not a hard finding):
|
||||||
|
* unlike the skill listing, the agent-listing mechanism is NOT documented (agents
|
||||||
|
* are absent from Claude Code's published context breakdown). So the finding is an
|
||||||
|
* INFERRED, UPPER-BOUND ESTIMATE, the budget is a config-audit heuristic (no
|
||||||
|
* documented agent allotment) anchored on a conservative 200k window, and the
|
||||||
|
* evidence discloses all of that. The cap, budget, and enumerate-and-measure step
|
||||||
|
* live in `lib/agent-listing-budget.mjs` — AGT only constructs the finding.
|
||||||
|
*
|
||||||
|
* Zero external dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { finding, scannerResult } from './lib/output.mjs';
|
||||||
|
import { SEVERITY } from './lib/severity.mjs';
|
||||||
|
import {
|
||||||
|
AGGREGATE_BUDGET_TOKENS,
|
||||||
|
BUDGET_CALIBRATION_NOTE,
|
||||||
|
PER_AGENT_DESC_SOFT_CAP,
|
||||||
|
measureActiveAgentListing,
|
||||||
|
} from './lib/agent-listing-budget.mjs';
|
||||||
|
|
||||||
|
const SCANNER = 'AGT';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main scanner entry point.
|
||||||
|
*
|
||||||
|
* @param {string} _targetPath unused (agent listing is HOME-scoped)
|
||||||
|
* @param {object} _discovery unused (ignores project discovery)
|
||||||
|
*/
|
||||||
|
export async function scan(_targetPath, _discovery) {
|
||||||
|
const start = Date.now();
|
||||||
|
const findings = [];
|
||||||
|
|
||||||
|
const { agents, aggregate } = await measureActiveAgentListing();
|
||||||
|
|
||||||
|
// Per-agent advisory (emitted FIRST so the common "long agent + aggregate"
|
||||||
|
// case reads 001=per-agent, 002=aggregate, mirroring SKL). This is a soft
|
||||||
|
// heuristic, NOT a truncation finding — agents have no verified per-description
|
||||||
|
// cap, so the framing is "this is large and re-sent every turn", never "Claude
|
||||||
|
// Code drops the tail".
|
||||||
|
for (const agent of agents) {
|
||||||
|
if (agent.descLength <= PER_AGENT_DESC_SOFT_CAP) continue;
|
||||||
|
|
||||||
|
const sourceLabel = agent.source === 'plugin'
|
||||||
|
? `plugin:${agent.pluginName}`
|
||||||
|
: 'user';
|
||||||
|
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.low,
|
||||||
|
title: 'Agent description is long (re-sent every turn in the always-loaded listing)',
|
||||||
|
description:
|
||||||
|
`Agent "${agent.name}" (${sourceLabel}) has a description of ${agent.descLength} ` +
|
||||||
|
`characters (>${PER_AGENT_DESC_SOFT_CAP}). Claude Code injects every active agent's ` +
|
||||||
|
'name+description into the agent listing on every turn so it knows which subagents it ' +
|
||||||
|
'can delegate to, so every character of this description re-enters context each turn ' +
|
||||||
|
'whether or not you delegate. Unlike the skill listing there is no verified ' +
|
||||||
|
'per-description cap, so nothing is dropped — this is a bloat advisory, not a ' +
|
||||||
|
'hard-cap finding.',
|
||||||
|
file: agent.path,
|
||||||
|
evidence:
|
||||||
|
`description_chars=${agent.descLength}; soft_cap=${PER_AGENT_DESC_SOFT_CAP} ` +
|
||||||
|
`(heuristic, same bloat threshold TOK pattern F uses for SKILL.md descriptions; agents ` +
|
||||||
|
`have NO verified per-description cap); agent="${agent.name}"; source=${sourceLabel}`,
|
||||||
|
recommendation:
|
||||||
|
'Trim the description toward its trigger phrases / "when to use this agent" cues and move ' +
|
||||||
|
'long examples into the agent body, or disable the plugin if you never delegate to this ' +
|
||||||
|
'agent (the whole agent block then leaves the always-loaded listing).',
|
||||||
|
category: 'token-efficiency',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aggregate.overBudget) {
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.low,
|
||||||
|
title: 'Aggregate agent listing may exceed the always-loaded budget',
|
||||||
|
description:
|
||||||
|
`The ${aggregate.scanned} active agents carry about ${aggregate.aggregateTokens} tokens of ` +
|
||||||
|
'name+description text that Claude Code injects every turn so it knows which subagents it can ' +
|
||||||
|
`delegate to — above the ${AGGREGATE_BUDGET_TOKENS}-token budget this scanner anchors on a 200k ` +
|
||||||
|
'context window. Every one of those tokens is re-sent on every turn whether or not you delegate. ' +
|
||||||
|
'Note: unlike the skill listing, the agent-listing always-loaded mechanism is inferred (not ' +
|
||||||
|
'documented), so this is an upper-bound estimate (see evidence).',
|
||||||
|
evidence:
|
||||||
|
`active_agents_scanned=${aggregate.scanned}; description_chars=${aggregate.aggregateChars}; ` +
|
||||||
|
`description_tokens~${aggregate.aggregateTokens}; budget@200k=${AGGREGATE_BUDGET_TOKENS} tok; ` +
|
||||||
|
`over_by~${aggregate.overBy} tok - ${BUDGET_CALIBRATION_NOTE}`,
|
||||||
|
recommendation:
|
||||||
|
'Shrink the always-loaded agent listing: disable plugins whose agents you do not use (the whole ' +
|
||||||
|
'agent block leaves the listing), remove dead user agents from ~/.claude/agents/, and trim long ' +
|
||||||
|
'agent descriptions toward their trigger phrases.',
|
||||||
|
category: 'token-efficiency',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return scannerResult(SCANNER, 'ok', findings, aggregate.scanned, Date.now() - start);
|
||||||
|
}
|
||||||
|
|
@ -15,9 +15,12 @@
|
||||||
* Zero external dependencies.
|
* Zero external dependencies.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
import { readTextFile } from './lib/file-discovery.mjs';
|
import { readTextFile } from './lib/file-discovery.mjs';
|
||||||
import { finding, scannerResult } from './lib/output.mjs';
|
import { finding, scannerResult } from './lib/output.mjs';
|
||||||
import { SEVERITY } from './lib/severity.mjs';
|
import { SEVERITY } from './lib/severity.mjs';
|
||||||
|
import { findImports } from './lib/yaml-parser.mjs';
|
||||||
|
|
||||||
const SCANNER = 'CPS';
|
const SCANNER = 'CPS';
|
||||||
|
|
||||||
|
|
@ -27,7 +30,28 @@ const SCANNER = 'CPS';
|
||||||
// hits per turn, not to chase every inline date in a long backlog file.
|
// hits per turn, not to chase every inline date in a long backlog file.
|
||||||
const CACHED_PREFIX_LINES = 150;
|
const CACHED_PREFIX_LINES = 150;
|
||||||
|
|
||||||
// Volatile-pattern set (extends token-hotspots.mjs Pattern A).
|
// CC-provided substitution variables that resolve to a stable per-install or
|
||||||
|
// per-project path (e.g. "${CLAUDE_PLUGIN_ROOT}/hooks/x.mjs"). CC expands them
|
||||||
|
// to the same value every turn, so they never break the prompt cache — unlike a
|
||||||
|
// runtime ${TIMESTAMP}. Excluded from the ${VAR} volatile flag (M-BUG-7).
|
||||||
|
const STABLE_CC_VARS = new Set(['CLAUDE_PLUGIN_ROOT', 'CLAUDE_PROJECT_DIR']);
|
||||||
|
|
||||||
|
// Matches every ${VAR} occurrence on a line so a line carrying only stable CC
|
||||||
|
// vars is not mistaken for a runtime cache-buster.
|
||||||
|
const VAR_RX = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
|
||||||
|
|
||||||
|
/** True when a line contains at least one non-CC-stable ${VAR} substitution. */
|
||||||
|
function hasVolatileVar(line) {
|
||||||
|
VAR_RX.lastIndex = 0;
|
||||||
|
let m;
|
||||||
|
while ((m = VAR_RX.exec(line)) !== null) {
|
||||||
|
if (!STABLE_CC_VARS.has(m[1])) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volatile-pattern set (extends token-hotspots.mjs Pattern A). The ${VAR} entry
|
||||||
|
// is `varAware` — flagged via hasVolatileVar() so CC-stable vars are excluded.
|
||||||
const VOLATILE_PATTERNS = [
|
const VOLATILE_PATTERNS = [
|
||||||
{ rx: /\{timestamp\}/i, label: '{timestamp} placeholder' },
|
{ rx: /\{timestamp\}/i, label: '{timestamp} placeholder' },
|
||||||
{ rx: /\{uuid\}/i, label: '{uuid} placeholder' },
|
{ rx: /\{uuid\}/i, label: '{uuid} placeholder' },
|
||||||
|
|
@ -38,9 +62,25 @@ const VOLATILE_PATTERNS = [
|
||||||
{ rx: /^\s*\[\d{4}-\d{2}-\d{2}/, label: 'dated log line [YYYY-MM-DD ...]' },
|
{ rx: /^\s*\[\d{4}-\d{2}-\d{2}/, label: 'dated log line [YYYY-MM-DD ...]' },
|
||||||
// v5 N3 extensions:
|
// v5 N3 extensions:
|
||||||
{ rx: /^\s*!/, label: 'shell-exec line (! prefix)' },
|
{ rx: /^\s*!/, label: 'shell-exec line (! prefix)' },
|
||||||
{ rx: /\$\{[A-Z_][A-Z0-9_]*\}/, label: '${VAR} substitution' },
|
{ rx: /\$\{[A-Z_][A-Z0-9_]*\}/, label: '${VAR} substitution', varAware: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an @import path relative to the file that declares it.
|
||||||
|
* Mirrors import-resolver.mjs / token-hotspots.mjs path semantics.
|
||||||
|
* @param {string} importPath
|
||||||
|
* @param {string} containingFile
|
||||||
|
* @returns {string} absolute resolved path
|
||||||
|
*/
|
||||||
|
function resolveImportPath(importPath, containingFile) {
|
||||||
|
if (importPath.startsWith('~')) {
|
||||||
|
const home = process.env.HOME || process.env.USERPROFILE || tmpdir();
|
||||||
|
return resolve(importPath.replace(/^~/, home));
|
||||||
|
}
|
||||||
|
if (importPath.startsWith('/')) return importPath;
|
||||||
|
return resolve(dirname(containingFile), importPath);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan content for volatile lines within the cached prefix window.
|
* Scan content for volatile lines within the cached prefix window.
|
||||||
* Returns array of {line, label, snippet}.
|
* Returns array of {line, label, snippet}.
|
||||||
|
|
@ -49,18 +89,35 @@ function findVolatileLines(content) {
|
||||||
const out = [];
|
const out = [];
|
||||||
if (!content) return out;
|
if (!content) return out;
|
||||||
const lines = content.split('\n').slice(0, CACHED_PREFIX_LINES);
|
const lines = content.split('\n').slice(0, CACHED_PREFIX_LINES);
|
||||||
|
let inFence = false;
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
for (const { rx, label } of VOLATILE_PATTERNS) {
|
const line = lines[i];
|
||||||
if (rx.test(lines[i])) {
|
// Fenced code blocks (``` or ~~~) hold illustrative, byte-stable literal
|
||||||
|
// text — a ${VAR} or timestamp shown inside one is documentation, not a
|
||||||
|
// runtime cache-buster — so the fence delimiters and their content are
|
||||||
|
// skipped (M-BUG-7).
|
||||||
|
if (/^\s*(```|~~~)/.test(line)) {
|
||||||
|
inFence = !inFence;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inFence) continue;
|
||||||
|
// Strip `inline code` spans before pattern-testing: a {date} or ${VAR}
|
||||||
|
// shown inside backticks is literal documentation text, byte-stable, not a
|
||||||
|
// runtime cache-buster (M-BUG-7). The original line is still reported as the
|
||||||
|
// snippet so context is preserved.
|
||||||
|
const probe = line.replace(/`[^`]*`/g, '');
|
||||||
|
for (const { rx, label, varAware } of VOLATILE_PATTERNS) {
|
||||||
|
// The ${VAR} pattern flags only non-CC-stable substitutions; every other
|
||||||
|
// pattern keeps its plain line test.
|
||||||
|
if (varAware ? !hasVolatileVar(probe) : !rx.test(probe)) continue;
|
||||||
out.push({
|
out.push({
|
||||||
line: i + 1,
|
line: i + 1,
|
||||||
label,
|
label,
|
||||||
snippet: lines[i].length > 120 ? lines[i].slice(0, 117) + '...' : lines[i],
|
snippet: line.length > 120 ? line.slice(0, 117) + '...' : line,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,19 +132,25 @@ export async function scan(targetPath, discovery) {
|
||||||
const findings = [];
|
const findings = [];
|
||||||
let filesScanned = 0;
|
let filesScanned = 0;
|
||||||
|
|
||||||
|
// Files already scanned in-file below — an @import resolving to one of these
|
||||||
|
// is reported by its own iteration, not duplicated as an import finding.
|
||||||
|
const discoveredClaudeMd = new Set(
|
||||||
|
discovery.files.filter(f => f.type === 'claude-md').map(f => f.absPath));
|
||||||
|
// @imported files reported once, even when several CLAUDE.md files import them.
|
||||||
|
const reportedImports = new Set();
|
||||||
|
|
||||||
for (const f of discovery.files) {
|
for (const f of discovery.files) {
|
||||||
if (f.type !== 'claude-md') continue;
|
if (f.type !== 'claude-md') continue;
|
||||||
filesScanned++;
|
filesScanned++;
|
||||||
const content = await readTextFile(f.absPath);
|
const content = await readTextFile(f.absPath);
|
||||||
if (!content) continue;
|
if (!content) continue;
|
||||||
const volatile = findVolatileLines(content);
|
|
||||||
if (volatile.length === 0) continue;
|
|
||||||
|
|
||||||
|
// --- In-file volatility (unchanged behavior) ---
|
||||||
|
const volatile = findVolatileLines(content);
|
||||||
// Skip volatility that's already covered by TOK Pattern A (lines 1–30) —
|
// Skip volatility that's already covered by TOK Pattern A (lines 1–30) —
|
||||||
// CPS' value is in the 31–150 range. Pattern A handles 1–30.
|
// CPS' value is in the 31–150 range. Pattern A handles 1–30.
|
||||||
const beyondTopThirty = volatile.filter(v => v.line > 30);
|
const beyondTopThirty = volatile.filter(v => v.line > 30);
|
||||||
if (beyondTopThirty.length === 0) continue;
|
if (beyondTopThirty.length > 0) {
|
||||||
|
|
||||||
const evidence =
|
const evidence =
|
||||||
beyondTopThirty.slice(0, 5)
|
beyondTopThirty.slice(0, 5)
|
||||||
.map(v => `line ${v.line} (${v.label}): ${v.snippet}`)
|
.map(v => `line ${v.line} (${v.label}): ${v.snippet}`)
|
||||||
|
|
@ -111,5 +174,49 @@ export async function scan(targetPath, discovery) {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- v5.10 B6: volatility inside @imported files ---
|
||||||
|
// @import-ed content is inlined into the cached prefix at the import site.
|
||||||
|
// TOK Pattern A and the in-file scan above never look past the importing
|
||||||
|
// file, so volatility in an imported file is otherwise invisible. We scan
|
||||||
|
// direct imports only (one hop); IMP owns deep-chain analysis. The whole
|
||||||
|
// imported-file prefix counts (no lines-1–30 skip — that exclusion is
|
||||||
|
// root-file-specific to avoid Pattern A overlap, which does not reach here).
|
||||||
|
for (const imp of findImports(content)) {
|
||||||
|
if (imp.line > CACHED_PREFIX_LINES) continue; // import site outside prefix
|
||||||
|
const resolved = resolveImportPath(imp.path, f.absPath);
|
||||||
|
if (discoveredClaudeMd.has(resolved)) continue; // scanned in its own iteration
|
||||||
|
if (reportedImports.has(resolved)) continue;
|
||||||
|
reportedImports.add(resolved);
|
||||||
|
const importedContent = await readTextFile(resolved);
|
||||||
|
if (!importedContent) continue;
|
||||||
|
const importedVolatile = findVolatileLines(importedContent);
|
||||||
|
if (importedVolatile.length === 0) continue;
|
||||||
|
|
||||||
|
const importEvidence =
|
||||||
|
`imported by ${f.relPath || f.absPath} (@${imp.path} at line ${imp.line}); ` +
|
||||||
|
importedVolatile.slice(0, 5)
|
||||||
|
.map(v => `line ${v.line} (${v.label}): ${v.snippet}`)
|
||||||
|
.join('; ');
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.medium,
|
||||||
|
title: 'Volatile content in @imported file breaks cached prefix',
|
||||||
|
description:
|
||||||
|
`@${imp.path} (imported by ${f.relPath || f.absPath} at line ${imp.line}) contains ` +
|
||||||
|
`${importedVolatile.length} volatile entr${importedVolatile.length === 1 ? 'y' : 'ies'} ` +
|
||||||
|
`within its first ${CACHED_PREFIX_LINES} lines. @import-ed content is inlined into the ` +
|
||||||
|
'prompt-cache prefix, so volatility there forces a fresh cache write every turn — even ' +
|
||||||
|
'when the importing CLAUDE.md is itself byte-stable.',
|
||||||
|
file: resolved,
|
||||||
|
evidence: importEvidence,
|
||||||
|
recommendation:
|
||||||
|
'Move volatile content (timestamps, !shell-exec, ${VAR} substitutions, dated logs) out ' +
|
||||||
|
'of the @imported file, or import it below the cached-prefix window. Keep imported config ' +
|
||||||
|
'byte-stable so the importing file\'s cache survives.',
|
||||||
|
category: 'token-efficiency',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
112
scanners/campaign-cli.mjs
Normal file
112
scanners/campaign-cli.mjs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* campaign-cli — read-only reporter for the durable machine-wide campaign ledger
|
||||||
|
* (v5.7 Fase 2, Block 3b).
|
||||||
|
*
|
||||||
|
* Mirrors the knowledge-refresh-cli precedent: it is the DETERMINISTIC, READ-ONLY half
|
||||||
|
* of the hybrid motor. It loads the campaign ledger (the durable file that sits ABOVE
|
||||||
|
* individual config-audit sessions), validates it, and emits the repo list + a
|
||||||
|
* machine-wide roll-up as JSON. It NEVER writes the ledger — initialization and every
|
||||||
|
* status transition belong to the command layer (Block 3c `/config-audit campaign`),
|
||||||
|
* which calls the lib's pure transforms + saveLedger only on explicit, human-approved
|
||||||
|
* action. A missing ledger file is reported gracefully (initialized:false), NEVER created.
|
||||||
|
*
|
||||||
|
* Naming: `-cli` suffix → NOT an orchestrated scanner (the scan-orchestrator only loads
|
||||||
|
* scanner modules), so the scanner count is unchanged and the snapshot suite stays
|
||||||
|
* byte-stable.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node campaign-cli.mjs [--ledger-file <path>] [--output-file <path>]
|
||||||
|
*
|
||||||
|
* Exit codes: 0 = initialized & valid, 1 = not initialized yet (advisory), 3 = error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import {
|
||||||
|
loadLedger,
|
||||||
|
validateLedger,
|
||||||
|
rollUp,
|
||||||
|
buildBacklog,
|
||||||
|
defaultLedgerPath,
|
||||||
|
} from './lib/campaign-ledger.mjs';
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
process.stderr.write(`Error: ${message}\n`);
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
let ledgerFile = null;
|
||||||
|
let outputFile = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const a = args[i];
|
||||||
|
if (a === '--ledger-file' && args[i + 1]) ledgerFile = args[++i];
|
||||||
|
else if (a === '--output-file' && args[i + 1]) outputFile = args[++i];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ledgerPath = resolve(ledgerFile || defaultLedgerPath());
|
||||||
|
|
||||||
|
let ledger;
|
||||||
|
try {
|
||||||
|
// loadLedger returns null on ENOENT (graceful first run) and throws on parse error.
|
||||||
|
ledger = await loadLedger(ledgerPath);
|
||||||
|
} catch (err) {
|
||||||
|
fail(`could not read ledger at ${ledgerPath}: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
let exitCode;
|
||||||
|
|
||||||
|
if (ledger === null) {
|
||||||
|
// Graceful first run — the ledger does not exist yet. We DO NOT create it; that is
|
||||||
|
// the command layer's job (Block 3c), on explicit human-approved action.
|
||||||
|
payload = {
|
||||||
|
status: 'ok',
|
||||||
|
initialized: false,
|
||||||
|
ledgerPath,
|
||||||
|
schemaVersion: null,
|
||||||
|
createdDate: null,
|
||||||
|
updatedDate: null,
|
||||||
|
repos: [],
|
||||||
|
rollUp: rollUp({ repos: [] }),
|
||||||
|
backlog: buildBacklog({ repos: [] }),
|
||||||
|
};
|
||||||
|
exitCode = 1; // advisory: there is no campaign to report yet
|
||||||
|
} else {
|
||||||
|
const { valid, errors } = validateLedger(ledger);
|
||||||
|
if (!valid) {
|
||||||
|
fail(`ledger at ${ledgerPath} is invalid:\n - ${errors.join('\n - ')}`);
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
status: 'ok',
|
||||||
|
initialized: true,
|
||||||
|
ledgerPath,
|
||||||
|
schemaVersion: ledger.schemaVersion,
|
||||||
|
createdDate: ledger.createdDate,
|
||||||
|
updatedDate: ledger.updatedDate,
|
||||||
|
repos: ledger.repos,
|
||||||
|
rollUp: rollUp(ledger),
|
||||||
|
backlog: buildBacklog(ledger),
|
||||||
|
};
|
||||||
|
exitCode = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = JSON.stringify(payload, null, 2);
|
||||||
|
if (outputFile) await writeFile(outputFile, json, 'utf-8');
|
||||||
|
else process.stdout.write(json + '\n');
|
||||||
|
|
||||||
|
process.exit(exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirectRun =
|
||||||
|
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||||
|
if (isDirectRun) {
|
||||||
|
main().catch((err) => {
|
||||||
|
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||||
|
process.exit(3);
|
||||||
|
});
|
||||||
|
}
|
||||||
165
scanners/campaign-export-cli.mjs
Normal file
165
scanners/campaign-export-cli.mjs
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* campaign-export-cli — export a tracked repo's action plan into that repo's own `docs/`
|
||||||
|
* (v5.7 Fase 2, Block 4c).
|
||||||
|
*
|
||||||
|
* Block 4b built the cross-repo prioritized backlog; this is the "plan export" half of Block 4c.
|
||||||
|
* Given a repo tracked in the campaign ledger, it resolves the repo's linked config-audit
|
||||||
|
* session, reads that session's `action-plan.md`, and assembles (via the pure
|
||||||
|
* `campaign-export` lib) a `docs/config-audit-plan-<sessionId>.md` document carrying a
|
||||||
|
* provenance header + the verbatim plan ("planer følger arbeidsstedet").
|
||||||
|
*
|
||||||
|
* Read-only by DEFAULT (a dry-run preview that returns the assembled `document` + `targetPath`
|
||||||
|
* so the command can show the user what will be written). The actual write happens ONLY under
|
||||||
|
* the opt-in `--write` flag — which the `/config-audit campaign` command invokes solely after
|
||||||
|
* explicit human approval (Verifiseringsplikt — nothing auto-written). Writing the file
|
||||||
|
* faithfully (a byte-exact copy of the assembled document) is the CLI's job, not the LLM's, so
|
||||||
|
* a 200-line plan is never re-typed and cannot drift.
|
||||||
|
*
|
||||||
|
* Execution is NOT here: Block 4c reuses the existing `/config-audit implement` (backup +
|
||||||
|
* apply + verify) + `/config-audit rollback`. This CLI only exports the durable record.
|
||||||
|
*
|
||||||
|
* Naming: `-cli` suffix → NOT an orchestrated scanner, so the scanner count is unchanged and
|
||||||
|
* the snapshot suite stays byte-stable.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node campaign-export-cli.mjs --repo <path> [--write]
|
||||||
|
* [--ledger-file <p>] [--sessions-dir <p>] [--reference-date <YYYY-MM-DD>] [--output-file <p>]
|
||||||
|
*
|
||||||
|
* Exit codes: 0 = exportable (preview ready, or written under --write),
|
||||||
|
* 1 = advisory: repo tracked but not exportable yet (no linked session / no plan),
|
||||||
|
* 3 = error (missing --repo, untracked repo, no/corrupt ledger, unreadable plan).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolve, join, dirname } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||||
|
import {
|
||||||
|
loadLedger,
|
||||||
|
validateLedger,
|
||||||
|
defaultLedgerPath,
|
||||||
|
} from './lib/campaign-ledger.mjs';
|
||||||
|
import { planExportPath, buildPlanExportDocument } from './lib/campaign-export.mjs';
|
||||||
|
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
process.stderr.write(`Error: ${message}\n`);
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default session store: next to the ledger, OUTSIDE the plugin dir. */
|
||||||
|
function defaultSessionsDir() {
|
||||||
|
return join(homedir(), '.claude', 'config-audit', 'sessions');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const flags = { repo: null, ledgerFile: null, sessionsDir: null, referenceDate: null, outputFile: null, write: false };
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === '--repo' && argv[i + 1] !== undefined) flags.repo = argv[++i];
|
||||||
|
else if (a === '--ledger-file' && argv[i + 1] !== undefined) flags.ledgerFile = argv[++i];
|
||||||
|
else if (a === '--sessions-dir' && argv[i + 1] !== undefined) flags.sessionsDir = argv[++i];
|
||||||
|
else if (a === '--reference-date' && argv[i + 1] !== undefined) flags.referenceDate = argv[++i];
|
||||||
|
else if (a === '--output-file' && argv[i + 1] !== undefined) flags.outputFile = argv[++i];
|
||||||
|
else if (a === '--write') flags.write = true;
|
||||||
|
else if (a.startsWith('--')) fail(`unknown flag "${a}"`);
|
||||||
|
else fail(`unexpected argument "${a}"`);
|
||||||
|
}
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emit(payload, outputFile, exitCode) {
|
||||||
|
const json = JSON.stringify(payload, null, 2);
|
||||||
|
if (outputFile) await writeFile(outputFile, json, 'utf-8');
|
||||||
|
else process.stdout.write(json + '\n');
|
||||||
|
process.exit(exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const flags = parseArgs(process.argv.slice(2));
|
||||||
|
if (!flags.repo) fail('--repo <path> is required');
|
||||||
|
if (flags.referenceDate && !DATE_RE.test(flags.referenceDate)) fail('--reference-date must be YYYY-MM-DD');
|
||||||
|
|
||||||
|
const ledgerPath = resolve(flags.ledgerFile || defaultLedgerPath());
|
||||||
|
const sessionsDir = resolve(flags.sessionsDir || defaultSessionsDir());
|
||||||
|
const repoPath = resolve(flags.repo);
|
||||||
|
// The clock is read here ONLY — passed to the pure lib as the injected `now`.
|
||||||
|
const now = flags.referenceDate || new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
let ledger;
|
||||||
|
try {
|
||||||
|
ledger = await loadLedger(ledgerPath);
|
||||||
|
} catch (err) {
|
||||||
|
fail(`could not read ledger at ${ledgerPath}: ${err.message}`);
|
||||||
|
}
|
||||||
|
if (ledger === null) fail(`no campaign ledger at ${ledgerPath} — run "/config-audit campaign init" first`);
|
||||||
|
|
||||||
|
const { valid, errors } = validateLedger(ledger);
|
||||||
|
if (!valid) fail(`ledger at ${ledgerPath} is invalid:\n - ${errors.join('\n - ')}`);
|
||||||
|
|
||||||
|
const repo = ledger.repos.find((r) => r.path === repoPath);
|
||||||
|
if (!repo) fail(`repo "${repoPath}" is not tracked in the campaign — add it first`);
|
||||||
|
|
||||||
|
const repoInfo = { path: repo.path, name: repo.name, status: repo.status, sessionId: repo.sessionId ?? null };
|
||||||
|
|
||||||
|
// Gate 1: the repo must have a linked session (set via `set-status … --session <id>`).
|
||||||
|
if (typeof repo.sessionId !== 'string' || repo.sessionId.trim() === '') {
|
||||||
|
return emit(
|
||||||
|
{ status: 'ok', action: 'export', repo: repoInfo, exportable: false, problems: ['no-session-linked'],
|
||||||
|
written: false, targetPath: null, document: null },
|
||||||
|
flags.outputFile,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate 2: that session must carry an action-plan.md (i.e. `/config-audit plan` has run).
|
||||||
|
const sourcePlanPath = join(sessionsDir, repo.sessionId, 'action-plan.md');
|
||||||
|
let planMarkdown;
|
||||||
|
try {
|
||||||
|
planMarkdown = await readFile(sourcePlanPath, 'utf-8');
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.code === 'ENOENT') {
|
||||||
|
return emit(
|
||||||
|
{ status: 'ok', action: 'export', repo: repoInfo, sessionId: repo.sessionId, sourcePlanPath,
|
||||||
|
exportable: false, problems: ['no-action-plan'], written: false, targetPath: null, document: null },
|
||||||
|
flags.outputFile,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fail(`could not read action plan at ${sourcePlanPath}: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = planExportPath(repo.path, repo.sessionId);
|
||||||
|
const document = buildPlanExportDocument({
|
||||||
|
repoName: repo.name,
|
||||||
|
repoPath: repo.path,
|
||||||
|
sessionId: repo.sessionId,
|
||||||
|
planMarkdown,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
|
||||||
|
let written = false;
|
||||||
|
if (flags.write) {
|
||||||
|
await mkdir(dirname(targetPath), { recursive: true });
|
||||||
|
await writeFile(targetPath, document, 'utf-8');
|
||||||
|
written = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return emit(
|
||||||
|
{ status: 'ok', action: 'export', repo: repoInfo, sessionId: repo.sessionId, sourcePlanPath,
|
||||||
|
exportable: true, problems: [], written, targetPath, document },
|
||||||
|
flags.outputFile,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirectRun =
|
||||||
|
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||||
|
if (isDirectRun) {
|
||||||
|
main().catch((err) => {
|
||||||
|
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||||
|
process.exit(3);
|
||||||
|
});
|
||||||
|
}
|
||||||
252
scanners/campaign-write-cli.mjs
Normal file
252
scanners/campaign-write-cli.mjs
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* campaign-write-cli — the human-approved WRITE half of the durable campaign ledger
|
||||||
|
* (v5.7 Fase 2, Block 3c).
|
||||||
|
*
|
||||||
|
* Sibling of the read-only `campaign-cli`: where that one only reports, this one mutates.
|
||||||
|
* Every mutation is routed through the pure, invariant-enforcing lib transforms
|
||||||
|
* (`createLedger`/`addRepo`/`setRepoStatus`) + `saveLedger` — so path normalization/dedup,
|
||||||
|
* idempotent add, the status-lifecycle guard, and the `updatedDate` bump are never
|
||||||
|
* re-implemented by hand. The `/config-audit campaign` command is a thin opus orchestrator:
|
||||||
|
* it reports (via campaign-cli), proposes a change, and only on explicit human approval
|
||||||
|
* invokes a single subcommand here (Verifiseringsplikt). It NEVER auto-writes.
|
||||||
|
*
|
||||||
|
* Determinism mirrors the lib + the knowledge-refresh CLI: `--reference-date` is the only
|
||||||
|
* place the clock is read (defaulting to today), and it is passed to the transforms as the
|
||||||
|
* injected `now`, so the persisted stamps are fully testable.
|
||||||
|
*
|
||||||
|
* Naming: `-cli` suffix → NOT an orchestrated scanner (the scan-orchestrator only loads
|
||||||
|
* scanner modules), so the scanner count is unchanged and the snapshot suite stays
|
||||||
|
* byte-stable.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node campaign-write-cli.mjs init [--ledger-file <p>] [--reference-date <YYYY-MM-DD>]
|
||||||
|
* node campaign-write-cli.mjs add <path>... [--name <n>] [--ledger-file <p>] [--reference-date <d>]
|
||||||
|
* node campaign-write-cli.mjs set-status <path> <status>
|
||||||
|
* [--findings '<json>'] [--session <id>]
|
||||||
|
* [--ledger-file <p>] [--reference-date <d>]
|
||||||
|
* node campaign-write-cli.mjs refresh-tokens [--ledger-file <p>] [--reference-date <d>]
|
||||||
|
* (all accept [--output-file <p>] to write the result payload to a file instead of stdout)
|
||||||
|
*
|
||||||
|
* `refresh-tokens` is the live cross-repo token sweep (v5.9 B2b): for every tracked
|
||||||
|
* repo it runs the manifest's always-loaded accounting (readActiveConfig → buildManifest)
|
||||||
|
* and splits each source into the shared global layer vs the repo's per-repo delta
|
||||||
|
* (splitManifestByOwnership). The shared layer is HOME-derived and identical across
|
||||||
|
* repos, so it is captured ONCE (from the first successful read) and stored at the
|
||||||
|
* ledger root; each repo gets only its delta. This is the IO half of the machine-wide
|
||||||
|
* token roll-up whose pure data model shipped in B2a.
|
||||||
|
*
|
||||||
|
* Exit codes: 0 = write performed (or no repos to sweep, a benign no-op), 1 = advisory
|
||||||
|
* no-op (init when already initialized), 3 = error (unknown subcommand, bad args,
|
||||||
|
* invalid status, untracked repo, no/corrupt ledger).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import {
|
||||||
|
createLedger,
|
||||||
|
addRepo,
|
||||||
|
setRepoStatus,
|
||||||
|
setSharedGlobal,
|
||||||
|
setRepoTokens,
|
||||||
|
rollUp,
|
||||||
|
loadLedger,
|
||||||
|
saveLedger,
|
||||||
|
defaultLedgerPath,
|
||||||
|
} from './lib/campaign-ledger.mjs';
|
||||||
|
import { readActiveConfig } from './lib/active-config-reader.mjs';
|
||||||
|
import { buildManifest, splitManifestByOwnership } from './manifest.mjs';
|
||||||
|
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
process.stderr.write(`Error: ${message}\n`);
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse argv into a subcommand, positional args, and the flag map. */
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const positionals = [];
|
||||||
|
const flags = { ledgerFile: null, referenceDate: null, outputFile: null, name: null, findings: null, session: null };
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === '--ledger-file' && argv[i + 1] !== undefined) flags.ledgerFile = argv[++i];
|
||||||
|
else if (a === '--reference-date' && argv[i + 1] !== undefined) flags.referenceDate = argv[++i];
|
||||||
|
else if (a === '--output-file' && argv[i + 1] !== undefined) flags.outputFile = argv[++i];
|
||||||
|
else if (a === '--name' && argv[i + 1] !== undefined) flags.name = argv[++i];
|
||||||
|
else if (a === '--findings' && argv[i + 1] !== undefined) flags.findings = argv[++i];
|
||||||
|
else if (a === '--session' && argv[i + 1] !== undefined) flags.session = argv[++i];
|
||||||
|
else if (a.startsWith('--')) fail(`unknown flag "${a}"`);
|
||||||
|
else positionals.push(a);
|
||||||
|
}
|
||||||
|
return { subcommand: positionals[0], rest: positionals.slice(1), flags };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load an existing ledger, treating a parse error as a hard failure (never clobber corrupt data). */
|
||||||
|
async function loadOrFail(path) {
|
||||||
|
try {
|
||||||
|
return await loadLedger(path); // null on ENOENT (no ledger yet)
|
||||||
|
} catch (err) {
|
||||||
|
fail(`could not read ledger at ${path}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emit(payload, outputFile, exitCode) {
|
||||||
|
const json = JSON.stringify(payload, null, 2);
|
||||||
|
if (outputFile) await writeFile(outputFile, json, 'utf-8');
|
||||||
|
else process.stdout.write(json + '\n');
|
||||||
|
process.exit(exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { subcommand, rest, flags } = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
if (!subcommand) fail('a subcommand is required: init | add | set-status | refresh-tokens');
|
||||||
|
|
||||||
|
const ledgerPath = resolve(flags.ledgerFile || defaultLedgerPath());
|
||||||
|
if (flags.referenceDate && !DATE_RE.test(flags.referenceDate)) fail('--reference-date must be YYYY-MM-DD');
|
||||||
|
// The clock is read here ONLY — the transforms take this injected `now` and stay pure.
|
||||||
|
const now = flags.referenceDate || new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
if (subcommand === 'init') {
|
||||||
|
const existing = await loadOrFail(ledgerPath);
|
||||||
|
if (existing !== null) {
|
||||||
|
// Advisory no-op: never wipe an existing campaign.
|
||||||
|
return emit(
|
||||||
|
{ status: 'ok', action: 'init', written: false, alreadyInitialized: true, ledgerPath },
|
||||||
|
flags.outputFile,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ledger = createLedger({ now });
|
||||||
|
await saveLedger(ledgerPath, ledger);
|
||||||
|
return emit(
|
||||||
|
{
|
||||||
|
status: 'ok', action: 'init', written: true, alreadyInitialized: false, ledgerPath,
|
||||||
|
schemaVersion: ledger.schemaVersion, createdDate: ledger.createdDate, updatedDate: ledger.updatedDate,
|
||||||
|
repos: ledger.repos, rollUp: rollUp(ledger),
|
||||||
|
},
|
||||||
|
flags.outputFile,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'add') {
|
||||||
|
const paths = rest;
|
||||||
|
if (paths.length === 0) fail('add requires at least one repo path');
|
||||||
|
const loaded = await loadOrFail(ledgerPath);
|
||||||
|
const autoInitialized = loaded === null;
|
||||||
|
let ledger = loaded === null ? createLedger({ now }) : loaded;
|
||||||
|
|
||||||
|
const added = [];
|
||||||
|
const skipped = [];
|
||||||
|
for (const p of paths) {
|
||||||
|
const resolved = resolve(p);
|
||||||
|
const present = ledger.repos.some((r) => r.path === resolved);
|
||||||
|
// --name applies only to a lone path; multi-add lets the lib derive each basename.
|
||||||
|
const name = paths.length === 1 ? flags.name || undefined : undefined;
|
||||||
|
ledger = addRepo(ledger, { path: p, name }, { now });
|
||||||
|
(present ? skipped : added).push(resolved);
|
||||||
|
}
|
||||||
|
await saveLedger(ledgerPath, ledger);
|
||||||
|
return emit(
|
||||||
|
{
|
||||||
|
status: 'ok', action: 'add', written: true, autoInitialized, ledgerPath,
|
||||||
|
added, skipped, repos: ledger.repos, rollUp: rollUp(ledger),
|
||||||
|
},
|
||||||
|
flags.outputFile,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'set-status') {
|
||||||
|
const [path, status] = rest;
|
||||||
|
if (!path || !status) fail('set-status requires <path> <status>');
|
||||||
|
const ledger = await loadOrFail(ledgerPath);
|
||||||
|
if (ledger === null) fail(`no ledger at ${ledgerPath} — run "init" or "add" first`);
|
||||||
|
|
||||||
|
let findingsBySeverity;
|
||||||
|
if (flags.findings !== null) {
|
||||||
|
try {
|
||||||
|
findingsBySeverity = JSON.parse(flags.findings);
|
||||||
|
} catch (err) {
|
||||||
|
fail(`--findings must be valid JSON: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const opts = { now };
|
||||||
|
if (findingsBySeverity !== undefined) opts.findingsBySeverity = findingsBySeverity;
|
||||||
|
if (flags.session !== null) opts.sessionId = flags.session;
|
||||||
|
|
||||||
|
let next;
|
||||||
|
try {
|
||||||
|
next = setRepoStatus(ledger, path, status, opts);
|
||||||
|
} catch (err) {
|
||||||
|
// RangeError (bad status) or Error (untracked repo) → caller error.
|
||||||
|
fail(err.message);
|
||||||
|
}
|
||||||
|
await saveLedger(ledgerPath, next);
|
||||||
|
const resolved = resolve(path);
|
||||||
|
return emit(
|
||||||
|
{
|
||||||
|
status: 'ok', action: 'set-status', written: true, ledgerPath,
|
||||||
|
repo: next.repos.find((r) => r.path === resolved), repos: next.repos, rollUp: rollUp(next),
|
||||||
|
},
|
||||||
|
flags.outputFile,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'refresh-tokens') {
|
||||||
|
const ledger0 = await loadOrFail(ledgerPath);
|
||||||
|
if (ledger0 === null) fail(`no ledger at ${ledgerPath} — run "init" or "add" first`);
|
||||||
|
|
||||||
|
let ledger = ledger0;
|
||||||
|
const swept = [];
|
||||||
|
const skipped = [];
|
||||||
|
// The shared global layer is HOME-derived and identical across every repo, so it
|
||||||
|
// is captured ONCE (from the first repo that reads cleanly) and stored at the
|
||||||
|
// ledger root — the structural guard against the historic shared-layer double-count.
|
||||||
|
let sharedSummary = null;
|
||||||
|
|
||||||
|
for (const repo of ledger0.repos) {
|
||||||
|
let split;
|
||||||
|
try {
|
||||||
|
const activeConfig = await readActiveConfig(repo.path, { verbose: false });
|
||||||
|
const { sources } = buildManifest(activeConfig);
|
||||||
|
split = splitManifestByOwnership(sources);
|
||||||
|
} catch (err) {
|
||||||
|
skipped.push({ path: repo.path, reason: err.message });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sharedSummary === null) sharedSummary = split.shared;
|
||||||
|
ledger = setRepoTokens(ledger, repo.path, split.delta, { now });
|
||||||
|
swept.push(repo.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sharedSummary !== null) ledger = setSharedGlobal(ledger, sharedSummary, { now });
|
||||||
|
|
||||||
|
const written = swept.length > 0;
|
||||||
|
if (written) await saveLedger(ledgerPath, ledger);
|
||||||
|
|
||||||
|
return emit(
|
||||||
|
{
|
||||||
|
status: 'ok', action: 'refresh-tokens', written, ledgerPath,
|
||||||
|
swept, skipped, sharedGlobal: ledger.sharedGlobal ?? null, rollUp: rollUp(ledger),
|
||||||
|
},
|
||||||
|
flags.outputFile,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(`unknown subcommand "${subcommand}" — expected init | add | set-status | refresh-tokens`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirectRun =
|
||||||
|
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||||
|
if (isDirectRun) {
|
||||||
|
main().catch((err) => {
|
||||||
|
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||||
|
process.exit(3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -9,11 +9,27 @@ import { finding, scannerResult, resetCounter } from './lib/output.mjs';
|
||||||
import { SEVERITY } from './lib/severity.mjs';
|
import { SEVERITY } from './lib/severity.mjs';
|
||||||
import { parseFrontmatter, extractSections, findImports } from './lib/yaml-parser.mjs';
|
import { parseFrontmatter, extractSections, findImports } from './lib/yaml-parser.mjs';
|
||||||
import { lineCount, truncate } from './lib/string-utils.mjs';
|
import { lineCount, truncate } from './lib/string-utils.mjs';
|
||||||
|
import { CONTEXT_WINDOW_ANCHOR, LARGE_CONTEXT_WINDOW, LARGE_CONTEXT_SCALE, scaleForWindow, withCommas } from './lib/context-window.mjs';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
|
||||||
const SCANNER = 'CML';
|
const SCANNER = 'CML';
|
||||||
const MAX_RECOMMENDED_LINES = 200;
|
const MAX_RECOMMENDED_LINES = 200;
|
||||||
const MAX_ABSOLUTE_LINES = 500;
|
const MAX_ABSOLUTE_LINES = 500;
|
||||||
|
|
||||||
|
// Shared remediation for the char-budget finding (byte-identical across the
|
||||||
|
// default and the B8 window-calibrated branches).
|
||||||
|
const CHAR_BUDGET_RECOMMENDATION =
|
||||||
|
'Split detail into @imports and .claude/rules/ files so only the relevant rules load, and keep the top of CLAUDE.md byte-stable for cache hits.';
|
||||||
|
|
||||||
|
// Claude Code's own startup warning ("Large CLAUDE.md will impact performance
|
||||||
|
// (X chars > 40.0k)") fires once a CLAUDE.md passes ~40.0k chars on a
|
||||||
|
// 200k-context model. CC 2.1.169 made that threshold scale with the model's
|
||||||
|
// context window. We mirror it in the same unit CC uses (chars, not lines):
|
||||||
|
// anchor on the conservative 200k window (we cannot observe the user's window,
|
||||||
|
// and the anchor fires earliest) and disclose the relaxed 1M figure.
|
||||||
|
const CLAUDE_MD_CHAR_WARN_ANCHOR = 40_000; // chars @ 200k context (CC startup warning)
|
||||||
|
const CLAUDE_MD_CHAR_WARN_LARGE = CLAUDE_MD_CHAR_WARN_ANCHOR * LARGE_CONTEXT_SCALE; // 200,000 @ 1M
|
||||||
|
|
||||||
/** Recommended sections for a project CLAUDE.md */
|
/** Recommended sections for a project CLAUDE.md */
|
||||||
const RECOMMENDED_SECTIONS = [
|
const RECOMMENDED_SECTIONS = [
|
||||||
{ pattern: /project|overview|description|what/i, label: 'Project overview' },
|
{ pattern: /project|overview|description|what/i, label: 'Project overview' },
|
||||||
|
|
@ -28,10 +44,20 @@ const RECOMMENDED_SECTIONS = [
|
||||||
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery
|
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery
|
||||||
* @returns {Promise<object>}
|
* @returns {Promise<object>}
|
||||||
*/
|
*/
|
||||||
export async function scan(targetPath, discovery) {
|
export async function scan(targetPath, discovery, opts = {}) {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const claudeFiles = discovery.files.filter(f => f.type === 'claude-md');
|
const claudeFiles = discovery.files.filter(f => f.type === 'claude-md');
|
||||||
|
|
||||||
|
// B8 — calibrate the char-budget threshold to the resolved context window. The
|
||||||
|
// default (no opts) is the conservative 200k anchor (40k chars) at full
|
||||||
|
// severity — byte-identical to the pre-B8 finding. An unknown (advisory) window
|
||||||
|
// keeps the anchor but downgrades the finding to info instead of a breach.
|
||||||
|
const cw = opts.contextWindow;
|
||||||
|
const window = (cw && typeof cw.window === 'number') ? cw.window : CONTEXT_WINDOW_ANCHOR;
|
||||||
|
const advisory = !!(cw && cw.advisory);
|
||||||
|
const isDefaultWindow = window === CONTEXT_WINDOW_ANCHOR && !advisory;
|
||||||
|
const charThreshold = scaleForWindow(CLAUDE_MD_CHAR_WARN_ANCHOR, window);
|
||||||
|
|
||||||
if (claudeFiles.length === 0) {
|
if (claudeFiles.length === 0) {
|
||||||
return scannerResult(SCANNER, 'ok', [
|
return scannerResult(SCANNER, 'ok', [
|
||||||
finding({
|
finding({
|
||||||
|
|
@ -58,6 +84,23 @@ export async function scan(targetPath, discovery) {
|
||||||
const sections = extractSections(body);
|
const sections = extractSections(body);
|
||||||
const imports = findImports(content);
|
const imports = findImports(content);
|
||||||
|
|
||||||
|
// A nested (subdirectory) CLAUDE.md is NOT re-injected after a context
|
||||||
|
// compaction — only the project-root CLAUDE.md is (context-window.md). Its
|
||||||
|
// instructions silently drop until a file in that directory is read again.
|
||||||
|
const relDir = dirname(file.relPath);
|
||||||
|
if (file.scope === 'project' && relDir !== '.' && relDir !== '.claude' && lines > 5) {
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.low,
|
||||||
|
title: 'Nested CLAUDE.md is not re-injected after compaction',
|
||||||
|
description: `${file.relPath} is a nested (subdirectory) CLAUDE.md. It loads when Claude reads a file in that directory, but after a context compaction it is not re-injected (only the project-root CLAUDE.md is) — its instructions silently drop until a file in that directory is read again.`,
|
||||||
|
file: file.absPath,
|
||||||
|
evidence: `${lines} lines, nested (scope=project, dir="${relDir}")`,
|
||||||
|
recommendation: 'If these instructions must always apply, move the must-hold parts to the project-root CLAUDE.md (re-injected after compaction). Keep nested CLAUDE.md for guidance only needed when working in that directory.',
|
||||||
|
autoFixable: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// --- Length checks ---
|
// --- Length checks ---
|
||||||
// Raw line count is no longer an absolute adherence threshold: CC 2.1.169
|
// Raw line count is no longer an absolute adherence threshold: CC 2.1.169
|
||||||
// scales the "too long" warning by context window, and cache-prefix
|
// scales the "too long" warning by context window, and cache-prefix
|
||||||
|
|
@ -87,6 +130,44 @@ export async function scan(targetPath, discovery) {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Char budget (mirrors Claude Code's own startup warning) ---
|
||||||
|
// Keyed on chars, not lines: CC's "Large CLAUDE.md will impact performance"
|
||||||
|
// warning is char-based (~40.0k @ 200k context) and CC 2.1.169 scales that
|
||||||
|
// threshold with the context window. A file can be long by lines yet under
|
||||||
|
// this budget (short lines), or short by lines yet over it (long lines), so
|
||||||
|
// this is complementary to the line-count checks above.
|
||||||
|
const chars = content.length;
|
||||||
|
if (chars > charThreshold) {
|
||||||
|
if (isDefaultWindow) {
|
||||||
|
// Conservative 200k anchor — byte-identical to the pre-B8 finding.
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.medium,
|
||||||
|
title: 'CLAUDE.md exceeds Claude Code\'s performance-warning threshold',
|
||||||
|
description: `${file.relPath} is ${withCommas(chars)} chars. Claude Code shows a startup warning ("Large CLAUDE.md will impact performance ... chars > 40.0k") once a CLAUDE.md passes ~40.0k chars on a 200k-context model — it loads in full on every turn. CC 2.1.169 scales that threshold with the context window, so on a ${withCommas(LARGE_CONTEXT_WINDOW)}-token model it relaxes to ~${withCommas(CLAUDE_MD_CHAR_WARN_LARGE)} chars and you are likely within it.`,
|
||||||
|
file: file.absPath,
|
||||||
|
evidence: `${withCommas(chars)} chars > 40.0k (200k-context anchor; ~${withCommas(CLAUDE_MD_CHAR_WARN_LARGE)} at ${withCommas(LARGE_CONTEXT_WINDOW)} context). This is an estimate, not measured telemetry.`,
|
||||||
|
recommendation: CHAR_BUDGET_RECOMMENDATION,
|
||||||
|
autoFixable: false,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// B8 — window-calibrated. Advisory (unknown window) downgrades to info.
|
||||||
|
const winLabel = withCommas(window);
|
||||||
|
const threshLabel = withCommas(charThreshold);
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: advisory ? SEVERITY.info : SEVERITY.medium,
|
||||||
|
title: 'CLAUDE.md exceeds Claude Code\'s performance-warning threshold',
|
||||||
|
description: `${file.relPath} is ${withCommas(chars)} chars, over the ~${threshLabel}-char performance-warning threshold Claude Code applies at a ${winLabel}-token context window (it scales the ~40.0k-char @ 200k warning by the context window, CC 2.1.169) — it loads in full on every turn.` +
|
||||||
|
(advisory ? ' Your context window is unknown, so this anchors on the conservative 200k window — advisory.' : ''),
|
||||||
|
file: file.absPath,
|
||||||
|
evidence: `${withCommas(chars)} chars > ${threshLabel} (calibrated to a ${winLabel}-token context window). This is an estimate, not measured telemetry.`,
|
||||||
|
recommendation: CHAR_BUDGET_RECOMMENDATION,
|
||||||
|
autoFixable: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Empty file ---
|
// --- Empty file ---
|
||||||
if (lines < 3) {
|
if (lines < 3) {
|
||||||
findings.push(finding({
|
findings.push(finding({
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
* Finding IDs: CA-CNF-NNN
|
* Finding IDs: CA-CNF-NNN
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { sep } from 'node:path';
|
||||||
import { readTextFile } from './lib/file-discovery.mjs';
|
import { readTextFile } from './lib/file-discovery.mjs';
|
||||||
import { finding, scannerResult } from './lib/output.mjs';
|
import { finding, scannerResult } from './lib/output.mjs';
|
||||||
import { SEVERITY } from './lib/severity.mjs';
|
import { SEVERITY } from './lib/severity.mjs';
|
||||||
|
|
@ -17,6 +18,22 @@ const SCANNER = 'CNF';
|
||||||
// Keys checked separately or not meaningful to compare
|
// Keys checked separately or not meaningful to compare
|
||||||
const SKIP_KEYS = new Set(['$schema', 'hooks', 'permissions']);
|
const SKIP_KEYS = new Set(['$schema', 'hooks', 'permissions']);
|
||||||
|
|
||||||
|
// Files under `.claude/plugins/` are shipped by installed plugins — the plugin's
|
||||||
|
// own settings.json/hooks.json plus bundled test fixtures and examples. They are
|
||||||
|
// not the user's authored cascade and a "conflict" between them is not something
|
||||||
|
// the user can resolve, so they must be excluded from cross-scope conflict
|
||||||
|
// analysis. (Other scanners still need active plugin config, so this exclusion is
|
||||||
|
// CNF-local, not a discovery-level skip. M-BUG-2.)
|
||||||
|
const PLUGIN_TREE_MARKER = `.claude${sep}plugins${sep}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('./lib/file-discovery.mjs').ConfigFile} file
|
||||||
|
* @returns {boolean} true if the file is shipped by an installed plugin
|
||||||
|
*/
|
||||||
|
function isPluginBundled(file) {
|
||||||
|
return file.absPath.includes(PLUGIN_TREE_MARKER);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flatten an object's top-level keys into a simple key→value map.
|
* Flatten an object's top-level keys into a simple key→value map.
|
||||||
* Only first level — we compare top-level settings, not nested.
|
* Only first level — we compare top-level settings, not nested.
|
||||||
|
|
@ -63,10 +80,10 @@ export async function scan(targetPath, discovery) {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const findings = [];
|
const findings = [];
|
||||||
|
|
||||||
// Collect settings files
|
// Collect settings files (excluding plugin-bundled — see PLUGIN_TREE_MARKER)
|
||||||
const settingsFiles = discovery.files.filter(f => f.type === 'settings-json');
|
const settingsFiles = discovery.files.filter(f => f.type === 'settings-json' && !isPluginBundled(f));
|
||||||
// Collect hooks files
|
// Collect hooks files (excluding plugin-bundled)
|
||||||
const hooksFiles = discovery.files.filter(f => f.type === 'hooks-json');
|
const hooksFiles = discovery.files.filter(f => f.type === 'hooks-json' && !isPluginBundled(f));
|
||||||
|
|
||||||
const totalFiles = settingsFiles.length + hooksFiles.length;
|
const totalFiles = settingsFiles.length + hooksFiles.length;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import { readTextFile } from './lib/file-discovery.mjs';
|
||||||
import { finding, scannerResult } from './lib/output.mjs';
|
import { finding, scannerResult } from './lib/output.mjs';
|
||||||
import { SEVERITY } from './lib/severity.mjs';
|
import { SEVERITY } from './lib/severity.mjs';
|
||||||
import { parseJson } from './lib/yaml-parser.mjs';
|
import { parseJson } from './lib/yaml-parser.mjs';
|
||||||
import { dominates, parseRule } from './lib/permission-rules.mjs';
|
import { dominates, parseRule, isIneffectiveAllowGlob, forbiddenParamRule } from './lib/permission-rules.mjs';
|
||||||
|
|
||||||
const SCANNER = 'DIS';
|
const SCANNER = 'DIS';
|
||||||
|
|
||||||
|
|
@ -52,6 +52,41 @@ function findDenyAllowOverlaps(settings) {
|
||||||
return overlaps;
|
return overlaps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find `permissions.allow` entries that are unanchored tool-name globs Claude
|
||||||
|
* Code silently skips (e.g. `mcp__*`, `B*`, `*`). They auto-approve nothing but
|
||||||
|
* the author usually believes they grant access. Returns array of entry strings.
|
||||||
|
*/
|
||||||
|
function findIneffectiveAllowGlobs(settings) {
|
||||||
|
if (!settings || typeof settings !== 'object') return [];
|
||||||
|
const perms = settings.permissions;
|
||||||
|
if (!perms || typeof perms !== 'object') return [];
|
||||||
|
const allowList = Array.isArray(perms.allow) ? perms.allow : [];
|
||||||
|
return allowList.filter(e => isIneffectiveAllowGlob(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find permission rules CC silently ignores because their `Tool(param:value)`
|
||||||
|
* key is the tool's own canonicalizing field (`command`, `file_path`, `path`,
|
||||||
|
* `notebook_path`, `url`). Scans allow + deny + ask so severity can split:
|
||||||
|
* deny/ask hits are false security, allow hits are dead config. Returns array
|
||||||
|
* of { list, entry, tool, key, hint }.
|
||||||
|
*/
|
||||||
|
function findForbiddenParamRules(settings) {
|
||||||
|
if (!settings || typeof settings !== 'object') return [];
|
||||||
|
const perms = settings.permissions;
|
||||||
|
if (!perms || typeof perms !== 'object') return [];
|
||||||
|
const results = [];
|
||||||
|
for (const list of ['allow', 'deny', 'ask']) {
|
||||||
|
const arr = Array.isArray(perms[list]) ? perms[list] : [];
|
||||||
|
for (const entry of arr) {
|
||||||
|
const hit = forbiddenParamRule(entry);
|
||||||
|
if (hit) results.push({ list, entry, ...hit });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main scanner entry point.
|
* Main scanner entry point.
|
||||||
*
|
*
|
||||||
|
|
@ -70,9 +105,9 @@ export async function scan(targetPath, discovery) {
|
||||||
if (!content) continue;
|
if (!content) continue;
|
||||||
const parsed = parseJson(content);
|
const parsed = parseJson(content);
|
||||||
if (!parsed) continue;
|
if (!parsed) continue;
|
||||||
const overlaps = findDenyAllowOverlaps(parsed);
|
|
||||||
if (overlaps.length === 0) continue;
|
|
||||||
|
|
||||||
|
const overlaps = findDenyAllowOverlaps(parsed);
|
||||||
|
if (overlaps.length > 0) {
|
||||||
const evidence = overlaps.slice(0, 5)
|
const evidence = overlaps.slice(0, 5)
|
||||||
.map(o => `${o.tool}: allow="${o.allowEntry}" + deny="${o.denyEntry}"`)
|
.map(o => `${o.tool}: allow="${o.allowEntry}" + deny="${o.denyEntry}"`)
|
||||||
.join('; ');
|
.join('; ');
|
||||||
|
|
@ -94,5 +129,78 @@ export async function scan(targetPath, discovery) {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ineffective = findIneffectiveAllowGlobs(parsed);
|
||||||
|
if (ineffective.length > 0) {
|
||||||
|
const evidence = `allow: ${ineffective.slice(0, 5).map(e => `"${e}"`).join(', ')}`;
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.low,
|
||||||
|
title: 'Ineffective allow wildcard — Claude Code ignores this rule',
|
||||||
|
description:
|
||||||
|
`${f.relPath || f.absPath} has ${ineffective.length} permissions.allow ` +
|
||||||
|
`entr${ineffective.length === 1 ? 'y' : 'ies'} that Claude Code skips: an ` +
|
||||||
|
'unanchored tool-name wildcard auto-approves nothing. CC accepts allow ' +
|
||||||
|
'wildcards only after a literal `mcp__<server>__` prefix.',
|
||||||
|
file: f.absPath,
|
||||||
|
evidence,
|
||||||
|
recommendation:
|
||||||
|
'Replace `*`/`mcp__*` with explicit tool names, or anchor MCP wildcards to ' +
|
||||||
|
'a server (`mcp__<server>__*`). As written these entries grant nothing.',
|
||||||
|
category: 'permissions-hygiene',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const forbidden = findForbiddenParamRules(parsed);
|
||||||
|
const falseSecurity = forbidden.filter(x => x.list === 'deny' || x.list === 'ask');
|
||||||
|
const deadAllow = forbidden.filter(x => x.list === 'allow');
|
||||||
|
|
||||||
|
if (falseSecurity.length > 0) {
|
||||||
|
const evidence = falseSecurity.slice(0, 5)
|
||||||
|
.map(x => `${x.list}: "${x.entry}" → use ${x.hint}`)
|
||||||
|
.join('; ');
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.medium,
|
||||||
|
title: 'Permission rule silently ignored — deny/ask uses a forbidden param key',
|
||||||
|
description:
|
||||||
|
`${f.relPath || f.absPath} has ${falseSecurity.length} deny/ask ` +
|
||||||
|
`rule${falseSecurity.length === 1 ? '' : 's'} whose \`Tool(param:value)\` key is ` +
|
||||||
|
'the tool\'s own canonicalizing field (`command`/`file_path`/`path`/`notebook_path`/' +
|
||||||
|
'`url`). Claude Code ignores these and emits a startup warning, so the guard you ' +
|
||||||
|
'intended does NOT apply — the action you meant to block or gate is effectively ' +
|
||||||
|
'unrestricted.',
|
||||||
|
file: f.absPath,
|
||||||
|
evidence,
|
||||||
|
recommendation:
|
||||||
|
'Rewrite each rule with the tool\'s own specifier syntax (e.g. `Bash(rm *)`, ' +
|
||||||
|
'`Read(./path)`, `WebFetch(domain:host)`). As written these rules block nothing.',
|
||||||
|
category: 'permissions-hygiene',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deadAllow.length > 0) {
|
||||||
|
const evidence = deadAllow.slice(0, 5)
|
||||||
|
.map(x => `allow: "${x.entry}" → use ${x.hint}`)
|
||||||
|
.join('; ');
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.low,
|
||||||
|
title: 'Permission rule silently ignored — allow uses a forbidden param key (dead config)',
|
||||||
|
description:
|
||||||
|
`${f.relPath || f.absPath} has ${deadAllow.length} permissions.allow ` +
|
||||||
|
`rule${deadAllow.length === 1 ? '' : 's'} using \`Tool(param:value)\` on the tool's ` +
|
||||||
|
'own canonicalizing field. `param:value` matching applies only to deny/ask rules; ' +
|
||||||
|
'allow rules use each tool\'s own specifier syntax. Claude Code ignores these and ' +
|
||||||
|
'emits a startup warning — they grant nothing.',
|
||||||
|
file: f.absPath,
|
||||||
|
evidence,
|
||||||
|
recommendation:
|
||||||
|
'Replace with the tool\'s specifier syntax (e.g. `Read(./path)`), or remove the ' +
|
||||||
|
'entry. As written it auto-approves nothing.',
|
||||||
|
category: 'permissions-hygiene',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
/**
|
/**
|
||||||
* GAP Scanner — Feature Gap Scanner
|
* GAP Scanner — Feature Gap Scanner
|
||||||
* Compares actual configuration against complete Claude Code feature register.
|
* Compares actual configuration against complete Claude Code feature register.
|
||||||
* 25 gap dimensions across 4 tiers. Always runs with includeGlobal: true.
|
* 25 gap dimensions across 4 tiers, plus a conditional disableBundledSkills
|
||||||
|
* budget-lever check (remediation companion to SKL CA-SKL-002, fires only under
|
||||||
|
* measured skill-listing pressure). Always runs with includeGlobal: true.
|
||||||
* Finding IDs: CA-GAP-NNN
|
* Finding IDs: CA-GAP-NNN
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { resolve } from 'node:path';
|
import { resolve, join, sep } from 'node:path';
|
||||||
import { readTextFile, discoverConfigFiles } from './lib/file-discovery.mjs';
|
import { readTextFile, discoverConfigFiles } from './lib/file-discovery.mjs';
|
||||||
import { finding, scannerResult } from './lib/output.mjs';
|
import { finding, scannerResult } from './lib/output.mjs';
|
||||||
import { SEVERITY } from './lib/severity.mjs';
|
import { SEVERITY } from './lib/severity.mjs';
|
||||||
import { findImports, parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
|
import { findImports, parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||||
|
import { measureActiveSkillListing, isBundledSkillsDisabled, BUDGET_CALIBRATION_NOTE } from './lib/skill-listing-budget.mjs';
|
||||||
|
import { assessMcpDeferralForRepo } from './lib/mcp-deferral.mjs';
|
||||||
|
import { assessHookContextForRepo } from './lib/hook-additional-context.mjs';
|
||||||
|
|
||||||
const SCANNER = 'GAP';
|
const SCANNER = 'GAP';
|
||||||
|
|
||||||
|
|
@ -41,6 +46,68 @@ function isTargetLocal(ctx, f) {
|
||||||
return f.absPath.startsWith(ctx.targetPath);
|
return f.absPath.startsWith(ctx.targetPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Files that are test/demo/vendored config — NOT part of the user's authored
|
||||||
|
// cascade — must not satisfy "is feature X present?" checks, or they mask real
|
||||||
|
// gaps. The canonical case: this plugin's own examples/optimal-setup sets
|
||||||
|
// outputStyle/statusLine/worktree/model/keybindings/.lsp.json, and (because GAP
|
||||||
|
// always runs includeGlobal) its copies vendored under ~/.claude/plugins/cache
|
||||||
|
// drive every tier-3 presence check to "present" — hiding the user's real gaps
|
||||||
|
// on ANY target. Two classes to exclude:
|
||||||
|
// - plugin-bundled: anything under ~/.claude/plugins/ (absPath marker, mirrors
|
||||||
|
// the CNF conflict-detector exclusion from M-BUG-2).
|
||||||
|
// - nested demo/test data: a file whose path RELATIVE TO THE SCAN TARGET sits
|
||||||
|
// under an examples/ or tests/fixtures/ subtree. relPath (not absPath) is
|
||||||
|
// deliberate: a fixture scanned AS the target keeps its own files, so the
|
||||||
|
// frozen v5.0.0 byte-snapshots (scanned from tests/fixtures/marketplace-medium)
|
||||||
|
// are untouched. (M-BUG-13)
|
||||||
|
const PLUGIN_TREE_MARKER = `.claude${sep}plugins${sep}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('./lib/file-discovery.mjs').ConfigFile} file
|
||||||
|
* @returns {boolean} true if the file is part of the user's authored config
|
||||||
|
*/
|
||||||
|
function isAuthoredConfig(file) {
|
||||||
|
if (file.absPath.includes(PLUGIN_TREE_MARKER)) return false;
|
||||||
|
const segs = (file.relPath || '').split(sep);
|
||||||
|
if (segs.includes('examples')) return false;
|
||||||
|
const ti = segs.indexOf('tests');
|
||||||
|
if (ti !== -1 && segs[ti + 1] === 'fixtures') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the user→project→local settings cascade directly from the filesystem.
|
||||||
|
* The settings-key gap checks ask "does the USER's resolved config set X?" — a
|
||||||
|
* question the includeGlobal discovery answers unreliably on a real machine: the
|
||||||
|
* top-level ~/.claude/settings.json is missed (its relPath carries no `.claude`
|
||||||
|
* segment when the walk root IS ~/.claude) and, when many vendored plugins flood
|
||||||
|
* the walk, dropped by the discovery file cap. Reading the canonical cascade
|
||||||
|
* paths directly is immune to both. Merged INTO (not replacing) the discovery
|
||||||
|
* settings so any non-canonical project settings still count and the frozen
|
||||||
|
* snapshots stay byte-stable. (M-BUG-13)
|
||||||
|
* @param {string} targetPath
|
||||||
|
* @returns {Promise<Array<{ key: string, parsed: object }>>}
|
||||||
|
*/
|
||||||
|
async function readSettingsCascade(targetPath) {
|
||||||
|
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||||
|
const paths = [];
|
||||||
|
if (home) {
|
||||||
|
paths.push(['user', join(home, '.claude', 'settings.json')]);
|
||||||
|
paths.push(['user-local', join(home, '.claude', 'settings.local.json')]);
|
||||||
|
}
|
||||||
|
paths.push(['project', join(targetPath, '.claude', 'settings.json')]);
|
||||||
|
paths.push(['local', join(targetPath, '.claude', 'settings.local.json')]);
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (const [scope, p] of paths) {
|
||||||
|
const content = await readTextFile(p);
|
||||||
|
if (!content) continue;
|
||||||
|
const parsed = parseJson(content);
|
||||||
|
if (parsed && typeof parsed === 'object') out.push({ key: `cascade:${scope}:${p}`, parsed });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
const TIER_SEVERITY = {
|
const TIER_SEVERITY = {
|
||||||
t1: SEVERITY.medium,
|
t1: SEVERITY.medium,
|
||||||
t2: SEVERITY.low,
|
t2: SEVERITY.low,
|
||||||
|
|
@ -87,6 +154,125 @@ function getSettingsValue(ctx, key) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remediation companion to SKL CA-SKL-002: when the active skill listing is over
|
||||||
|
* its budget and the `disableBundledSkills` lever is un-pulled, recommend it.
|
||||||
|
*
|
||||||
|
* Bundled (built-in) skills — /code-review, /batch, /debug, /loop, /claude-api
|
||||||
|
* and more — live in the Claude Code binary, not on disk, so their exact listing
|
||||||
|
* cost cannot be measured here. But they draw on the SAME budget the SKL scanner
|
||||||
|
* measures; when that budget is already exceeded, dropping them is a zero-cost
|
||||||
|
* lever that does not touch the user's own skills. We fire ONLY under measured
|
||||||
|
* pressure (SKL's overflow signal) so this stays an opportunity, not noise.
|
||||||
|
*
|
||||||
|
* Pure and exported for unit testing.
|
||||||
|
*
|
||||||
|
* @param {{ leverPulled: boolean, aggregate: (import('./lib/skill-listing-budget.mjs').BudgetAssessment|null) }} args
|
||||||
|
* @returns {object|null} a GAP finding, or null when the lever is pulled or the listing is within budget
|
||||||
|
*/
|
||||||
|
export function bundledSkillsLeverFinding({ leverPulled, aggregate }) {
|
||||||
|
if (leverPulled) return null;
|
||||||
|
if (!aggregate || !aggregate.overBudget) return null;
|
||||||
|
|
||||||
|
return finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.low,
|
||||||
|
title: 'Bundled skills add to an over-budget skill listing',
|
||||||
|
description:
|
||||||
|
`Your ${aggregate.scanned} active skills already carry ~${aggregate.aggregateTokens} tokens of ` +
|
||||||
|
`description text, over the ${aggregate.budgetTokens}-token listing budget Claude Code allots the ` +
|
||||||
|
'skill listing on a 200k context window (~2% of context, CC 2.1.32). Claude Code also loads its ' +
|
||||||
|
'bundled (built-in) skills — /code-review, /batch, /debug, /loop, /claude-api and more — into that ' +
|
||||||
|
'same listing. They are not on disk, so their exact cost cannot be measured here, but they draw on ' +
|
||||||
|
'the same budget. `disableBundledSkills: true` drops them from the listing, reclaiming space without ' +
|
||||||
|
'touching your own skills.',
|
||||||
|
evidence:
|
||||||
|
`description_tokens~${aggregate.aggregateTokens}; budget@200k=${aggregate.budgetTokens} tok; over_by~` +
|
||||||
|
`${aggregate.overBy} tok; lever=disableBundledSkills (unset) - ${BUDGET_CALIBRATION_NOTE}`,
|
||||||
|
recommendation:
|
||||||
|
'Set `disableBundledSkills: true` in settings.json (or the CLAUDE_CODE_DISABLE_BUNDLED_SKILLS env var) ' +
|
||||||
|
'to hide built-in skills and slash commands from the model and reclaim skill-listing budget (CC 2.1.169+). ' +
|
||||||
|
'Keep it off if you rely on bundled skills like /code-review — in that case trim your own skill ' +
|
||||||
|
'descriptions or use `skillOverrides` instead.',
|
||||||
|
category: 'token-efficiency',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI-over-MCP lever — remediation companion to CA-TOK-006 (v5.10 B4).
|
||||||
|
*
|
||||||
|
* Fires ONLY when MCP tool schemas are forced into the always-loaded prefix
|
||||||
|
* (tool search disabled, or a per-server alwaysLoad), i.e. when MCP is actually
|
||||||
|
* costing always-loaded tokens. When schemas are deferred (the default), MCP is
|
||||||
|
* effectively free until used, so there is nothing to recommend and we stay
|
||||||
|
* silent — opportunity, not noise (mirrors the bundledSkills lever's "fire only
|
||||||
|
* under measured pressure" contract). CLI tools (gh / aws / gcloud) add zero
|
||||||
|
* context tokens until invoked, so they are the lever the deferral mechanism
|
||||||
|
* cannot reach for the forced-upfront servers.
|
||||||
|
*
|
||||||
|
* Pure and exported for unit testing.
|
||||||
|
*
|
||||||
|
* @param {{ assessment: (import('./lib/mcp-deferral.mjs').assessMcpDeferral)|null }} args
|
||||||
|
* @returns {object|null} a GAP finding, or null when nothing is forced upfront
|
||||||
|
*/
|
||||||
|
export function cliOverMcpLeverFinding({ assessment } = {}) {
|
||||||
|
if (!assessment || !assessment.forcedUpfront) return null;
|
||||||
|
const names = (assessment.affectedServers || []).map((m) => m.name).join(', ');
|
||||||
|
return finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.low,
|
||||||
|
title: 'Prefer CLI over MCP for common operations',
|
||||||
|
description:
|
||||||
|
`Your active project MCP tool schemas (~${assessment.aggregateTokens} tokens) are forced into the ` +
|
||||||
|
'always-loaded prefix every turn rather than deferred (see CA-TOK-006). CLI tools (gh, aws, gcloud, …) ' +
|
||||||
|
'add ZERO context tokens until you actually call them, so moving common operations off MCP and onto a ' +
|
||||||
|
'CLI reclaims always-loaded budget the deferral mechanism cannot.',
|
||||||
|
evidence:
|
||||||
|
`forced_schema_tokens~${assessment.aggregateTokens}; servers=${names}; ` +
|
||||||
|
`reason=${assessment.reason || 'alwaysLoad'} (companion to CA-TOK-006)`,
|
||||||
|
recommendation:
|
||||||
|
'For operations a CLI already covers (GitHub → gh, AWS → aws, GCP → gcloud), prefer the CLI over an ' +
|
||||||
|
'MCP server — CLI output enters context only when invoked. Keep MCP for capabilities with no CLI ' +
|
||||||
|
'equivalent, disable unused servers via /mcp, and re-enable tool-search deferral so the rest stay names-only.',
|
||||||
|
category: 'token-efficiency',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* filter-before-Claude-reads lever — remediation companion to HKV's B5 advisory
|
||||||
|
* (v5.10). Fires ONLY when ≥1 active hook was detected injecting unfiltered
|
||||||
|
* command output into additionalContext, i.e. when there is a measured chatty
|
||||||
|
* hook to fix. When no such hook exists there is nothing to recommend and we
|
||||||
|
* stay silent — opportunity, not noise (same "fire only under measured pressure"
|
||||||
|
* contract as the cliOverMcp / bundledSkills levers).
|
||||||
|
*
|
||||||
|
* Pure and exported for unit testing.
|
||||||
|
*
|
||||||
|
* @param {{ flaggedHooks: Array<{event:string, scriptPath:string}> }} args
|
||||||
|
* @returns {object|null} a GAP finding, or null when no chatty hook was detected
|
||||||
|
*/
|
||||||
|
export function filterHookLeverFinding({ flaggedHooks } = {}) {
|
||||||
|
const hooks = Array.isArray(flaggedHooks) ? flaggedHooks : [];
|
||||||
|
if (hooks.length === 0) return null;
|
||||||
|
const scripts = hooks.map((h) => h.scriptPath.split('/').slice(-1)[0]).join(', ');
|
||||||
|
return finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.info,
|
||||||
|
title: 'Filter hook output before it enters context',
|
||||||
|
description:
|
||||||
|
`${hooks.length} active hook${hooks.length === 1 ? '' : 's'} build hookSpecificOutput.additionalContext ` +
|
||||||
|
"from un-grepped command output (see HKV advisory). That field enters Claude's context on every fire, " +
|
||||||
|
'so filtering verbose output down to what matters BEFORE Claude reads it reclaims per-turn tokens — the ' +
|
||||||
|
'documented filter-test-output.sh pattern (grep ERROR and return only matches instead of a 10,000-line log).',
|
||||||
|
evidence: `chatty_hooks=${hooks.length}; scripts=${scripts} (companion to HKV additionalContext advisory)`,
|
||||||
|
recommendation:
|
||||||
|
'In each flagged hook, pipe the command output through grep/head/jq to keep only the actionable lines ' +
|
||||||
|
'before assigning additionalContext. Reserve additionalContext for concise signals; leave bulk diagnostics ' +
|
||||||
|
'on plain stdout (exit 0) so they go to the debug log, not context.',
|
||||||
|
category: 'token-efficiency',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {GapCheck[]} */
|
/** @type {GapCheck[]} */
|
||||||
const GAP_CHECKS = [
|
const GAP_CHECKS = [
|
||||||
// --- Tier 1: Foundation ---
|
// --- Tier 1: Foundation ---
|
||||||
|
|
@ -355,18 +541,30 @@ export async function scan(targetPath, sharedDiscovery) {
|
||||||
? sharedDiscovery
|
? sharedDiscovery
|
||||||
: await discoverConfigFiles(resolve(targetPath), { includeGlobal: true });
|
: await discoverConfigFiles(resolve(targetPath), { includeGlobal: true });
|
||||||
|
|
||||||
// Parse all settings files upfront
|
// Presence checks ("does the user have feature X?") must see only the user's
|
||||||
|
// authored cascade — not bundled/vendored/demo config, which masks real gaps
|
||||||
|
// (M-BUG-13, see isAuthoredConfig).
|
||||||
|
const authoredFiles = discovery.files.filter(isAuthoredConfig);
|
||||||
|
|
||||||
|
// Parse all settings files upfront (authored discovery files) ...
|
||||||
const parsedSettings = new Map();
|
const parsedSettings = new Map();
|
||||||
for (const file of discovery.files.filter(f => f.type === 'settings-json')) {
|
for (const file of authoredFiles.filter(f => f.type === 'settings-json')) {
|
||||||
const content = await readTextFile(file.absPath);
|
const content = await readTextFile(file.absPath);
|
||||||
if (content) {
|
if (content) {
|
||||||
const parsed = parseJson(content);
|
const parsed = parseJson(content);
|
||||||
parsedSettings.set(`${file.scope}:${file.relPath}`, parsed);
|
parsedSettings.set(`${file.scope}:${file.relPath}`, parsed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ... plus the real user→project→local cascade read directly, so settings-key
|
||||||
|
// checks see the true resolved config regardless of the discovery cap/gotcha
|
||||||
|
// (M-BUG-13). Merged, not replacing — keeps non-canonical project settings and
|
||||||
|
// the frozen byte-snapshots unchanged.
|
||||||
|
for (const { key, parsed } of await readSettingsCascade(resolve(targetPath))) {
|
||||||
|
parsedSettings.set(key, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
files: discovery.files,
|
files: authoredFiles,
|
||||||
targetPath: resolve(targetPath),
|
targetPath: resolve(targetPath),
|
||||||
parsedSettings,
|
parsedSettings,
|
||||||
fileContents: new Map(),
|
fileContents: new Map(),
|
||||||
|
|
@ -386,6 +584,27 @@ export async function scan(targetPath, sharedDiscovery) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// disableBundledSkills lever — fires only under measured skill-listing pressure
|
||||||
|
// (SKL's CA-SKL-002 overflow signal). HOME-scoped: the listing and the lever
|
||||||
|
// cascade both resolve via process.env.HOME, independent of project discovery.
|
||||||
|
const leverPulled = await isBundledSkillsDisabled(ctx.targetPath);
|
||||||
|
const { aggregate } = await measureActiveSkillListing();
|
||||||
|
const leverFinding = bundledSkillsLeverFinding({ leverPulled, aggregate });
|
||||||
|
if (leverFinding) findings.push(leverFinding);
|
||||||
|
|
||||||
|
// CLI-over-MCP lever — companion to CA-TOK-006: fires only when project-local
|
||||||
|
// MCP tool schemas are forced into the always-loaded prefix (tool search
|
||||||
|
// disabled or a per-server alwaysLoad). Reuses the same static assessment.
|
||||||
|
const mcpAssessment = await assessMcpDeferralForRepo(ctx.targetPath);
|
||||||
|
const cliLever = cliOverMcpLeverFinding({ assessment: mcpAssessment });
|
||||||
|
if (cliLever) findings.push(cliLever);
|
||||||
|
|
||||||
|
// filter-before-Claude-reads lever — companion to HKV's B5 advisory: fires
|
||||||
|
// only when an active hook injects unfiltered output into additionalContext.
|
||||||
|
const flaggedHooks = await assessHookContextForRepo(discovery);
|
||||||
|
const hookLever = filterHookLeverFinding({ flaggedHooks });
|
||||||
|
if (hookLever) findings.push(hookLever);
|
||||||
|
|
||||||
const filesScanned = discovery.files.length;
|
const filesScanned = discovery.files.length;
|
||||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ function createFixPlan(finding) {
|
||||||
|
|
||||||
// --- RUL scanner fixes ---
|
// --- RUL scanner fixes ---
|
||||||
if (scanner === 'RUL') {
|
if (scanner === 'RUL') {
|
||||||
if (title === 'Rule uses deprecated "globs" field') {
|
if (title === 'Rule uses "globs" instead of documented "paths"') {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
type: FIX_TYPES.FRONTMATTER_RENAME,
|
type: FIX_TYPES.FRONTMATTER_RENAME,
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,18 @@ import { readTextFile, discoverConfigFiles } from './lib/file-discovery.mjs';
|
||||||
import { finding, scannerResult } from './lib/output.mjs';
|
import { finding, scannerResult } from './lib/output.mjs';
|
||||||
import { SEVERITY } from './lib/severity.mjs';
|
import { SEVERITY } from './lib/severity.mjs';
|
||||||
import { parseJson } from './lib/yaml-parser.mjs';
|
import { parseJson } from './lib/yaml-parser.mjs';
|
||||||
|
import { assessHookAdditionalContext } from './lib/hook-additional-context.mjs';
|
||||||
import { stat } from 'node:fs/promises';
|
import { stat } from 'node:fs/promises';
|
||||||
import { resolve, dirname } from 'node:path';
|
import { resolve, dirname } from 'node:path';
|
||||||
|
|
||||||
const SCANNER = 'HKV';
|
const SCANNER = 'HKV';
|
||||||
|
|
||||||
/** All valid hook events (as of June 2026) */
|
/** All valid hook events — verified against code.claude.com/docs/en/hooks.md (2026-06-19) */
|
||||||
const VALID_EVENTS = new Set([
|
const VALID_EVENTS = new Set([
|
||||||
'SessionStart', 'InstructionsLoaded', 'UserPromptSubmit',
|
'SessionStart', 'Setup', 'InstructionsLoaded',
|
||||||
|
'UserPromptSubmit', 'UserPromptExpansion',
|
||||||
'PreToolUse', 'PermissionRequest', 'PermissionDenied',
|
'PreToolUse', 'PermissionRequest', 'PermissionDenied',
|
||||||
'PostToolUse', 'PostToolUseFailure',
|
'PostToolUse', 'PostToolUseFailure', 'PostToolBatch',
|
||||||
'SubagentStart', 'SubagentStop',
|
'SubagentStart', 'SubagentStop',
|
||||||
'TaskCreated', 'TaskCompleted',
|
'TaskCreated', 'TaskCompleted',
|
||||||
'Stop', 'StopFailure',
|
'Stop', 'StopFailure',
|
||||||
|
|
@ -26,9 +28,11 @@ const VALID_EVENTS = new Set([
|
||||||
'WorktreeCreate', 'WorktreeRemove',
|
'WorktreeCreate', 'WorktreeRemove',
|
||||||
'PreCompact', 'PostCompact',
|
'PreCompact', 'PostCompact',
|
||||||
'Elicitation', 'ElicitationResult',
|
'Elicitation', 'ElicitationResult',
|
||||||
'SessionEnd',
|
'SessionEnd', 'MessageDisplay',
|
||||||
// CC 2.1.152 (MessageDisplay), 2.1.169 (post-session, kebab — distinct from SessionEnd)
|
// 'post-session' deliberately EXCLUDED: the 2.1.169 changelog `post-session`
|
||||||
'MessageDisplay', 'post-session',
|
// is a self-hosted-runner workspace-lifecycle hook, NOT a settings.json hook
|
||||||
|
// event (absent from hooks.md; all settings.json events are PascalCase).
|
||||||
|
// Verified 2026-06-20.
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Valid hook handler types */
|
/** Valid hook handler types */
|
||||||
|
|
@ -136,7 +140,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
||||||
description: `${file.relPath}: "${event}" is not a valid hook event. This hook will never fire.`,
|
description: `${file.relPath}: "${event}" is not a valid hook event. This hook will never fire.`,
|
||||||
file: file.absPath,
|
file: file.absPath,
|
||||||
evidence: event,
|
evidence: event,
|
||||||
recommendation: `Valid events: ${[...VALID_EVENTS].slice(0, 8).join(', ')}... (28 total)`,
|
recommendation: `Valid events: ${[...VALID_EVENTS].slice(0, 8).join(', ')}... (${VALID_EVENTS.size} total)`,
|
||||||
autoFixable: false,
|
autoFixable: false,
|
||||||
}));
|
}));
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -245,6 +249,35 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
||||||
autoFixable: false,
|
autoFixable: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v5.10 B5: advisory (info) — a hook that injects unfiltered
|
||||||
|
// command output into hookSpecificOutput.additionalContext pays
|
||||||
|
// that whole payload into Claude's context on every fire (plain
|
||||||
|
// stdout does not). Low-precision static heuristic, so info only.
|
||||||
|
const scriptContent = await readTextFile(scriptPath);
|
||||||
|
const ac = assessHookAdditionalContext({ scriptContent });
|
||||||
|
if (ac.flagged) {
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.info,
|
||||||
|
title: 'Hook injects unfiltered output into context',
|
||||||
|
description:
|
||||||
|
`${file.relPath}: "${event}" runs ${scriptPath.split('/').slice(-2).join('/')} ` +
|
||||||
|
'which builds hookSpecificOutput.additionalContext from un-grepped command ' +
|
||||||
|
"output. That field enters Claude's context every time the hook fires (plain " +
|
||||||
|
'stdout does not), so an unfiltered payload is a recurring per-turn token cost. ' +
|
||||||
|
'Advisory only — low-precision static heuristic; verify the real payload size.',
|
||||||
|
file: scriptPath,
|
||||||
|
evidence:
|
||||||
|
'additional_context_unfiltered=true; ' +
|
||||||
|
`verbose_capture=${ac.hasVerboseCapture}; filter_applied=${ac.hasFilter}`,
|
||||||
|
recommendation:
|
||||||
|
'Filter before Claude reads: grep/head the command output down to what matters ' +
|
||||||
|
'before putting it in additionalContext (the documented filter-test-output.sh ' +
|
||||||
|
'pattern), or keep large diagnostics on plain stdout so they stay out of context.',
|
||||||
|
autoFixable: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
scanners/knowledge-refresh-cli.mjs
Normal file
103
scanners/knowledge-refresh-cli.mjs
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* knowledge-refresh CLI — feeds the v5.7 `/config-audit knowledge-refresh` command
|
||||||
|
* (Chunk 3: the "living" half of the living knowledge base).
|
||||||
|
*
|
||||||
|
* This is the DETERMINISTIC half of the hybrid motor: it loads the best-practices
|
||||||
|
* register and classifies every entry as `fresh` or `stale` by the age of its
|
||||||
|
* `source.verified` stamp (via the pure `assessFreshness` core). It is READ-ONLY —
|
||||||
|
* it NEVER writes the register and NEVER touches the network. Candidate discovery
|
||||||
|
* (polling the CC changelog + Anthropic blog) and the human-approved writes live in
|
||||||
|
* the command layer (Verifiseringsplikt). `--dry-run` is implicit and the only mode;
|
||||||
|
* the flag is accepted for explicitness and echoed back.
|
||||||
|
*
|
||||||
|
* Naming: `-cli` suffix → NOT an orchestrated scanner (the scan-orchestrator only
|
||||||
|
* loads scanner modules), so the scanner count is unchanged and the snapshot suite
|
||||||
|
* stays byte-stable.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node knowledge-refresh-cli.mjs [--output-file <path>] [--stale-after <N>]
|
||||||
|
* [--reference-date <YYYY-MM-DD>] [--dry-run]
|
||||||
|
*
|
||||||
|
* Exit codes: 0 = every entry fresh, 1 = one or more stale (advisory), 3 = error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { loadRegister, REGISTER_PATH } from './lib/best-practices-register.mjs';
|
||||||
|
import { assessFreshness, STALE_AFTER_DAYS_DEFAULT } from './lib/knowledge-refresh.mjs';
|
||||||
|
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
process.stderr.write(`Error: ${message}\n`);
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
let outputFile = null;
|
||||||
|
let staleAfterDays = STALE_AFTER_DAYS_DEFAULT;
|
||||||
|
let referenceDate = null; // null → today
|
||||||
|
let dryRun = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const a = args[i];
|
||||||
|
if (a === '--dry-run') dryRun = true;
|
||||||
|
else if (a === '--output-file' && args[i + 1]) outputFile = args[++i];
|
||||||
|
else if (a === '--stale-after' && args[i + 1] !== undefined) {
|
||||||
|
const n = Number.parseInt(args[++i], 10);
|
||||||
|
if (!Number.isInteger(n) || n < 0) fail('--stale-after must be a non-negative integer (days)');
|
||||||
|
staleAfterDays = n;
|
||||||
|
} else if (a === '--reference-date' && args[i + 1]) {
|
||||||
|
referenceDate = args[++i];
|
||||||
|
if (!DATE_RE.test(referenceDate)) fail('--reference-date must be YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clock is read here ONLY — the core takes an injected date and stays pure.
|
||||||
|
const ref = referenceDate || new Date();
|
||||||
|
|
||||||
|
let register;
|
||||||
|
try {
|
||||||
|
register = loadRegister();
|
||||||
|
} catch (err) {
|
||||||
|
fail(`could not load register at ${REGISTER_PATH}: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let assessment;
|
||||||
|
try {
|
||||||
|
assessment = assessFreshness(register, { referenceDate: ref, staleAfterDays });
|
||||||
|
} catch (err) {
|
||||||
|
fail(err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
status: 'ok',
|
||||||
|
registerPath: REGISTER_PATH,
|
||||||
|
version: register.version,
|
||||||
|
dryRun: true, // this CLI never writes; the flag is informational
|
||||||
|
requestedDryRun: dryRun,
|
||||||
|
referenceDate: assessment.referenceDate,
|
||||||
|
staleAfterDays: assessment.staleAfterDays,
|
||||||
|
counts: assessment.counts,
|
||||||
|
stale: assessment.stale,
|
||||||
|
fresh: assessment.fresh,
|
||||||
|
};
|
||||||
|
|
||||||
|
const json = JSON.stringify(payload, null, 2);
|
||||||
|
if (outputFile) await writeFile(outputFile, json, 'utf-8');
|
||||||
|
else process.stdout.write(json + '\n');
|
||||||
|
|
||||||
|
process.exit(assessment.counts.stale > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirectRun =
|
||||||
|
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||||
|
if (isDirectRun) {
|
||||||
|
main().catch((err) => {
|
||||||
|
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||||
|
process.exit(3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -53,6 +53,134 @@ export function estimateTokens(bytes, kind = 'markdown', opts = {}) {
|
||||||
return Math.ceil(bytes / 4);
|
return Math.ceil(bytes / 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip block-level HTML comments (`<!-- ... -->`) that lie OUTSIDE fenced code
|
||||||
|
* blocks. Claude Code strips these before injecting a CLAUDE.md / memory file
|
||||||
|
* into context (code.claude.com/docs/en/memory: "block-level HTML comments are
|
||||||
|
* stripped before the content is injected"), preserving them only inside fenced
|
||||||
|
* code blocks (``` / ~~~). A byte-accurate token estimate must therefore discount
|
||||||
|
* them. (M-BUG-6)
|
||||||
|
*
|
||||||
|
* Conservative scope — only *block-level* comments are removed (a comment that
|
||||||
|
* occupies its own line(s)); inline comments sharing a line with other text are
|
||||||
|
* retained, since the verified CC behavior covers block-level stripping only.
|
||||||
|
*
|
||||||
|
* @param {string} content
|
||||||
|
* @returns {string} content with out-of-fence block comments removed
|
||||||
|
*/
|
||||||
|
export function stripInjectedHtmlComments(content) {
|
||||||
|
if (typeof content !== 'string' || content === '') return '';
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const out = [];
|
||||||
|
let inFence = false;
|
||||||
|
let inComment = false;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (inComment) {
|
||||||
|
// Inside a multi-line block comment: drop lines until the closing `-->`,
|
||||||
|
// keeping any real content that trails the close on the same line.
|
||||||
|
const end = line.indexOf('-->');
|
||||||
|
if (end !== -1) {
|
||||||
|
inComment = false;
|
||||||
|
const rest = line.slice(end + 3);
|
||||||
|
if (rest.trim() !== '') out.push(rest);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Fence delimiters (``` / ~~~) toggle a preserve-verbatim region.
|
||||||
|
if (/^\s*(```|~~~)/.test(line)) {
|
||||||
|
inFence = !inFence;
|
||||||
|
out.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inFence) {
|
||||||
|
out.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Whole line is a single self-contained block comment → CC strips it.
|
||||||
|
if (/^\s*<!--[\s\S]*?-->\s*$/.test(line)) continue;
|
||||||
|
// Block comment opening with nothing but whitespace before it and no close
|
||||||
|
// on this line → runs onto following lines.
|
||||||
|
const openIdx = line.indexOf('<!--');
|
||||||
|
if (openIdx !== -1 && line.indexOf('-->', openIdx) === -1 && line.slice(0, openIdx).trim() === '') {
|
||||||
|
inComment = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective injected byte length of a CLAUDE.md / memory source: raw UTF-8 bytes
|
||||||
|
* minus the block-level HTML comments CC strips before injection. Used wherever a
|
||||||
|
* CLAUDE.md token estimate must reflect what actually enters context. (M-BUG-6)
|
||||||
|
*
|
||||||
|
* @param {string} content
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function effectiveMemoryBytes(content) {
|
||||||
|
if (typeof content !== 'string') return 0;
|
||||||
|
return Buffer.byteLength(stripInjectedHtmlComments(content), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Load-pattern model (v5.6 Foundation)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive how a config source loads into context and whether it survives a
|
||||||
|
* `/compact`, from the published Claude Code loading model. Deterministic,
|
||||||
|
* side-effect-free. `derivationConfidence` is 'confirmed' when a primary-doc
|
||||||
|
* row nails the row (V-rows in docs/v5.5-steering-model-plan.md), 'inferred'
|
||||||
|
* when reasoned from an analogue (so a renderer can choose to mark it).
|
||||||
|
*
|
||||||
|
* loadPattern ∈ { 'always', 'on-demand', 'external', 'unknown' }
|
||||||
|
* survivesCompaction ∈ { 'yes', 'no', 'n/a' }
|
||||||
|
*
|
||||||
|
* @param {string} kind - source kind (see switch)
|
||||||
|
* @param {{scoped?: boolean}} [opts] - kind-specific discriminators
|
||||||
|
* @returns {{loadPattern:string, survivesCompaction:string, derivationConfidence:string}}
|
||||||
|
*/
|
||||||
|
export function deriveLoadPattern(kind, opts = {}) {
|
||||||
|
const mk = (loadPattern, survivesCompaction, derivationConfidence) =>
|
||||||
|
({ loadPattern, survivesCompaction, derivationConfidence });
|
||||||
|
switch (kind) {
|
||||||
|
// CLAUDE.md cascade
|
||||||
|
case 'claude-md-root': return mk('always', 'yes', 'confirmed'); // V1
|
||||||
|
case 'claude-md-nested': return mk('on-demand', 'no', 'confirmed'); // V3
|
||||||
|
case 'claude-md-user':
|
||||||
|
case 'claude-md-managed':
|
||||||
|
case 'claude-md-import': return mk('always', 'yes', 'inferred');
|
||||||
|
// Rules
|
||||||
|
case 'rule':
|
||||||
|
return opts.scoped
|
||||||
|
? mk('on-demand', 'no', 'confirmed') // V2, V4 (loads on Read of a match)
|
||||||
|
: mk('always', 'yes', 'confirmed'); // V1, V6 (unscoped = always-on)
|
||||||
|
// Skills
|
||||||
|
case 'skill-listing': return mk('always', 'n/a', 'confirmed'); // V7 (name+desc every turn)
|
||||||
|
case 'skill-body': return mk('on-demand', 'n/a', 'confirmed'); // V7 (body on invoke)
|
||||||
|
// Agents — name+description load for delegation each turn (skill analogue;
|
||||||
|
// no primary-doc row pins it, so 'inferred').
|
||||||
|
case 'agent': return mk('always', 'n/a', 'inferred');
|
||||||
|
// Output styles modify the system prompt, re-sent every turn (V10, V12).
|
||||||
|
case 'output-style': return mk('always', 'yes', 'confirmed');
|
||||||
|
// Hooks run outside context (V18); the hook itself is external.
|
||||||
|
case 'hook': return mk('external', 'n/a', 'confirmed');
|
||||||
|
// MCP tool schemas are part of the per-turn payload (no explicit
|
||||||
|
// compaction-survival row → 'inferred').
|
||||||
|
case 'mcp': return mk('always', 'yes', 'inferred');
|
||||||
|
// Slash-command body loads when the command is invoked (on-demand). No
|
||||||
|
// primary-doc row pins the always-loaded command listing cost → 'inferred'.
|
||||||
|
case 'command': return mk('on-demand', 'n/a', 'inferred');
|
||||||
|
// Harness-config files (settings.json, keybindings.json, .mcp.json, hooks.json,
|
||||||
|
// plugin.json, ~/.claude.json) are read by the CLI to configure the harness —
|
||||||
|
// they are NOT injected into the model context, so they cost no per-turn
|
||||||
|
// context tokens. 'external' = outside the context window (like hooks).
|
||||||
|
case 'harness-config': return mk('external', 'n/a', 'inferred');
|
||||||
|
default: return mk('unknown', 'n/a', 'inferred');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Git root detection
|
// Git root detection
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -144,7 +272,11 @@ export async function walkClaudeMdCascade(repoPath) {
|
||||||
|
|
||||||
const totalBytes = files.reduce((sum, f) => sum + f.bytes, 0);
|
const totalBytes = files.reduce((sum, f) => sum + f.bytes, 0);
|
||||||
const totalLines = files.reduce((sum, f) => sum + f.lines, 0);
|
const totalLines = files.reduce((sum, f) => sum + f.lines, 0);
|
||||||
const estimatedTokens = estimateTokens(totalBytes, 'markdown');
|
// Token estimate is computed from the *effective* (injected) byte count — CC
|
||||||
|
// strips block-level HTML comments before injection — while totalBytes stays
|
||||||
|
// the honest on-disk figure. (M-BUG-6)
|
||||||
|
const effectiveBytes = files.reduce((sum, f) => sum + (f.effectiveBytes ?? f.bytes), 0);
|
||||||
|
const estimatedTokens = estimateTokens(effectiveBytes, 'markdown');
|
||||||
|
|
||||||
return { files, totalBytes, totalLines, estimatedTokens };
|
return { files, totalBytes, totalLines, estimatedTokens };
|
||||||
}
|
}
|
||||||
|
|
@ -159,6 +291,7 @@ async function tryAddClaudeMd(absPath, scope, parent, files, seen) {
|
||||||
path: absPath,
|
path: absPath,
|
||||||
scope,
|
scope,
|
||||||
bytes: s.size,
|
bytes: s.size,
|
||||||
|
effectiveBytes: effectiveMemoryBytes(content),
|
||||||
lines: lineCount(content),
|
lines: lineCount(content),
|
||||||
parent,
|
parent,
|
||||||
};
|
};
|
||||||
|
|
@ -269,19 +402,120 @@ export async function readClaudeJsonProjectSlice(repoPath) {
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enumerate all plugins installed under ~/.claude/plugins/marketplaces.
|
* Enumerate the plugins Claude Code actually injects for a repo.
|
||||||
* For each plugin: counts commands, agents, skills, hooks, rules; reads version from plugin.json.
|
|
||||||
*
|
*
|
||||||
|
* Authoritative source is `~/.claude/plugins/installed_plugins.json` (the install
|
||||||
|
* manifest) gated by the `enabledPlugins` toggle map. Only plugins that are both
|
||||||
|
* installed AND `enabledPlugins[key] === true` are injected, so only those are
|
||||||
|
* counted — each resolved to its ACTIVE `installPath`, which for polyrepo plugins
|
||||||
|
* lives under `plugins/cache` (never under `plugins/marketplaces`, so the historic
|
||||||
|
* marketplaces walk missed them entirely while also counting disabled/uninstalled
|
||||||
|
* marketplaces plugins). Mirrors file-discovery.mjs's "trust installed_plugins.json"
|
||||||
|
* contract: when the manifest is absent (test fixtures, pre-v2 installs) we cannot
|
||||||
|
* tell enabled from installed, so we fall back to discovering everything under
|
||||||
|
* `plugins/marketplaces` rather than silently dropping config. (M-BUG-1)
|
||||||
|
*
|
||||||
|
* @param {string} [repoPath] - when given, project/local-scoped installs and
|
||||||
|
* project-level `enabledPlugins` overrides are resolved relative to it; omit for
|
||||||
|
* HOME/global scope (only user-scope installs + user `enabledPlugins`).
|
||||||
* @returns {Promise<Array<{name:string, path:string, version:string|null, commands:number, agents:number, skills:number, hooks:number, rules:number, totalBytes:number, estimatedTokens:number}>>}
|
* @returns {Promise<Array<{name:string, path:string, version:string|null, commands:number, agents:number, skills:number, hooks:number, rules:number, totalBytes:number, estimatedTokens:number}>>}
|
||||||
*/
|
*/
|
||||||
export async function enumeratePlugins() {
|
export async function enumeratePlugins(repoPath) {
|
||||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||||
if (!home) return [];
|
if (!home) return [];
|
||||||
|
|
||||||
const marketplacesRoot = join(home, '.claude', 'plugins', 'marketplaces');
|
const installed = await readInstalledPluginsManifest(home);
|
||||||
const pluginRoots = await discoverAllPluginsUnder(marketplacesRoot);
|
|
||||||
|
|
||||||
// Dedupe via realpath (symlinks are common)
|
let pluginRoots;
|
||||||
|
if (installed) {
|
||||||
|
// Manifest present → inject only ENABLED plugins, from their active installPath.
|
||||||
|
const enabled = await readEnabledPluginsMap(home, repoPath);
|
||||||
|
pluginRoots = [];
|
||||||
|
for (const [key, recs] of Object.entries(installed)) {
|
||||||
|
if (enabled[key] !== true) continue; // not explicitly enabled → not injected
|
||||||
|
const rec = pickActivePluginRecord(recs, repoPath);
|
||||||
|
if (!rec || !rec.installPath) continue;
|
||||||
|
try {
|
||||||
|
await stat(rec.installPath); // skip enabled-but-missing installPaths
|
||||||
|
pluginRoots.push(rec.installPath);
|
||||||
|
} catch { /* installPath gone → not loadable */ }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No manifest → cannot tell enabled from installed → discover all on disk.
|
||||||
|
pluginRoots = await discoverAllPluginsUnder(join(home, '.claude', 'plugins', 'marketplaces'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildPluginRecords(pluginRoots);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the install manifest's `plugins` map ({ "name@marketplace": [record, …] }).
|
||||||
|
* Returns null when absent/unparseable so callers fall back to disk discovery.
|
||||||
|
*/
|
||||||
|
async function readInstalledPluginsManifest(home) {
|
||||||
|
const p = join(home, '.claude', 'plugins', 'installed_plugins.json');
|
||||||
|
let raw;
|
||||||
|
try { raw = await readFile(p, 'utf-8'); } catch { return null; }
|
||||||
|
const parsed = parseJson(raw);
|
||||||
|
if (!parsed || !parsed.plugins || typeof parsed.plugins !== 'object') return null;
|
||||||
|
return parsed.plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge the `enabledPlugins` toggle map across the scopes Claude Code reads:
|
||||||
|
* user settings.json, then (when repoPath given) project settings + local + the
|
||||||
|
* ~/.claude.json project slice. Later scopes override earlier ones.
|
||||||
|
*/
|
||||||
|
async function readEnabledPluginsMap(home, repoPath) {
|
||||||
|
const merged = {};
|
||||||
|
const sources = [join(home, '.claude', 'settings.json')];
|
||||||
|
if (repoPath) {
|
||||||
|
sources.push(join(repoPath, '.claude', 'settings.json'));
|
||||||
|
sources.push(join(repoPath, '.claude', 'settings.local.json'));
|
||||||
|
}
|
||||||
|
for (const s of sources) {
|
||||||
|
try {
|
||||||
|
const parsed = parseJson(await readFile(s, 'utf-8'));
|
||||||
|
if (parsed && parsed.enabledPlugins && typeof parsed.enabledPlugins === 'object') {
|
||||||
|
Object.assign(merged, parsed.enabledPlugins);
|
||||||
|
}
|
||||||
|
} catch { /* missing/unreadable scope */ }
|
||||||
|
}
|
||||||
|
if (repoPath) {
|
||||||
|
try {
|
||||||
|
const slice = await readClaudeJsonProjectSlice(repoPath);
|
||||||
|
if (slice && slice.enabledPlugins && typeof slice.enabledPlugins === 'object') {
|
||||||
|
Object.assign(merged, slice.enabledPlugins);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the applicable install record for a plugin. User-scope records apply
|
||||||
|
* everywhere; project/local-scope records only when repoPath is within their
|
||||||
|
* projectPath (so a project-scoped plugin never leaks into HOME/global scope).
|
||||||
|
*/
|
||||||
|
function pickActivePluginRecord(recs, repoPath) {
|
||||||
|
if (!Array.isArray(recs) || recs.length === 0) return null;
|
||||||
|
const applicable = recs.filter((r) => {
|
||||||
|
if (!r || !r.installPath) return false;
|
||||||
|
const scope = r.scope || 'user';
|
||||||
|
if (scope === 'user') return true;
|
||||||
|
if (!repoPath || !r.projectPath) return false;
|
||||||
|
const target = normalizePath(resolve(repoPath));
|
||||||
|
const pp = normalizePath(resolve(r.projectPath));
|
||||||
|
return target === pp || target.startsWith(pp + sep);
|
||||||
|
});
|
||||||
|
return applicable.find((r) => (r.scope || 'user') === 'user') || applicable[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build plugin records from a list of plugin root paths: dedupe via realpath,
|
||||||
|
* count items, read plugin.json name/version.
|
||||||
|
*/
|
||||||
|
async function buildPluginRecords(pluginRoots) {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const root of pluginRoots) {
|
for (const root of pluginRoots) {
|
||||||
|
|
@ -413,14 +647,20 @@ async function countPluginItems(pluginRoot) {
|
||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listMarkdownFiles(dir) {
|
async function listMarkdownFiles(dir, recursive = false) {
|
||||||
const out = [];
|
const out = [];
|
||||||
let entries;
|
let entries;
|
||||||
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return out; }
|
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return out; }
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
|
const full = join(dir, e.name);
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
// Opt-in recursion (M-BUG-3): CC scans agents dirs recursively, so agents
|
||||||
|
// organized into subfolders must be enumerated too. Other callers stay flat.
|
||||||
|
if (recursive) out.push(...await listMarkdownFiles(full, true));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!e.isFile()) continue;
|
if (!e.isFile()) continue;
|
||||||
if (!e.name.endsWith('.md')) continue;
|
if (!e.name.endsWith('.md')) continue;
|
||||||
const full = join(dir, e.name);
|
|
||||||
try {
|
try {
|
||||||
const s = await stat(full);
|
const s = await stat(full);
|
||||||
out.push({ path: full, size: s.size });
|
out.push({ path: full, size: s.size });
|
||||||
|
|
@ -499,6 +739,148 @@ export async function enumerateSkills(pluginList = []) {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Rules, agents, output styles (v5.6 Foundation enumeration)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** True when `v` is a non-empty, non-whitespace string (a usable frontmatter field). */
|
||||||
|
function hasText(v) {
|
||||||
|
return typeof v === 'string' && v.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the project/user/plugin directory list for a per-kind enumerator.
|
||||||
|
* Project + user dirs live under `.claude/<dir>`; plugins under each of the
|
||||||
|
* given subpaths relative to the plugin root.
|
||||||
|
*/
|
||||||
|
function configDirs(repoPath, pluginList, subdir, pluginSubdirs = [subdir]) {
|
||||||
|
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||||
|
const projectDir = join(repoPath, '.claude', subdir);
|
||||||
|
const userDir = home ? join(home, '.claude', subdir) : null;
|
||||||
|
const dirs = [];
|
||||||
|
// M-BUG-4: when repoPath === $HOME (the `manifest --global` self-scan), the
|
||||||
|
// project dir resolves to the same path as the user dir. Count it once, as
|
||||||
|
// user scope, instead of enumerating the same directory twice.
|
||||||
|
if (!(userDir && userDir === projectDir)) {
|
||||||
|
dirs.push({ dir: projectDir, source: 'project', pluginName: null });
|
||||||
|
}
|
||||||
|
if (userDir) dirs.push({ dir: userDir, source: 'user', pluginName: null });
|
||||||
|
for (const p of pluginList) {
|
||||||
|
for (const sub of pluginSubdirs) {
|
||||||
|
dirs.push({ dir: join(p.path, sub), source: 'plugin', pluginName: p.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerate rule files: `<repo>/.claude/rules/`, `~/.claude/rules/`, and each
|
||||||
|
* plugin's `rules/` + `.claude/rules/`. A rule is path-scoped when its
|
||||||
|
* frontmatter declares `paths:` (the only documented scoping field, V5) — which
|
||||||
|
* determines its load pattern (scoped = on-demand, unscoped = always, V1/V2/V6).
|
||||||
|
*
|
||||||
|
* @param {string} repoPath
|
||||||
|
* @param {Array<{name:string, path:string}>} [pluginList]
|
||||||
|
* @returns {Promise<Array<{name:string, source:string, pluginName:string|null, path:string, scoped:boolean, bytes:number, estimatedTokens:number, loadPattern:string, survivesCompaction:string, derivationConfidence:string}>>}
|
||||||
|
*/
|
||||||
|
export async function enumerateRules(repoPath, pluginList = []) {
|
||||||
|
const out = [];
|
||||||
|
const dirs = configDirs(repoPath, pluginList, 'rules', ['rules', join('.claude', 'rules')]);
|
||||||
|
for (const { dir, source, pluginName } of dirs) {
|
||||||
|
const files = await listMarkdownFiles(dir);
|
||||||
|
for (const f of files) {
|
||||||
|
let scoped = false;
|
||||||
|
try {
|
||||||
|
const content = await readFile(f.path, 'utf-8');
|
||||||
|
const { frontmatter } = parseFrontmatter(content);
|
||||||
|
scoped = !!(frontmatter && frontmatter.paths);
|
||||||
|
} catch { /* unreadable → treat as unscoped */ }
|
||||||
|
out.push({
|
||||||
|
name: basename(f.path),
|
||||||
|
source,
|
||||||
|
pluginName,
|
||||||
|
path: f.path,
|
||||||
|
scoped,
|
||||||
|
bytes: f.size,
|
||||||
|
estimatedTokens: estimateTokens(f.size, 'markdown'),
|
||||||
|
...deriveLoadPattern('rule', { scoped }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerate agent definitions: `<repo>/.claude/agents/`, `~/.claude/agents/`,
|
||||||
|
* and each plugin's `agents/`. Only name+description load for delegation each
|
||||||
|
* turn, so cost is estimated like other frontmatter-only sources.
|
||||||
|
*
|
||||||
|
* @param {string} repoPath
|
||||||
|
* @param {Array<{name:string, path:string}>} [pluginList]
|
||||||
|
* @returns {Promise<Array<{name:string, source:string, pluginName:string|null, path:string, bytes:number, estimatedTokens:number, loadPattern:string, survivesCompaction:string, derivationConfidence:string}>>}
|
||||||
|
*/
|
||||||
|
export async function enumerateAgents(repoPath, pluginList = []) {
|
||||||
|
const out = [];
|
||||||
|
const lp = deriveLoadPattern('agent');
|
||||||
|
const dirs = configDirs(repoPath, pluginList, 'agents');
|
||||||
|
for (const { dir, source, pluginName } of dirs) {
|
||||||
|
const files = await listMarkdownFiles(dir, true); // M-BUG-3: CC scans agents dirs recursively
|
||||||
|
for (const f of files) {
|
||||||
|
// M-BUG-5: CC registers a subagent only when its frontmatter declares both
|
||||||
|
// `name` and `description` (docs: identity comes only from `name`; both are
|
||||||
|
// required). Frontmatter-less / incomplete files are registration no-ops
|
||||||
|
// that cost zero always-loaded tokens — don't count them as agents.
|
||||||
|
let frontmatter;
|
||||||
|
try {
|
||||||
|
({ frontmatter } = parseFrontmatter(await readFile(f.path, 'utf-8')));
|
||||||
|
} catch { continue; }
|
||||||
|
if (!hasText(frontmatter && frontmatter.name) || !hasText(frontmatter && frontmatter.description)) continue;
|
||||||
|
out.push({
|
||||||
|
name: basename(f.path).replace(/\.md$/, ''),
|
||||||
|
source,
|
||||||
|
pluginName,
|
||||||
|
path: f.path,
|
||||||
|
bytes: f.size,
|
||||||
|
estimatedTokens: estimateTokens(f.size, 'frontmatter'),
|
||||||
|
...lp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerate output styles: `<repo>/.claude/output-styles/`,
|
||||||
|
* `~/.claude/output-styles/`, and each plugin's `output-styles/`. An output
|
||||||
|
* style modifies the system prompt and is re-sent every turn (V10, V12).
|
||||||
|
* Foundation only enumerates them; the `keep-coding-instructions` /
|
||||||
|
* `force-for-plugin` checks are the CA-OST scanner (v5.6 C).
|
||||||
|
*
|
||||||
|
* @param {string} repoPath
|
||||||
|
* @param {Array<{name:string, path:string}>} [pluginList]
|
||||||
|
* @returns {Promise<Array<{name:string, source:string, pluginName:string|null, path:string, bytes:number, estimatedTokens:number, loadPattern:string, survivesCompaction:string, derivationConfidence:string}>>}
|
||||||
|
*/
|
||||||
|
export async function enumerateOutputStyles(repoPath, pluginList = []) {
|
||||||
|
const out = [];
|
||||||
|
const lp = deriveLoadPattern('output-style');
|
||||||
|
const dirs = configDirs(repoPath, pluginList, 'output-styles');
|
||||||
|
for (const { dir, source, pluginName } of dirs) {
|
||||||
|
const files = await listMarkdownFiles(dir);
|
||||||
|
for (const f of files) {
|
||||||
|
out.push({
|
||||||
|
name: basename(f.path).replace(/\.md$/, ''),
|
||||||
|
source,
|
||||||
|
pluginName,
|
||||||
|
path: f.path,
|
||||||
|
bytes: f.size,
|
||||||
|
estimatedTokens: estimateTokens(f.size, 'markdown'),
|
||||||
|
...lp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Hooks (user + project + plugin)
|
// Hooks (user + project + plugin)
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -610,6 +992,7 @@ export async function readActiveMcpServers(repoPath, claudeJsonSlice = null, plu
|
||||||
toolCount,
|
toolCount,
|
||||||
toolCountUnknown: detected.toolCountUnknown,
|
toolCountUnknown: detected.toolCountUnknown,
|
||||||
estimatedTokens: estimateTokens(0, 'mcp', { toolCount: toolCount ?? 0 }),
|
estimatedTokens: estimateTokens(0, 'mcp', { toolCount: toolCount ?? 0 }),
|
||||||
|
alwaysLoad: def?.alwaysLoad === true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -639,6 +1022,7 @@ async function collectMcpFromFile(path, source, disabled, out, repoPath) {
|
||||||
toolCount,
|
toolCount,
|
||||||
toolCountUnknown: detected.toolCountUnknown,
|
toolCountUnknown: detected.toolCountUnknown,
|
||||||
estimatedTokens: estimateTokens(0, 'mcp', { toolCount: toolCount ?? 0 }),
|
estimatedTokens: estimateTokens(0, 'mcp', { toolCount: toolCount ?? 0 }),
|
||||||
|
alwaysLoad: def?.alwaysLoad === true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -837,15 +1221,18 @@ export async function readActiveConfig(repoPath, opts = {}) {
|
||||||
detectGitRoot(absRepoPath),
|
detectGitRoot(absRepoPath),
|
||||||
walkClaudeMdCascade(absRepoPath),
|
walkClaudeMdCascade(absRepoPath),
|
||||||
readClaudeJsonProjectSlice(absRepoPath),
|
readClaudeJsonProjectSlice(absRepoPath),
|
||||||
enumeratePlugins(),
|
enumeratePlugins(absRepoPath),
|
||||||
readSettingsCascade(absRepoPath),
|
readSettingsCascade(absRepoPath),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Skills depend on plugins
|
// Skills, hooks, MCP, and the v5.6 enumerations all depend on plugins
|
||||||
const [skills, hooks, mcpServers] = await Promise.all([
|
const [skills, hooks, mcpServers, rules, agents, outputStyles] = await Promise.all([
|
||||||
enumerateSkills(plugins),
|
enumerateSkills(plugins),
|
||||||
readActiveHooks(absRepoPath, plugins),
|
readActiveHooks(absRepoPath, plugins),
|
||||||
readActiveMcpServers(absRepoPath, claudeJsonSlice, plugins),
|
readActiveMcpServers(absRepoPath, claudeJsonSlice, plugins),
|
||||||
|
enumerateRules(absRepoPath, plugins),
|
||||||
|
enumerateAgents(absRepoPath, plugins),
|
||||||
|
enumerateOutputStyles(absRepoPath, plugins),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Totals
|
// Totals
|
||||||
|
|
@ -854,6 +1241,9 @@ export async function readActiveConfig(repoPath, opts = {}) {
|
||||||
skills: skills.length,
|
skills: skills.length,
|
||||||
mcpServers: mcpServers.length,
|
mcpServers: mcpServers.length,
|
||||||
hooks: hooks.length,
|
hooks: hooks.length,
|
||||||
|
rules: rules.length,
|
||||||
|
agents: agents.length,
|
||||||
|
outputStyles: outputStyles.length,
|
||||||
claudeMdFiles: claudeMd.files.length,
|
claudeMdFiles: claudeMd.files.length,
|
||||||
estimatedTokens: {
|
estimatedTokens: {
|
||||||
claudeMd: claudeMd.estimatedTokens,
|
claudeMd: claudeMd.estimatedTokens,
|
||||||
|
|
@ -861,6 +1251,9 @@ export async function readActiveConfig(repoPath, opts = {}) {
|
||||||
skills: skills.reduce((s, k) => s + k.estimatedTokens, 0),
|
skills: skills.reduce((s, k) => s + k.estimatedTokens, 0),
|
||||||
mcpServers: mcpServers.reduce((s, m) => s + m.estimatedTokens, 0),
|
mcpServers: mcpServers.reduce((s, m) => s + m.estimatedTokens, 0),
|
||||||
hooks: hooks.reduce((s, h) => s + h.estimatedTokens, 0),
|
hooks: hooks.reduce((s, h) => s + h.estimatedTokens, 0),
|
||||||
|
rules: rules.reduce((s, r) => s + r.estimatedTokens, 0),
|
||||||
|
agents: agents.reduce((s, a) => s + a.estimatedTokens, 0),
|
||||||
|
outputStyles: outputStyles.reduce((s, o) => s + o.estimatedTokens, 0),
|
||||||
grandTotal: 0,
|
grandTotal: 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -869,7 +1262,10 @@ export async function readActiveConfig(repoPath, opts = {}) {
|
||||||
totals.estimatedTokens.plugins +
|
totals.estimatedTokens.plugins +
|
||||||
totals.estimatedTokens.skills +
|
totals.estimatedTokens.skills +
|
||||||
totals.estimatedTokens.mcpServers +
|
totals.estimatedTokens.mcpServers +
|
||||||
totals.estimatedTokens.hooks;
|
totals.estimatedTokens.hooks +
|
||||||
|
totals.estimatedTokens.rules +
|
||||||
|
totals.estimatedTokens.agents +
|
||||||
|
totals.estimatedTokens.outputStyles;
|
||||||
|
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
|
|
||||||
|
|
@ -898,6 +1294,9 @@ export async function readActiveConfig(repoPath, opts = {}) {
|
||||||
skills,
|
skills,
|
||||||
mcpServers,
|
mcpServers,
|
||||||
hooks,
|
hooks,
|
||||||
|
rules,
|
||||||
|
agents,
|
||||||
|
outputStyles,
|
||||||
settings: { cascade: settingsCascade },
|
settings: { cascade: settingsCascade },
|
||||||
totals,
|
totals,
|
||||||
suggestDisables,
|
suggestDisables,
|
||||||
|
|
|
||||||
51
scanners/lib/active-model.mjs
Normal file
51
scanners/lib/active-model.mjs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
/**
|
||||||
|
* Active-model resolution for the `--context-window auto` probe (B8b).
|
||||||
|
*
|
||||||
|
* Reads the configured model the way Claude Code itself resolves it, so the
|
||||||
|
* window probe (context-window.mjs `modelToContextWindow`) sees the real model:
|
||||||
|
* 1. the shell `ANTHROPIC_MODEL` override (applies to the launched session);
|
||||||
|
* 2. otherwise the settings cascade `model` field — user `~/.claude`, then
|
||||||
|
* project `.claude`, then project-local `.claude` (local > project > user).
|
||||||
|
*
|
||||||
|
* Reads the cascade files directly (like isBundledSkillsDisabled) rather than via
|
||||||
|
* config-discovery classification, and takes an injectable `env` so it is
|
||||||
|
* deterministic and hermetic under the test HOME. Returns null when no model is
|
||||||
|
* pinned anywhere — the honest signal that `auto` must fall back to advisory.
|
||||||
|
*
|
||||||
|
* Zero external dependencies (repo invariant).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { readTextFile } from './file-discovery.mjs';
|
||||||
|
import { parseJson } from './yaml-parser.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string|null|undefined} projectPath - project root, to also read project + local settings
|
||||||
|
* @param {{ env?: Record<string,string|undefined> }} [opts]
|
||||||
|
* @returns {Promise<string|null>} the resolved model id/alias, or null if unset
|
||||||
|
*/
|
||||||
|
export async function resolveActiveModel(projectPath, { env = process.env } = {}) {
|
||||||
|
// 1. Shell ANTHROPIC_MODEL overrides settings (CC: applies to the session).
|
||||||
|
const envModel = typeof env?.ANTHROPIC_MODEL === 'string' ? env.ANTHROPIC_MODEL.trim() : '';
|
||||||
|
if (envModel) return envModel;
|
||||||
|
|
||||||
|
// 2. Settings cascade: user -> project -> project-local, later wins.
|
||||||
|
const home = (env && (env.HOME || env.USERPROFILE)) || '';
|
||||||
|
const candidates = [];
|
||||||
|
if (home) candidates.push(join(home, '.claude', 'settings.json'));
|
||||||
|
if (projectPath) {
|
||||||
|
candidates.push(join(projectPath, '.claude', 'settings.json'));
|
||||||
|
candidates.push(join(projectPath, '.claude', 'settings.local.json'));
|
||||||
|
}
|
||||||
|
|
||||||
|
let model = null;
|
||||||
|
for (const p of candidates) {
|
||||||
|
const content = await readTextFile(p);
|
||||||
|
if (!content) continue;
|
||||||
|
const parsed = parseJson(content);
|
||||||
|
if (parsed && typeof parsed.model === 'string' && parsed.model.trim()) {
|
||||||
|
model = parsed.model.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
}
|
||||||
132
scanners/lib/agent-listing-budget.mjs
Normal file
132
scanners/lib/agent-listing-budget.mjs
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
/**
|
||||||
|
* Agent-listing budget — single source of truth for the AGT scanner.
|
||||||
|
*
|
||||||
|
* Claude Code injects a listing of every active agent's name+description into the
|
||||||
|
* system prompt so the model knows which subagents it can delegate to. With many
|
||||||
|
* installed agents that listing is a large always-loaded cost: re-sent every turn
|
||||||
|
* whether or not a delegation actually happens.
|
||||||
|
*
|
||||||
|
* CRUCIAL HONESTY CAVEAT — this is why AGT differs from SKL. Unlike the skill
|
||||||
|
* listing (documented ~2% allotment, CC 2.1.32; verified 1,536-char truncation
|
||||||
|
* cap, CC 2.1.105), the agent-listing mechanism is NOT documented — agents are
|
||||||
|
* absent from Claude Code's published context breakdown. Therefore:
|
||||||
|
* - "always-loaded" is INFERRED (reasoned from the skill analogue + agents'
|
||||||
|
* absence from the deferred/on-demand list), not documented.
|
||||||
|
* - the token figure is an UPPER-BOUND ESTIMATE, not measured telemetry.
|
||||||
|
* - there is no documented per-listing allotment, so the aggregate budget is a
|
||||||
|
* config-audit heuristic anchored — by analogy to the skill listing — on a
|
||||||
|
* conservative 200k window, and the evidence says so loudly.
|
||||||
|
* - there is no verified per-description truncation cap, so (unlike CA-SKL-001)
|
||||||
|
* each description contributes its FULL length to the aggregate.
|
||||||
|
*
|
||||||
|
* Zero external dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { estimateTokens, enumeratePlugins, enumerateAgents } from './active-config-reader.mjs';
|
||||||
|
import { readTextFile } from './file-discovery.mjs';
|
||||||
|
import { parseFrontmatter } from './yaml-parser.mjs';
|
||||||
|
import { CONTEXT_WINDOW_ANCHOR, LARGE_CONTEXT_WINDOW, withCommas } from './context-window.mjs';
|
||||||
|
|
||||||
|
// Heuristic budget by analogy to the skill listing's documented ~2% allotment.
|
||||||
|
// Agents have NO documented allotment of their own — disclosed loudly in
|
||||||
|
// BUDGET_CALIBRATION_NOTE so the number is never mistaken for a CC guarantee.
|
||||||
|
export const BUDGET_FRACTION = 0.02;
|
||||||
|
export const AGGREGATE_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * CONTEXT_WINDOW_ANCHOR); // 4000
|
||||||
|
export const LARGE_CONTEXT_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * LARGE_CONTEXT_WINDOW); // 20000
|
||||||
|
export { CONTEXT_WINDOW_ANCHOR, LARGE_CONTEXT_WINDOW, withCommas };
|
||||||
|
|
||||||
|
// Per-agent soft cap for the description-bloat advisory. This is a HEURISTIC,
|
||||||
|
// NOT a truncation cap: agents have no verified per-description limit (unlike
|
||||||
|
// the skill listing's 1,536-char cap, CC 2.1.105). We reuse the 500-char
|
||||||
|
// bloat threshold TOK pattern F already applies to SKILL.md descriptions, so a
|
||||||
|
// long agent description is flagged for the same reason — every char re-enters
|
||||||
|
// context in the always-loaded agent listing on every turn — without ever
|
||||||
|
// claiming Claude Code drops the tail.
|
||||||
|
export const PER_AGENT_DESC_SOFT_CAP = 500;
|
||||||
|
|
||||||
|
// The honest framing required because (a) the mechanism is inferred and (b) the
|
||||||
|
// budget depends on a context window we cannot observe. Appended to overflow evidence.
|
||||||
|
export const BUDGET_CALIBRATION_NOTE =
|
||||||
|
'the agent-listing always-loaded mechanism is INFERRED, not documented (agents are absent from ' +
|
||||||
|
"Claude Code's published context breakdown), so this token figure is an UPPER-BOUND ESTIMATE, not " +
|
||||||
|
'measured telemetry. unlike the skill listing there is no documented per-listing allotment, so this ' +
|
||||||
|
'budget is a config-audit heuristic anchored on a conservative 200k window; at ' +
|
||||||
|
`${withCommas(LARGE_CONTEXT_WINDOW)} context the budget is ~${withCommas(LARGE_CONTEXT_BUDGET_TOKENS)} ` +
|
||||||
|
'tok and you are likely within it';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} AgentBudgetAssessment
|
||||||
|
* @property {number} scanned - number of agent descriptions assessed
|
||||||
|
* @property {number} aggregateChars - sum of description lengths (no cap; no verified truncation)
|
||||||
|
* @property {number} aggregateTokens - estimateTokens(aggregateChars, 'markdown')
|
||||||
|
* @property {number} budgetTokens - AGGREGATE_BUDGET_TOKENS (the 200k-anchored heuristic)
|
||||||
|
* @property {boolean} overBudget - aggregateTokens strictly greater than budgetTokens
|
||||||
|
* @property {number} overBy - tokens over budget (0 when not over)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure aggregate-budget assessment. Each description contributes its full length:
|
||||||
|
* agents have no verified truncation cap, so nothing is dropped from the estimate.
|
||||||
|
*
|
||||||
|
* @param {number[]} descLengths - one entry per active agent (description char count)
|
||||||
|
* @returns {AgentBudgetAssessment}
|
||||||
|
*/
|
||||||
|
export function assessAgentListingBudget(descLengths) {
|
||||||
|
let aggregateChars = 0;
|
||||||
|
for (const len of descLengths) {
|
||||||
|
const safe = (typeof len === 'number' && Number.isFinite(len) && len > 0) ? len : 0;
|
||||||
|
aggregateChars += safe;
|
||||||
|
}
|
||||||
|
const aggregateTokens = estimateTokens(aggregateChars, 'markdown');
|
||||||
|
const overBudget = aggregateTokens > AGGREGATE_BUDGET_TOKENS;
|
||||||
|
return {
|
||||||
|
scanned: descLengths.length,
|
||||||
|
aggregateChars,
|
||||||
|
aggregateTokens,
|
||||||
|
budgetTokens: AGGREGATE_BUDGET_TOKENS,
|
||||||
|
overBudget,
|
||||||
|
overBy: overBudget ? aggregateTokens - AGGREGATE_BUDGET_TOKENS : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} ActiveAgentEntry
|
||||||
|
* @property {string} name
|
||||||
|
* @property {'user'|'plugin'} source
|
||||||
|
* @property {string|null} pluginName
|
||||||
|
* @property {string} path
|
||||||
|
* @property {number} descLength
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerate every active agent (user + plugin) and measure the listing budget.
|
||||||
|
* HOME-scoped (mirrors the skill listing): resolves ~/.claude via process.env.HOME
|
||||||
|
* and excludes project-scoped agents — those load only inside their own repo.
|
||||||
|
* Callers that run under test MUST override HOME (runScannerWithHome pattern).
|
||||||
|
*
|
||||||
|
* @returns {Promise<{ agents: ActiveAgentEntry[], aggregate: AgentBudgetAssessment }>}
|
||||||
|
*/
|
||||||
|
export async function measureActiveAgentListing() {
|
||||||
|
const plugins = await enumeratePlugins();
|
||||||
|
// enumerateAgents yields project + user + plugin; drop project (repo-local).
|
||||||
|
const allAgents = await enumerateAgents('', plugins);
|
||||||
|
|
||||||
|
const agents = [];
|
||||||
|
for (const agent of allAgents) {
|
||||||
|
if (!agent || agent.source === 'project' || typeof agent.path !== 'string') continue;
|
||||||
|
const content = await readTextFile(agent.path);
|
||||||
|
if (!content) continue;
|
||||||
|
const fm = parseFrontmatter(content)?.frontmatter || null;
|
||||||
|
const desc = (fm && typeof fm.description === 'string') ? fm.description : '';
|
||||||
|
agents.push({
|
||||||
|
name: agent.name,
|
||||||
|
source: agent.source,
|
||||||
|
pluginName: agent.pluginName,
|
||||||
|
path: agent.path,
|
||||||
|
descLength: desc.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregate = assessAgentListingBudget(agents.map((a) => a.descLength));
|
||||||
|
return { agents, aggregate };
|
||||||
|
}
|
||||||
113
scanners/lib/best-practices-register.mjs
Normal file
113
scanners/lib/best-practices-register.mjs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
/**
|
||||||
|
* best-practices-register — loader + schema validator for the machine-readable
|
||||||
|
* best-practices register (knowledge/best-practices.json).
|
||||||
|
*
|
||||||
|
* The register is the SOURCE OF TRUTH for the v5.7 optimization lens (CA-OPT). Each entry
|
||||||
|
* is provenance-stamped (source.url + source.verified) and carries a confidence; only
|
||||||
|
* CONFIRMED claims are surfaced user-facing (Verifiseringsplikt). Zero-dependency: native
|
||||||
|
* JSON, validated by hand here. See docs/v5.7-optimization-lens-plan.md.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { SEVERITY } from './severity.mjs';
|
||||||
|
|
||||||
|
/** Absolute path to the bundled register. */
|
||||||
|
export const REGISTER_PATH = fileURLToPath(
|
||||||
|
new URL('../../knowledge/best-practices.json', import.meta.url)
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Confidence levels. Only `confirmed` is consumed user-facing by the lens. */
|
||||||
|
export const CONFIDENCE_LEVELS = Object.freeze(['confirmed', 'inferred', 'unverified']);
|
||||||
|
|
||||||
|
const VALID_SEVERITIES = new Set(Object.keys(SEVERITY));
|
||||||
|
const ID_RE = /^BP-[A-Z]+-\d{3}$/;
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load + parse a register file (defaults to the bundled one). Throws on missing file or
|
||||||
|
* invalid JSON — callers that want graceful handling should try/catch.
|
||||||
|
* @param {string} [path]
|
||||||
|
* @returns {{version:number, entries:object[]}}
|
||||||
|
*/
|
||||||
|
export function loadRegister(path = REGISTER_PATH) {
|
||||||
|
return JSON.parse(readFileSync(path, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a parsed register against the schema. Never throws — returns a result so the
|
||||||
|
* caller (and tests) can inspect every problem at once.
|
||||||
|
* @param {unknown} data
|
||||||
|
* @returns {{valid:boolean, errors:string[]}}
|
||||||
|
*/
|
||||||
|
export function validateRegister(data) {
|
||||||
|
const errors = [];
|
||||||
|
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||||
|
return { valid: false, errors: ['register must be an object'] };
|
||||||
|
}
|
||||||
|
if (typeof data.version !== 'number') errors.push('version must be a number');
|
||||||
|
if (!Array.isArray(data.entries)) {
|
||||||
|
errors.push('entries must be an array');
|
||||||
|
return { valid: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
data.entries.forEach((e, i) => {
|
||||||
|
const at = `entry[${i}]${e && typeof e === 'object' && e.id ? ` (${e.id})` : ''}`;
|
||||||
|
if (!e || typeof e !== 'object' || Array.isArray(e)) {
|
||||||
|
errors.push(`${at}: must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// id — required, BP-TOPIC-NNN, unique
|
||||||
|
if (typeof e.id !== 'string' || !ID_RE.test(e.id)) {
|
||||||
|
errors.push(`${at}: id must match BP-TOPIC-NNN`);
|
||||||
|
} else if (seen.has(e.id)) {
|
||||||
|
errors.push(`${at}: duplicate id`);
|
||||||
|
} else {
|
||||||
|
seen.add(e.id);
|
||||||
|
}
|
||||||
|
// claim — required, non-empty
|
||||||
|
if (typeof e.claim !== 'string' || e.claim.trim() === '') {
|
||||||
|
errors.push(`${at}: claim is required`);
|
||||||
|
}
|
||||||
|
// confidence — required, enum
|
||||||
|
if (!CONFIDENCE_LEVELS.includes(e.confidence)) {
|
||||||
|
errors.push(`${at}: confidence must be one of ${CONFIDENCE_LEVELS.join('|')}`);
|
||||||
|
}
|
||||||
|
// source — required object with url + verified date (provenance)
|
||||||
|
if (!e.source || typeof e.source !== 'object' || Array.isArray(e.source)) {
|
||||||
|
errors.push(`${at}: source is required`);
|
||||||
|
} else {
|
||||||
|
if (typeof e.source.url !== 'string' || e.source.url.trim() === '') {
|
||||||
|
errors.push(`${at}: source.url is required`);
|
||||||
|
}
|
||||||
|
if (typeof e.source.verified !== 'string' || !DATE_RE.test(e.source.verified)) {
|
||||||
|
errors.push(`${at}: source.verified must be a YYYY-MM-DD date`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// optional fields — typed only when present
|
||||||
|
if (e.severity !== undefined && !VALID_SEVERITIES.has(e.severity)) {
|
||||||
|
errors.push(`${at}: severity must be one of ${[...VALID_SEVERITIES].join('|')}`);
|
||||||
|
}
|
||||||
|
for (const key of ['mechanism', 'appliesTo', 'recommendation', 'category']) {
|
||||||
|
if (e[key] !== undefined && typeof e[key] !== 'string') {
|
||||||
|
errors.push(`${at}: ${key} must be a string`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.lensCheck !== undefined && e.lensCheck !== null && typeof e.lensCheck !== 'string') {
|
||||||
|
errors.push(`${at}: lensCheck must be a string or null`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up an entry by id.
|
||||||
|
* @param {{entries:object[]}} register
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {object|undefined}
|
||||||
|
*/
|
||||||
|
export function getEntry(register, id) {
|
||||||
|
if (!register || !Array.isArray(register.entries)) return undefined;
|
||||||
|
return register.entries.find((e) => e.id === id);
|
||||||
|
}
|
||||||
78
scanners/lib/campaign-export.mjs
Normal file
78
scanners/lib/campaign-export.mjs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
/**
|
||||||
|
* campaign-export — plan-export transforms (v5.7 Fase 2, Block 4c).
|
||||||
|
*
|
||||||
|
* The second half of Block 4 ("durable backlog + execution"). Block 4b built the cross-repo
|
||||||
|
* prioritized backlog the user picks from; this exports a picked repo's per-repo action plan
|
||||||
|
* into the TARGET repo's OWN `docs/` directory, so the plan gets a durable, human-readable
|
||||||
|
* home where the work is done ("planer følger arbeidsstedet" — the operator's continuity rule
|
||||||
|
* that plans live next to the workplace, in `docs/`).
|
||||||
|
*
|
||||||
|
* Design mirrors campaign-ledger: PURE, deterministic transforms — `now` is injected as a
|
||||||
|
* YYYY-MM-DD string, never read from the clock here, so they are fully unit-testable. The IO
|
||||||
|
* (loading the ledger, reading the session's action-plan.md, writing the exported file) lives
|
||||||
|
* in the thin `campaign-export-cli` shell. The transforms throw on programmer error
|
||||||
|
* (missing/blank required field), consistent with the ledger transforms.
|
||||||
|
*
|
||||||
|
* NOTE on execution: Block 4c deliberately adds NO new execution machinery. Execution reuses
|
||||||
|
* the existing per-repo `/config-audit implement` (which backs up every changed file, applies
|
||||||
|
* the plan from the session, and verifies) + `/config-audit rollback`. The exported `docs/`
|
||||||
|
* copy is the repo's durable record of the plan, NOT the execution input — `implement` still
|
||||||
|
* reads the canonical plan from the session directory. See docs/v5.7-optimization-lens-plan.md
|
||||||
|
* §Fase 2 (Block 4).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exported plan's destination inside the TARGET repo's own `docs/`. Keyed on the source
|
||||||
|
* `sessionId` (timestamp-unique per audit) rather than the calendar date, so two audits of the
|
||||||
|
* same repo on the same day produce distinct files (history is preserved, never silently
|
||||||
|
* overwritten) and the filename ties the export back to the audit that produced it.
|
||||||
|
*
|
||||||
|
* @param {string} repoPath - absolute path to the target repo (the ledger stores it resolved)
|
||||||
|
* @param {string} sessionId - the config-audit session that produced the plan
|
||||||
|
* @returns {string} `<repoPath>/docs/config-audit-plan-<sessionId>.md`
|
||||||
|
*/
|
||||||
|
export function planExportPath(repoPath, sessionId) {
|
||||||
|
if (typeof repoPath !== 'string' || repoPath.trim() === '') {
|
||||||
|
throw new TypeError('repoPath is required');
|
||||||
|
}
|
||||||
|
if (typeof sessionId !== 'string' || sessionId.trim() === '') {
|
||||||
|
throw new TypeError('sessionId is required');
|
||||||
|
}
|
||||||
|
return join(repoPath, 'docs', `config-audit-plan-${sessionId}.md`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble the exported document: a provenance header (who/when/where this came from + how to
|
||||||
|
* execute and undo it) followed by the verbatim session plan body. Pure — given the same inputs
|
||||||
|
* it always produces the same bytes, so it is snapshot-testable.
|
||||||
|
*
|
||||||
|
* @param {{repoName:string, repoPath:string, sessionId:string, planMarkdown:string, now:string}} input
|
||||||
|
* @returns {string} the full markdown to write into the repo's docs/
|
||||||
|
*/
|
||||||
|
export function buildPlanExportDocument({ repoName, repoPath, sessionId, planMarkdown, now } = {}) {
|
||||||
|
for (const [k, v] of Object.entries({ repoName, repoPath, sessionId, planMarkdown, now })) {
|
||||||
|
if (typeof v !== 'string' || v.trim() === '') {
|
||||||
|
throw new TypeError(`${k} is required`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = [
|
||||||
|
`# Config-Audit Action Plan — ${repoName}`,
|
||||||
|
'',
|
||||||
|
`> Exported from the config-audit machine-wide campaign on ${now}.`,
|
||||||
|
`> **Repo:** \`${repoPath}\``,
|
||||||
|
`> **Source session:** \`${sessionId}\``,
|
||||||
|
'>',
|
||||||
|
'> Generated by `/config-audit plan`. To **execute**: run `/config-audit implement` in this',
|
||||||
|
'> repo — it backs up every changed file, applies the plan, then verifies the result. To',
|
||||||
|
'> **undo**: `/config-audit rollback`. Record progress back in the campaign with',
|
||||||
|
`> \`/config-audit campaign set-status ${repoPath} implemented\`.`,
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
return `${header}${planMarkdown.trimEnd()}\n`;
|
||||||
|
}
|
||||||
369
scanners/lib/campaign-ledger.mjs
Normal file
369
scanners/lib/campaign-ledger.mjs
Normal file
|
|
@ -0,0 +1,369 @@
|
||||||
|
/**
|
||||||
|
* campaign-ledger — durable, machine-wide campaign ledger (v5.7 Fase 2, Block 3a THIN).
|
||||||
|
*
|
||||||
|
* The ledger sits ABOVE individual config-audit sessions: it tracks which repos are part
|
||||||
|
* of a machine-wide audit campaign, each repo's lifecycle status (pending → audited →
|
||||||
|
* planned → implemented), and a machine-wide roll-up by status + severity. It is resumable
|
||||||
|
* across sessions because it persists to a single JSON file OUTSIDE the plugin dir
|
||||||
|
* (`~/.claude/config-audit/campaign-ledger.json`, next to `sessions/` and `mcp-cache/`),
|
||||||
|
* so it survives plugin uninstall/reinstall/upgrade.
|
||||||
|
*
|
||||||
|
* Design mirrors the knowledge-refresh precedent: the transformations are PURE and
|
||||||
|
* deterministic (every "now" is injected as a YYYY-MM-DD string, never read from the clock
|
||||||
|
* here), so they are fully unit-testable; a thin IO shell (load/save, explicit path) does
|
||||||
|
* the only filesystem work. `validateLedger` is soft (returns a result, never throws) for
|
||||||
|
* externally-loaded data; the transforms throw on programmer error (invalid status, unknown
|
||||||
|
* path). `schemaVersion` is stamped from the start so a future Block 4 migration is cheap.
|
||||||
|
*
|
||||||
|
* THIN scope (Block 3a): ledger core + roll-up + persistence only — NOT execution,
|
||||||
|
* orchestration, or a command surface (those are Blocks 3b/3c/4). Zero dependencies.
|
||||||
|
* See docs/v5.7-optimization-lens-plan.md §Fase 2.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
/** Ledger schema version — bump + add a migration (Block 4) on any breaking shape change. */
|
||||||
|
export const CAMPAIGN_SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
/** Per-repo lifecycle, in order. A repo advances through these as the campaign progresses. */
|
||||||
|
export const STATUSES = Object.freeze(['pending', 'audited', 'planned', 'implemented']);
|
||||||
|
|
||||||
|
/** Severity buckets aggregated by the machine-wide roll-up. */
|
||||||
|
const SEVERITIES = Object.freeze(['critical', 'high', 'medium', 'low']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order-of-magnitude severity weights for the cross-repo backlog priority score. Each tier
|
||||||
|
* dominates the next so a single higher-severity finding outranks many lower ones; exact
|
||||||
|
* score collisions are still broken deterministically by the lexicographic + name tie-break
|
||||||
|
* in `buildBacklog`. Exported so the score is documented, not a magic number.
|
||||||
|
*/
|
||||||
|
export const SEVERITY_WEIGHTS = Object.freeze({ critical: 1000, high: 100, medium: 10, low: 1 });
|
||||||
|
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
/** Validate an injected `now` (required, YYYY-MM-DD). Throws — callers pass today's date. */
|
||||||
|
function requireNow(now) {
|
||||||
|
if (typeof now !== 'string' || !DATE_RE.test(now)) {
|
||||||
|
throw new TypeError('now must be a YYYY-MM-DD string');
|
||||||
|
}
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonicalize a repo path so the same repo never appears twice under different spellings. */
|
||||||
|
function normalizePath(path) {
|
||||||
|
if (typeof path !== 'string' || path.trim() === '') {
|
||||||
|
throw new TypeError('repo path is required');
|
||||||
|
}
|
||||||
|
return resolve(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an empty, versioned ledger stamped with `now`.
|
||||||
|
* @param {{now: string}} opts
|
||||||
|
* @returns {{schemaVersion:number, createdDate:string, updatedDate:string, repos:object[]}}
|
||||||
|
*/
|
||||||
|
export function createLedger({ now } = {}) {
|
||||||
|
requireNow(now);
|
||||||
|
return { schemaVersion: CAMPAIGN_SCHEMA_VERSION, createdDate: now, updatedDate: now, repos: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a repo to the campaign (status `pending`). Idempotent on the normalized path — a repo
|
||||||
|
* already present is left untouched (its progress is NOT reset). Returns a NEW ledger.
|
||||||
|
* @param {object} ledger
|
||||||
|
* @param {{path: string, name?: string}} repo
|
||||||
|
* @param {{now: string}} opts
|
||||||
|
*/
|
||||||
|
export function addRepo(ledger, { path, name } = {}, { now } = {}) {
|
||||||
|
requireNow(now);
|
||||||
|
const resolved = normalizePath(path);
|
||||||
|
if (ledger.repos.some((r) => r.path === resolved)) {
|
||||||
|
return ledger; // idempotent: already tracked, preserve its status
|
||||||
|
}
|
||||||
|
const repo = {
|
||||||
|
path: resolved,
|
||||||
|
name: typeof name === 'string' && name.trim() !== '' ? name : resolved.split('/').pop(),
|
||||||
|
status: 'pending',
|
||||||
|
sessionId: null,
|
||||||
|
findingsBySeverity: null,
|
||||||
|
tokens: null,
|
||||||
|
updatedDate: now,
|
||||||
|
};
|
||||||
|
return { ...ledger, updatedDate: now, repos: [...ledger.repos, repo] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition a tracked repo to a new status, optionally attaching the audit's
|
||||||
|
* findings-by-severity and the producing sessionId. Returns a NEW ledger.
|
||||||
|
* @param {object} ledger
|
||||||
|
* @param {string} path
|
||||||
|
* @param {string} status - one of STATUSES
|
||||||
|
* @param {{now: string, findingsBySeverity?: object|null, sessionId?: string|null}} opts
|
||||||
|
*/
|
||||||
|
export function setRepoStatus(ledger, path, status, { now, findingsBySeverity, sessionId } = {}) {
|
||||||
|
requireNow(now);
|
||||||
|
if (!STATUSES.includes(status)) {
|
||||||
|
throw new RangeError(`invalid status "${status}" — must be one of ${STATUSES.join(', ')}`);
|
||||||
|
}
|
||||||
|
const resolved = normalizePath(path);
|
||||||
|
const idx = ledger.repos.findIndex((r) => r.path === resolved);
|
||||||
|
if (idx === -1) {
|
||||||
|
throw new Error(`repo "${resolved}" is not in the ledger — addRepo first`);
|
||||||
|
}
|
||||||
|
const updated = { ...ledger.repos[idx], status, updatedDate: now };
|
||||||
|
if (findingsBySeverity !== undefined) updated.findingsBySeverity = findingsBySeverity;
|
||||||
|
if (sessionId !== undefined) updated.sessionId = sessionId;
|
||||||
|
const repos = ledger.repos.slice();
|
||||||
|
repos[idx] = updated;
|
||||||
|
return { ...ledger, updatedDate: now, repos };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load-pattern buckets carried by a token summary (manifest `summarizeByLoadPattern` shape). */
|
||||||
|
const LOAD_PATTERNS = Object.freeze(['always', 'onDemand', 'external', 'unknown']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the machine-wide SHARED global always-loaded layer (global CLAUDE.md + agent listing +
|
||||||
|
* global MCP + unscoped global rules + active plugins' always-loaded components). Stored ONCE
|
||||||
|
* at the ledger root — never per repo — so the machine-wide roll-up counts it exactly once
|
||||||
|
* (the structural guard against the historic double-count). The `summary` is the shape
|
||||||
|
* manifest's `summarizeByLoadPattern` emits: `{always|onDemand|external|unknown: {tokens,count}}`.
|
||||||
|
* Returns a NEW ledger. (B2b populates this from a live cross-repo sweep.)
|
||||||
|
* @param {object} ledger
|
||||||
|
* @param {object} summary
|
||||||
|
* @param {{now: string}} opts
|
||||||
|
*/
|
||||||
|
export function setSharedGlobal(ledger, summary, { now } = {}) {
|
||||||
|
requireNow(now);
|
||||||
|
return { ...ledger, updatedDate: now, sharedGlobal: summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach a tracked repo's PER-REPO always-loaded token delta (its project-scoped contribution
|
||||||
|
* beyond the shared global layer — project CLAUDE.md / rules / agents / MCP). Same `summary`
|
||||||
|
* shape as `setSharedGlobal`. Mirrors `setRepoStatus`: throws if the repo is untracked. Returns
|
||||||
|
* a NEW ledger.
|
||||||
|
* @param {object} ledger
|
||||||
|
* @param {string} path
|
||||||
|
* @param {object} tokens
|
||||||
|
* @param {{now: string}} opts
|
||||||
|
*/
|
||||||
|
export function setRepoTokens(ledger, path, tokens, { now } = {}) {
|
||||||
|
requireNow(now);
|
||||||
|
const resolved = normalizePath(path);
|
||||||
|
const idx = ledger.repos.findIndex((r) => r.path === resolved);
|
||||||
|
if (idx === -1) {
|
||||||
|
throw new Error(`repo "${resolved}" is not in the ledger — addRepo first`);
|
||||||
|
}
|
||||||
|
const repos = ledger.repos.slice();
|
||||||
|
repos[idx] = { ...ledger.repos[idx], tokens, updatedDate: now };
|
||||||
|
return { ...ledger, updatedDate: now, repos };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tolerantly read the four load-pattern token numbers from a stored summary (or null/old data). */
|
||||||
|
function bucketTokens(summary) {
|
||||||
|
const out = {};
|
||||||
|
for (const k of LOAD_PATTERNS) {
|
||||||
|
const v = summary && summary[k];
|
||||||
|
out[k] = v && typeof v.tokens === 'number' ? v.tokens : 0;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Machine-wide roll-up: repo counts by status, a severity total across every repo carrying
|
||||||
|
* `findingsBySeverity`, AND a machine-wide always-loaded token bill. The token aggregate adds
|
||||||
|
* the SHARED global layer (counted once, from the ledger root) to the SUM of per-repo deltas,
|
||||||
|
* so `tokens.machineWide` is the honest "context spent every turn across the whole machine".
|
||||||
|
* Pure derivation — never mutates; tolerant of old ledgers without token fields.
|
||||||
|
* @param {object} ledger
|
||||||
|
* @returns {{totalRepos:number, byStatus:object, bySeverity:object, reposWithFindings:number, tokens:object}}
|
||||||
|
*/
|
||||||
|
export function rollUp(ledger) {
|
||||||
|
const byStatus = Object.fromEntries(STATUSES.map((s) => [s, 0]));
|
||||||
|
const bySeverity = Object.fromEntries(SEVERITIES.map((s) => [s, 0]));
|
||||||
|
let reposWithFindings = 0;
|
||||||
|
|
||||||
|
const sharedGlobal = bucketTokens(ledger.sharedGlobal);
|
||||||
|
const perRepoDelta = Object.fromEntries(LOAD_PATTERNS.map((k) => [k, 0]));
|
||||||
|
const byRepo = [];
|
||||||
|
let reposWithTokens = 0;
|
||||||
|
|
||||||
|
for (const repo of ledger.repos) {
|
||||||
|
if (byStatus[repo.status] !== undefined) byStatus[repo.status] += 1;
|
||||||
|
const f = repo.findingsBySeverity;
|
||||||
|
if (f && typeof f === 'object') {
|
||||||
|
reposWithFindings += 1;
|
||||||
|
for (const sev of SEVERITIES) {
|
||||||
|
if (typeof f[sev] === 'number') bySeverity[sev] += f[sev];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (repo.tokens && typeof repo.tokens === 'object') {
|
||||||
|
reposWithTokens += 1;
|
||||||
|
const b = bucketTokens(repo.tokens);
|
||||||
|
for (const k of LOAD_PATTERNS) perRepoDelta[k] += b[k];
|
||||||
|
byRepo.push({ name: repo.name, path: repo.path, always: b.always, onDemand: b.onDemand, external: b.external });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DESC by always-loaded cost ("most expensive repos"); deterministic name tie-break.
|
||||||
|
byRepo.sort((x, y) => y.always - x.always || x.name.localeCompare(y.name));
|
||||||
|
|
||||||
|
const machineWide = Object.fromEntries(
|
||||||
|
LOAD_PATTERNS.map((k) => [k, sharedGlobal[k] + perRepoDelta[k]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalRepos: ledger.repos.length,
|
||||||
|
byStatus,
|
||||||
|
bySeverity,
|
||||||
|
reposWithFindings,
|
||||||
|
tokens: { sharedGlobal, perRepoDelta, machineWide, reposWithTokens, byRepo },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the single, machine-wide PRIORITIZED backlog the user picks from. Pure derivation —
|
||||||
|
* never mutates. The actionable unit is a REPO (the ledger tracks per-repo severity counts,
|
||||||
|
* not individual findings — it tracks state, it does not re-run audits), so each backlog item
|
||||||
|
* is one repo with outstanding work.
|
||||||
|
*
|
||||||
|
* Inclusion: a repo is in the backlog iff it is NOT yet `implemented` AND has at least one
|
||||||
|
* outstanding finding (`totalFindings > 0`). `implemented` repos are done; `pending` and
|
||||||
|
* zero-finding repos have nothing known to fix (they still surface in `rollUp.byStatus`).
|
||||||
|
*
|
||||||
|
* Order: DESC by `weightedScore` (SEVERITY_WEIGHTS), tie-broken lexicographically by
|
||||||
|
* critical→high→medium→low count, then ascending by `name` — fully deterministic, and the
|
||||||
|
* tie-break preserves "criticals always win" even when two repos share a weighted score.
|
||||||
|
*
|
||||||
|
* @param {object} ledger
|
||||||
|
* @returns {Array<{path:string,name:string,status:string,sessionId:string|null,findingsBySeverity:object,totalFindings:number,weightedScore:number,rank:number}>}
|
||||||
|
*/
|
||||||
|
export function buildBacklog(ledger) {
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
for (const repo of ledger.repos) {
|
||||||
|
if (repo.status === 'implemented') continue;
|
||||||
|
const f = repo.findingsBySeverity;
|
||||||
|
if (!f || typeof f !== 'object') continue;
|
||||||
|
|
||||||
|
const findingsBySeverity = Object.fromEntries(
|
||||||
|
SEVERITIES.map((s) => [s, typeof f[s] === 'number' ? f[s] : 0]),
|
||||||
|
);
|
||||||
|
const totalFindings = SEVERITIES.reduce((sum, s) => sum + findingsBySeverity[s], 0);
|
||||||
|
if (totalFindings === 0) continue;
|
||||||
|
|
||||||
|
const weightedScore = SEVERITIES.reduce(
|
||||||
|
(score, s) => score + findingsBySeverity[s] * SEVERITY_WEIGHTS[s],
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
items.push({
|
||||||
|
path: repo.path,
|
||||||
|
name: repo.name,
|
||||||
|
status: repo.status,
|
||||||
|
sessionId: repo.sessionId ?? null,
|
||||||
|
findingsBySeverity,
|
||||||
|
totalFindings,
|
||||||
|
weightedScore,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items.sort(
|
||||||
|
(x, y) =>
|
||||||
|
y.weightedScore - x.weightedScore ||
|
||||||
|
y.findingsBySeverity.critical - x.findingsBySeverity.critical ||
|
||||||
|
y.findingsBySeverity.high - x.findingsBySeverity.high ||
|
||||||
|
y.findingsBySeverity.medium - x.findingsBySeverity.medium ||
|
||||||
|
y.findingsBySeverity.low - x.findingsBySeverity.low ||
|
||||||
|
x.name.localeCompare(y.name),
|
||||||
|
);
|
||||||
|
|
||||||
|
return items.map((item, i) => ({ ...item, rank: i + 1 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a parsed ledger against the schema. Never throws — returns every problem at once
|
||||||
|
* so the caller (and tests) can inspect them. Soft by design (loaded data may be corrupt).
|
||||||
|
* @param {unknown} data
|
||||||
|
* @returns {{valid:boolean, errors:string[]}}
|
||||||
|
*/
|
||||||
|
export function validateLedger(data) {
|
||||||
|
const errors = [];
|
||||||
|
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||||
|
return { valid: false, errors: ['ledger must be an object'] };
|
||||||
|
}
|
||||||
|
if (data.schemaVersion !== CAMPAIGN_SCHEMA_VERSION) {
|
||||||
|
errors.push(`schemaVersion must be ${CAMPAIGN_SCHEMA_VERSION}`);
|
||||||
|
}
|
||||||
|
for (const field of ['createdDate', 'updatedDate']) {
|
||||||
|
if (typeof data[field] !== 'string' || !DATE_RE.test(data[field])) {
|
||||||
|
errors.push(`${field} must be YYYY-MM-DD`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Array.isArray(data.repos)) {
|
||||||
|
errors.push('repos must be an array');
|
||||||
|
return { valid: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
data.repos.forEach((r, i) => {
|
||||||
|
const at = `repos[${i}]${r && typeof r === 'object' && r.path ? ` (${r.path})` : ''}`;
|
||||||
|
if (!r || typeof r !== 'object' || Array.isArray(r)) {
|
||||||
|
errors.push(`${at}: must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof r.path !== 'string' || r.path.trim() === '') {
|
||||||
|
errors.push(`${at}: path is required`);
|
||||||
|
} else if (seen.has(r.path)) {
|
||||||
|
errors.push(`${at}: duplicate path`);
|
||||||
|
} else {
|
||||||
|
seen.add(r.path);
|
||||||
|
}
|
||||||
|
if (!STATUSES.includes(r.status)) {
|
||||||
|
errors.push(`${at}: status must be one of ${STATUSES.join(', ')}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persistence (thin IO shell) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default on-disk location: next to `sessions/`, OUTSIDE the plugin dir, so the campaign
|
||||||
|
* survives plugin uninstall/reinstall/upgrade.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function defaultLedgerPath() {
|
||||||
|
return join(homedir(), '.claude', 'config-audit', 'campaign-ledger.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load + parse a ledger file. Returns `null` if the file does not exist (graceful first run);
|
||||||
|
* other read/parse errors propagate so corruption is not silently swallowed.
|
||||||
|
* @param {string} [path]
|
||||||
|
* @returns {Promise<object|null>}
|
||||||
|
*/
|
||||||
|
export async function loadLedger(path = defaultLedgerPath()) {
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
content = await readFile(path, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.code === 'ENOENT') return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return JSON.parse(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a ledger as human-readable JSON, creating parent directories as needed.
|
||||||
|
* @param {string} path
|
||||||
|
* @param {object} ledger
|
||||||
|
* @returns {Promise<{path: string}>}
|
||||||
|
*/
|
||||||
|
export async function saveLedger(path = defaultLedgerPath(), ledger) {
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, `${JSON.stringify(ledger, null, 2)}\n`, 'utf8');
|
||||||
|
return { path };
|
||||||
|
}
|
||||||
132
scanners/lib/context-window.mjs
Normal file
132
scanners/lib/context-window.mjs
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
/**
|
||||||
|
* Context-window constants — single source of truth.
|
||||||
|
*
|
||||||
|
* Several Claude Code budgets scale with the model's context window:
|
||||||
|
* - the skill listing is allotted ~2% of context (CC 2.1.32, changelog L2860);
|
||||||
|
* - the "CLAUDE.md is too long" warning threshold scales with it (CC 2.1.169).
|
||||||
|
*
|
||||||
|
* We cannot observe the user's actual context window, so scanners anchor on a
|
||||||
|
* conservative 200k window (the smallest common size — it fires earliest, the
|
||||||
|
* safe default when the window is unknown) and disclose the relaxed 1M figure.
|
||||||
|
*
|
||||||
|
* Zero external dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Conservative anchor: the smallest common context window. Budgets anchored
|
||||||
|
// here fire earliest, which is the safe default when the window is unknown.
|
||||||
|
export const CONTEXT_WINDOW_ANCHOR = 200_000;
|
||||||
|
|
||||||
|
// Large context window (Opus/Sonnet 1M tier). Used to disclose how a budget
|
||||||
|
// relaxes on large-context models.
|
||||||
|
export const LARGE_CONTEXT_WINDOW = 1_000_000;
|
||||||
|
|
||||||
|
// How much larger the 1M window is than the 200k anchor (= 5). A budget that
|
||||||
|
// scales linearly with the window relaxes by this factor at 1M.
|
||||||
|
export const LARGE_CONTEXT_SCALE = LARGE_CONTEXT_WINDOW / CONTEXT_WINDOW_ANCHOR;
|
||||||
|
|
||||||
|
// Dependency-free thousands separator (repo invariant: zero external deps).
|
||||||
|
export const withCommas = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
|
||||||
|
// Model families whose context window is the large (1M) tier. Verified June 2026
|
||||||
|
// (platform.claude.com models overview): Fable 5, Opus 4.8/4.7/4.6 and Sonnet 4.6
|
||||||
|
// all run a 1M context window. Matched by substring so dated IDs
|
||||||
|
// (claude-opus-4-8-20260528) and provider-prefixed IDs
|
||||||
|
// (us.anthropic.claude-opus-4-8) resolve too. Models we cannot confirm (e.g.
|
||||||
|
// Haiku, older 200k-era IDs) are deliberately left out: the caller then keeps the
|
||||||
|
// conservative anchor rather than guess a relaxed budget.
|
||||||
|
export const LARGE_CONTEXT_MODEL_IDS = [
|
||||||
|
'claude-fable-5',
|
||||||
|
'claude-opus-4-8',
|
||||||
|
'claude-opus-4-7',
|
||||||
|
'claude-opus-4-6',
|
||||||
|
'claude-sonnet-4-6',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Short aliases Claude Code accepts in the `model` setting / ANTHROPIC_MODEL that
|
||||||
|
// currently resolve to a 1M-tier model (`opusplan` plans on an Opus-tier model).
|
||||||
|
export const LARGE_CONTEXT_MODEL_ALIASES = new Set(['opus', 'sonnet', 'fable', 'opusplan']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a configured model id/alias to its context window, or null when we cannot
|
||||||
|
* confirm it. Pure: no IO. Used by the `--context-window auto` probe (B8b) so
|
||||||
|
* known 1M-tier models calibrate budgets instead of falling back to the
|
||||||
|
* conservative advisory anchor.
|
||||||
|
*
|
||||||
|
* @param {string} modelId - e.g. "claude-opus-4-8[1m]", "claude-sonnet-4-6", "opus"
|
||||||
|
* @returns {number|null} the context window, or null if unrecognized
|
||||||
|
*/
|
||||||
|
export function modelToContextWindow(modelId) {
|
||||||
|
if (typeof modelId !== 'string') return null;
|
||||||
|
const id = modelId.trim().toLowerCase();
|
||||||
|
if (!id) return null;
|
||||||
|
// Explicit tier tag wins — the running session model surfaces as e.g.
|
||||||
|
// "claude-opus-4-8[1m]". This is the strongest, most future-proof signal.
|
||||||
|
if (id.includes('[1m]')) return LARGE_CONTEXT_WINDOW;
|
||||||
|
// Known 1M-tier families (substring → tolerant of date/provider-prefix variants).
|
||||||
|
for (const fam of LARGE_CONTEXT_MODEL_IDS) {
|
||||||
|
if (id.includes(fam)) return LARGE_CONTEXT_WINDOW;
|
||||||
|
}
|
||||||
|
// Short aliases.
|
||||||
|
if (LARGE_CONTEXT_MODEL_ALIASES.has(id)) return LARGE_CONTEXT_WINDOW;
|
||||||
|
// Unknown: cannot confirm the window — keep the conservative anchor (null).
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} ResolvedContextWindow
|
||||||
|
* @property {number} window - the context window budgets calibrate against
|
||||||
|
* @property {boolean} advisory - true when the window is unknown: keep the anchor
|
||||||
|
* but downgrade budget findings to info instead of
|
||||||
|
* firing them as a breach
|
||||||
|
* @property {'default'|'explicit'|'auto-probed'|'auto-unresolved'} source
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the raw `--context-window` CLI value into a window + advisory flag.
|
||||||
|
*
|
||||||
|
* Design (B8): the DEFAULT (no flag) is byte-identical to the pre-B8 behavior —
|
||||||
|
* the conservative 200k anchor at full severity. Only an explicit value changes
|
||||||
|
* calibration. `auto` asks the tool to figure out the window.
|
||||||
|
*
|
||||||
|
* B8b: `auto` now probes the configured model (`opts.model`, resolved from the
|
||||||
|
* settings cascade / ANTHROPIC_MODEL by the orchestrator). A recognized 1M-tier
|
||||||
|
* model calibrates to its window (source `auto-probed`, not advisory). When the
|
||||||
|
* model is unknown or unpinned, it keeps the conservative anchor but marks the
|
||||||
|
* result advisory (source `auto-unresolved`) so SKL/CML downgrade their budget
|
||||||
|
* findings to info rather than "crying wolf" on a window we cannot confirm.
|
||||||
|
*
|
||||||
|
* @param {string|number|null|undefined} arg
|
||||||
|
* @param {{ model?: string|null }} [opts] - probe input for `auto` (ignored on the
|
||||||
|
* default/explicit paths, which stay byte-stable).
|
||||||
|
* @returns {ResolvedContextWindow}
|
||||||
|
*/
|
||||||
|
export function resolveContextWindow(arg, opts = {}) {
|
||||||
|
if (arg == null) {
|
||||||
|
return { window: CONTEXT_WINDOW_ANCHOR, advisory: false, source: 'default' };
|
||||||
|
}
|
||||||
|
if (String(arg).trim().toLowerCase() === 'auto') {
|
||||||
|
const probed = modelToContextWindow(opts.model);
|
||||||
|
if (probed) {
|
||||||
|
return { window: probed, advisory: false, source: 'auto-probed' };
|
||||||
|
}
|
||||||
|
return { window: CONTEXT_WINDOW_ANCHOR, advisory: true, source: 'auto-unresolved' };
|
||||||
|
}
|
||||||
|
const n = typeof arg === 'number' ? arg : parseInt(String(arg).trim(), 10);
|
||||||
|
if (Number.isFinite(n) && n > 0) {
|
||||||
|
return { window: n, advisory: false, source: 'explicit' };
|
||||||
|
}
|
||||||
|
// Unparseable / non-positive: fall back to the conservative default (no advisory).
|
||||||
|
return { window: CONTEXT_WINDOW_ANCHOR, advisory: false, source: 'default' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale a 200k-anchored budget to a given context window. Linear in the window,
|
||||||
|
* so it is the identity at the anchor (keeps the default byte-stable).
|
||||||
|
*
|
||||||
|
* @param {number} anchorValue - the budget/threshold defined at the 200k anchor
|
||||||
|
* @param {number} window - the target context window
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function scaleForWindow(anchorValue, window) {
|
||||||
|
return Math.round(anchorValue * (window / CONTEXT_WINDOW_ANCHOR));
|
||||||
|
}
|
||||||
|
|
@ -11,8 +11,148 @@ const SKIP_DIRS = new Set([
|
||||||
'node_modules', '.git', 'dist', 'build', 'coverage', '__pycache__',
|
'node_modules', '.git', 'dist', 'build', 'coverage', '__pycache__',
|
||||||
'.next', '.nuxt', '.output', '.cache', '.turbo', '.parcel-cache',
|
'.next', '.nuxt', '.output', '.cache', '.turbo', '.parcel-cache',
|
||||||
'vendor', 'venv', '.venv', '.tox',
|
'vendor', 'venv', '.venv', '.tox',
|
||||||
|
// A `backups` dir holds backup COPIES, not live config — auditing it as if
|
||||||
|
// live produces stale findings. config-audit's own session backups
|
||||||
|
// (~/.claude/config-audit/backups/<ts>/files/.../CLAUDE.md) are the canonical
|
||||||
|
// case (M-BUG-8), but the rule is general: backups are never live config.
|
||||||
|
'backups',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Path marker for the plugin install cache (~/.claude/plugins/cache).
|
||||||
|
// Structure: <...>/plugins/cache/<marketplace>/<plugin>/<version>/...
|
||||||
|
// installed_plugins.json's installPath points INTO this tree at the ACTIVE
|
||||||
|
// version; any other version dir is a stale leftover (superseded install).
|
||||||
|
const PLUGIN_CACHE_MARKER = `plugins${sep}cache${sep}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the `<marketplace>/<plugin>/<version>` key for a path inside
|
||||||
|
* ~/.claude/plugins/cache. Returns null when the path is not under
|
||||||
|
* plugins/cache, or is shallower than the version directory.
|
||||||
|
* @param {string} absPath
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
export function cacheVersionKey(absPath) {
|
||||||
|
const i = absPath.indexOf(PLUGIN_CACHE_MARKER);
|
||||||
|
if (i === -1) return null;
|
||||||
|
const rest = absPath.slice(i + PLUGIN_CACHE_MARKER.length);
|
||||||
|
const segs = rest.split(sep).filter(Boolean);
|
||||||
|
if (segs.length < 3) return null; // need marketplace/plugin/version
|
||||||
|
return segs.slice(0, 3).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a path inside plugins/cache, return the absolute `<...>/plugins`
|
||||||
|
* directory that owns it (where installed_plugins.json lives).
|
||||||
|
* @param {string} absPath
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
function pluginsDirForCachePath(absPath) {
|
||||||
|
const i = absPath.indexOf(PLUGIN_CACHE_MARKER);
|
||||||
|
if (i === -1) return null;
|
||||||
|
return absPath.slice(0, i + 'plugins'.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the set of ACTIVE cache version-keys from a `<...>/plugins` directory's
|
||||||
|
* installed_plugins.json. Each record's installPath points at the version
|
||||||
|
* Claude Code loads. Returns null when the manifest is absent or unparseable —
|
||||||
|
* callers must then NOT filter (we cannot safely tell active from stale, and
|
||||||
|
* silently dropping active config is the worse failure).
|
||||||
|
* @param {string} pluginsDir
|
||||||
|
* @returns {Promise<Set<string> | null>}
|
||||||
|
*/
|
||||||
|
async function readActiveCacheVersions(pluginsDir) {
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = await readFile(join(pluginsDir, 'installed_plugins.json'), 'utf-8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const active = new Set();
|
||||||
|
const plugins = parsed && parsed.plugins;
|
||||||
|
if (plugins && typeof plugins === 'object') {
|
||||||
|
for (const recs of Object.values(plugins)) {
|
||||||
|
if (!Array.isArray(recs)) continue;
|
||||||
|
for (const r of recs) {
|
||||||
|
const key = r && r.installPath ? cacheVersionKey(resolve(r.installPath)) : null;
|
||||||
|
if (key) active.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify stale plugin-cache versions among discovered files and (optionally)
|
||||||
|
* filter them out. "Stale" = a cache version-key not referenced by the owning
|
||||||
|
* installed_plugins.json. Active versions are always kept — installPaths point
|
||||||
|
* INTO the cache, so a blunt "skip all of plugins/cache" would drop live config.
|
||||||
|
*
|
||||||
|
* @param {Array} files
|
||||||
|
* @param {boolean} excludeCache - when true, stale-version files are removed
|
||||||
|
* @returns {Promise<{ files: Array, staleCacheVersions: Array<{key:string, fileCount:number, estimatedBytes:number}> }>}
|
||||||
|
*/
|
||||||
|
async function applyCacheFilter(files, excludeCache) {
|
||||||
|
const cacheFiles = [];
|
||||||
|
for (const f of files) {
|
||||||
|
const key = cacheVersionKey(f.absPath);
|
||||||
|
if (key) cacheFiles.push({ f, key });
|
||||||
|
}
|
||||||
|
if (cacheFiles.length === 0) return { files, staleCacheVersions: [] };
|
||||||
|
|
||||||
|
// Union active version-keys across every installed_plugins.json adjacent to
|
||||||
|
// the cache (a full-machine sweep only ever sees one, but be robust).
|
||||||
|
const pluginsDirs = new Set();
|
||||||
|
for (const { f } of cacheFiles) {
|
||||||
|
const pd = pluginsDirForCachePath(f.absPath);
|
||||||
|
if (pd) pluginsDirs.add(pd);
|
||||||
|
}
|
||||||
|
let active = null;
|
||||||
|
for (const pd of pluginsDirs) {
|
||||||
|
const keys = await readActiveCacheVersions(pd);
|
||||||
|
if (keys) {
|
||||||
|
if (active === null) active = new Set();
|
||||||
|
for (const k of keys) active.add(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No readable manifest → cannot distinguish active from stale → do nothing.
|
||||||
|
if (active === null) return { files, staleCacheVersions: [] };
|
||||||
|
|
||||||
|
const byKey = new Map();
|
||||||
|
for (const { f, key } of cacheFiles) {
|
||||||
|
if (!byKey.has(key)) byKey.set(key, []);
|
||||||
|
byKey.get(key).push(f);
|
||||||
|
}
|
||||||
|
const staleCacheVersions = [];
|
||||||
|
for (const [key, group] of byKey) {
|
||||||
|
if (!active.has(key)) {
|
||||||
|
staleCacheVersions.push({
|
||||||
|
key,
|
||||||
|
fileCount: group.length,
|
||||||
|
estimatedBytes: group.reduce((s, f) => s + (f.size || 0), 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
staleCacheVersions.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
||||||
|
|
||||||
|
let outFiles = files;
|
||||||
|
if (excludeCache && staleCacheVersions.length > 0) {
|
||||||
|
const staleKeys = new Set(staleCacheVersions.map(s => s.key));
|
||||||
|
outFiles = files.filter(f => {
|
||||||
|
const k = cacheVersionKey(f.absPath);
|
||||||
|
return k === null || !staleKeys.has(k);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { files: outFiles, staleCacheVersions };
|
||||||
|
}
|
||||||
|
|
||||||
/** Config file patterns to discover */
|
/** Config file patterns to discover */
|
||||||
const CONFIG_PATTERNS = {
|
const CONFIG_PATTERNS = {
|
||||||
claudeMd: /^CLAUDE\.md$|^CLAUDE\.local\.md$/i,
|
claudeMd: /^CLAUDE\.md$|^CLAUDE\.local\.md$/i,
|
||||||
|
|
@ -34,7 +174,8 @@ const CONFIG_PATTERNS = {
|
||||||
* @param {object} [opts]
|
* @param {object} [opts]
|
||||||
* @param {number} [opts.maxFiles=500] - max files to return
|
* @param {number} [opts.maxFiles=500] - max files to return
|
||||||
* @param {boolean} [opts.includeGlobal=false] - also scan ~/.claude/
|
* @param {boolean} [opts.includeGlobal=false] - also scan ~/.claude/
|
||||||
* @returns {Promise<{ files: ConfigFile[], skipped: number }>}
|
* @param {boolean} [opts.excludeCache=false] - drop stale ~/.claude/plugins/cache versions (B3)
|
||||||
|
* @returns {Promise<{ files: ConfigFile[], skipped: number, staleCacheVersions: Array }>}
|
||||||
*
|
*
|
||||||
* @typedef {{ absPath: string, relPath: string, type: string, scope: string, size: number }} ConfigFile
|
* @typedef {{ absPath: string, relPath: string, type: string, scope: string, size: number }} ConfigFile
|
||||||
*/
|
*/
|
||||||
|
|
@ -68,7 +209,8 @@ export async function discoverConfigFiles(targetPath, opts = {}) {
|
||||||
} catch { /* doesn't exist */ }
|
} catch { /* doesn't exist */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { files, skipped: skippedRef.count };
|
const { files: outFiles, staleCacheVersions } = await applyCacheFilter(files, opts.excludeCache || false);
|
||||||
|
return { files: outFiles, skipped: skippedRef.count, staleCacheVersions };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -245,7 +387,8 @@ export async function discoverFullMachinePaths() {
|
||||||
* @param {Array<{ path: string, maxDepth: number }>} roots
|
* @param {Array<{ path: string, maxDepth: number }>} roots
|
||||||
* @param {object} [opts]
|
* @param {object} [opts]
|
||||||
* @param {number} [opts.maxFiles=2000] - global max across all roots
|
* @param {number} [opts.maxFiles=2000] - global max across all roots
|
||||||
* @returns {Promise<{ files: ConfigFile[], skipped: number }>}
|
* @param {boolean} [opts.excludeCache=false] - drop stale ~/.claude/plugins/cache versions (B3)
|
||||||
|
* @returns {Promise<{ files: ConfigFile[], skipped: number, staleCacheVersions: Array }>}
|
||||||
*/
|
*/
|
||||||
export async function discoverConfigFilesMulti(roots, opts = {}) {
|
export async function discoverConfigFilesMulti(roots, opts = {}) {
|
||||||
const maxFiles = opts.maxFiles || 2000;
|
const maxFiles = opts.maxFiles || 2000;
|
||||||
|
|
@ -287,7 +430,8 @@ export async function discoverConfigFilesMulti(roots, opts = {}) {
|
||||||
} catch { /* doesn't exist */ }
|
} catch { /* doesn't exist */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { files: allFiles, skipped: totalSkipped };
|
const { files: outFiles, staleCacheVersions } = await applyCacheFilter(allFiles, opts.excludeCache || false);
|
||||||
|
return { files: outFiles, skipped: totalSkipped, staleCacheVersions };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
160
scanners/lib/hook-additional-context.mjs
Normal file
160
scanners/lib/hook-additional-context.mjs
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
/**
|
||||||
|
* Hook additionalContext injection advisory (v5.10 B5).
|
||||||
|
*
|
||||||
|
* A hook that emits `hookSpecificOutput.additionalContext` has that content
|
||||||
|
* injected into Claude's context EVERY time the hook fires. Plain stdout on
|
||||||
|
* exit 0 does NOT enter context (it goes to the debug log only). So a hook that
|
||||||
|
* dumps large, unfiltered command output into additionalContext is a recurring
|
||||||
|
* per-turn token cost that compounds and is compaction-sensitive.
|
||||||
|
*
|
||||||
|
* This module is a STATIC heuristic over hook SCRIPT SOURCE. It is deliberately
|
||||||
|
* LOW PRECISION — it cannot run the script or measure the real payload — so it
|
||||||
|
* ships as an INFO advisory (weight 0, never severity-bearing), paired with a
|
||||||
|
* feature-gap "filter-before-Claude-reads" lever. The signal:
|
||||||
|
*
|
||||||
|
* the script references `additionalContext`
|
||||||
|
* AND captures output from a verbose-prone command (cat/find/git log/test/curl…)
|
||||||
|
* AND applies no truncating/filtering tool anywhere (grep/head/jq/.slice…).
|
||||||
|
*
|
||||||
|
* A script that pipes through a filter, or only captures cheap output ($(date)),
|
||||||
|
* is assumed bounded and is not flagged. Conversely a verbose capture with no
|
||||||
|
* filter is the un-grepped pattern worth surfacing.
|
||||||
|
*
|
||||||
|
* Mechanism verified 2026-06-23 against code.claude.com/docs:
|
||||||
|
* context-window.md — "A PostToolUse hook … reports back via
|
||||||
|
* hookSpecificOutput.additionalContext. That field enters Claude's context.
|
||||||
|
* Plain stdout on exit 0 does not." + tip: keep output concise; it enters
|
||||||
|
* context without truncation. The remediation lever is the documented
|
||||||
|
* filter-test-output.sh pattern (grep before Claude reads).
|
||||||
|
*
|
||||||
|
* The pure `assessHookAdditionalContext` takes already-read script text so it is
|
||||||
|
* fully unit-testable without IO. `assessHookContextForRepo` is the thin IO
|
||||||
|
* wrapper (walk hooks → scripts → assess) shared by feature-gap; the HKV scanner
|
||||||
|
* calls the pure function inline on scripts it already reads.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readTextFile } from './file-discovery.mjs';
|
||||||
|
import { parseJson } from './yaml-parser.mjs';
|
||||||
|
import { stat } from 'node:fs/promises';
|
||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
|
||||||
|
// The field that actually enters Claude's context (vs. plain stdout / debug log).
|
||||||
|
const ADDITIONAL_CONTEXT_RX = /additionalContext/;
|
||||||
|
|
||||||
|
// Commands whose UNfiltered output can be large. A capture invoking one of these
|
||||||
|
// with no filter anywhere is the low-precision "un-grepped output" signal.
|
||||||
|
// Shell substitution AND node child_process / file reads are both covered.
|
||||||
|
const VERBOSE_CAPTURE_RX =
|
||||||
|
/\b(?:cat|find|ls|git\s+(?:log|diff|status|show)|npm|yarn|pnpm|pytest|jest|go\s+test|cargo\s+test|curl|wget|env|printenv|dmesg|journalctl|execSync|spawnSync|readFileSync)\b/;
|
||||||
|
|
||||||
|
// Truncating / filtering tools that BOUND a payload before it reaches context.
|
||||||
|
// Their presence anywhere in the script suppresses the advisory (assumed bounded).
|
||||||
|
// Shell filters + the common node-side bounding operations.
|
||||||
|
const FILTER_RX =
|
||||||
|
/\b(?:grep|egrep|rg|head|tail|sed|awk|jq|cut|wc|uniq|sort)\b|\.(?:slice|substring|substr)\s*\(/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assess one hook script's source for unfiltered additionalContext injection.
|
||||||
|
*
|
||||||
|
* @param {{ scriptContent?: string }} [args]
|
||||||
|
* @returns {{
|
||||||
|
* buildsAdditionalContext: boolean,
|
||||||
|
* hasVerboseCapture: boolean,
|
||||||
|
* hasFilter: boolean,
|
||||||
|
* capturesUnfiltered: boolean,
|
||||||
|
* flagged: boolean,
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function assessHookAdditionalContext({ scriptContent } = {}) {
|
||||||
|
const content = typeof scriptContent === 'string' ? scriptContent : '';
|
||||||
|
const buildsAdditionalContext = ADDITIONAL_CONTEXT_RX.test(content);
|
||||||
|
const hasVerboseCapture = VERBOSE_CAPTURE_RX.test(content);
|
||||||
|
const hasFilter = FILTER_RX.test(content);
|
||||||
|
const capturesUnfiltered = hasVerboseCapture && !hasFilter;
|
||||||
|
return {
|
||||||
|
buildsAdditionalContext,
|
||||||
|
hasVerboseCapture,
|
||||||
|
hasFilter,
|
||||||
|
capturesUnfiltered,
|
||||||
|
flagged: buildsAdditionalContext && capturesUnfiltered,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a filesystem script path from a hook command string.
|
||||||
|
* Mirrors hook-validator's extractScriptPath (kept local so the shared lib has
|
||||||
|
* no upward dependency on a scanner). Handles ${CLAUDE_PLUGIN_ROOT}.
|
||||||
|
*/
|
||||||
|
function extractScriptPath(command, baseDir) {
|
||||||
|
const match = command.match(/(?:bash|node|sh)\s+(.+?)(?:\s|$)/);
|
||||||
|
if (!match) return null;
|
||||||
|
let scriptPath = match[1].trim();
|
||||||
|
scriptPath = scriptPath.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, resolve(baseDir, '..'));
|
||||||
|
scriptPath = scriptPath.replace(/\$CLAUDE_PLUGIN_ROOT/g, resolve(baseDir, '..'));
|
||||||
|
if (scriptPath.includes('$')) return null;
|
||||||
|
return resolve(baseDir, scriptPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yield every command-hook { event, command } from a hooks object. */
|
||||||
|
function* iterateCommandHooks(hooks) {
|
||||||
|
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return;
|
||||||
|
for (const [event, handlers] of Object.entries(hooks)) {
|
||||||
|
if (!Array.isArray(handlers)) continue;
|
||||||
|
for (const group of handlers) {
|
||||||
|
const hookList = group && Array.isArray(group.hooks) ? group.hooks : [];
|
||||||
|
for (const hook of hookList) {
|
||||||
|
if (hook && hook.type === 'command' && typeof hook.command === 'string') {
|
||||||
|
yield { event, command: hook.command };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IO wrapper: walk discovered hooks (hooks.json + settings.json hooks), resolve
|
||||||
|
* each command hook's script, and return the ones flagged by the heuristic.
|
||||||
|
* Shared by feature-gap (the HKV scanner assesses inline on scripts it reads).
|
||||||
|
*
|
||||||
|
* @param {{ files: import('./file-discovery.mjs').ConfigFile[] }} discovery
|
||||||
|
* @returns {Promise<Array<{ event: string, scriptPath: string, file: string,
|
||||||
|
* assessment: ReturnType<typeof assessHookAdditionalContext> }>>}
|
||||||
|
*/
|
||||||
|
export async function assessHookContextForRepo(discovery) {
|
||||||
|
const flagged = [];
|
||||||
|
const files = (discovery && Array.isArray(discovery.files)) ? discovery.files : [];
|
||||||
|
|
||||||
|
const hooksObjects = [];
|
||||||
|
for (const file of files.filter((f) => f.type === 'hooks-json')) {
|
||||||
|
const content = await readTextFile(file.absPath);
|
||||||
|
const parsed = content ? parseJson(content) : null;
|
||||||
|
if (parsed) hooksObjects.push({ hooks: parsed.hooks || parsed, file });
|
||||||
|
}
|
||||||
|
for (const file of files.filter((f) => f.type === 'settings-json')) {
|
||||||
|
const content = await readTextFile(file.absPath);
|
||||||
|
const parsed = content ? parseJson(content) : null;
|
||||||
|
if (parsed && parsed.hooks && !Array.isArray(parsed.hooks)) {
|
||||||
|
hooksObjects.push({ hooks: parsed.hooks, file });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { hooks, file } of hooksObjects) {
|
||||||
|
const baseDir = dirname(file.absPath);
|
||||||
|
for (const { event, command } of iterateCommandHooks(hooks)) {
|
||||||
|
const scriptPath = extractScriptPath(command, baseDir);
|
||||||
|
if (!scriptPath) continue;
|
||||||
|
try {
|
||||||
|
await stat(scriptPath);
|
||||||
|
} catch {
|
||||||
|
continue; // missing script — HKV reports that separately
|
||||||
|
}
|
||||||
|
const scriptContent = await readTextFile(scriptPath);
|
||||||
|
const assessment = assessHookAdditionalContext({ scriptContent });
|
||||||
|
if (assessment.flagged) {
|
||||||
|
flagged.push({ event, scriptPath, file: file.absPath, assessment });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return flagged;
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,11 @@ export const TRANSLATIONS = {
|
||||||
description: 'Without `CLAUDE.md` at your project root, Claude has to work out your conventions from scratch every conversation. Project-specific guidance is the single highest-impact thing you can add.',
|
description: 'Without `CLAUDE.md` at your project root, Claude has to work out your conventions from scratch every conversation. Project-specific guidance is the single highest-impact thing you can add.',
|
||||||
recommendation: 'Create a file called `CLAUDE.md` in your project root. Start with a one-paragraph project overview, common commands, and any quirks Claude should know about.',
|
recommendation: 'Create a file called `CLAUDE.md` in your project root. Start with a one-paragraph project overview, common commands, and any quirks Claude should know about.',
|
||||||
},
|
},
|
||||||
|
'Nested CLAUDE.md is not re-injected after compaction': {
|
||||||
|
title: 'A `CLAUDE.md` in a subfolder can quietly drop out mid-session',
|
||||||
|
description: 'Only the main project `CLAUDE.md` is restored when Claude trims older history. A `CLAUDE.md` in a subfolder loads when you open a file there, but it does not come back after a trim until you open one again.',
|
||||||
|
recommendation: 'If its guidance must always apply, move that part into the main `CLAUDE.md`. Keep the subfolder file for things only needed when working in that folder.',
|
||||||
|
},
|
||||||
'CLAUDE.md is nearly empty': {
|
'CLAUDE.md is nearly empty': {
|
||||||
title: 'Your `CLAUDE.md` is mostly empty',
|
title: 'Your `CLAUDE.md` is mostly empty',
|
||||||
description: 'An empty instructions file gives Claude no project-specific context, so behavior falls back to defaults.',
|
description: 'An empty instructions file gives Claude no project-specific context, so behavior falls back to defaults.',
|
||||||
|
|
@ -91,10 +96,10 @@ export const TRANSLATIONS = {
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
SET: {
|
SET: {
|
||||||
static: {
|
static: {
|
||||||
'Unknown settings key': {
|
'Possible typo in settings key': {
|
||||||
title: 'A settings key isn\'t recognized',
|
title: 'A settings key looks like a typo',
|
||||||
description: 'A key in your settings file isn\'t one Claude Code understands. It will be ignored.',
|
description: 'A key in your settings file isn\'t recognized, but it\'s very close to a real one — likely a typo. Claude Code forwards unrecognized keys unchanged rather than rejecting them, so a misspelled key silently has no effect.',
|
||||||
recommendation: 'Check the key name for typos, or remove the key if it\'s no longer in use.',
|
recommendation: 'Check the suggested key name. Fix the spelling, or keep the key if it\'s intentional (e.g. a newer key this audit doesn\'t know yet).',
|
||||||
},
|
},
|
||||||
'Deprecated settings key': {
|
'Deprecated settings key': {
|
||||||
title: 'A settings key is no longer supported',
|
title: 'A settings key is no longer supported',
|
||||||
|
|
@ -142,7 +147,27 @@ export const TRANSLATIONS = {
|
||||||
recommendation: 'Open the file and fix the JSON syntax shown in the details (often a missing comma or quote).',
|
recommendation: 'Open the file and fix the JSON syntax shown in the details (often a missing comma or quote).',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
patterns: [],
|
patterns: [
|
||||||
|
{
|
||||||
|
// Specific case first: well-formed autoMode placed in the wrong scope.
|
||||||
|
regex: /^autoMode in shared project settings/,
|
||||||
|
translation: {
|
||||||
|
title: 'Your auto-mode rules are in a file Claude Code ignores',
|
||||||
|
description: 'Claude Code doesn\'t read `autoMode` from shared project settings (`.claude/settings.json`), so a checked-in repo can\'t grant itself auto-approval rules. The block has no effect where it is.',
|
||||||
|
recommendation: 'Move `autoMode` to your user settings (`~/.claude/settings.json`), the local (gitignored) project settings (`.claude/settings.local.json`), or managed settings.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Catch-all for autoMode structure problems (not-an-object, unknown
|
||||||
|
// sub-key, sub-key not a string array).
|
||||||
|
regex: /^autoMode/,
|
||||||
|
translation: {
|
||||||
|
title: 'Your `autoMode` block is malformed',
|
||||||
|
description: '`autoMode` must be an object with `environment`, `allow`, `soft_deny`, and `hard_deny`, each a list of plain-text rules. Part of it doesn\'t match that shape, so those rules may not apply.',
|
||||||
|
recommendation: 'Fix the `autoMode` entry shown in the details — use only the four known keys, each set to a list of strings.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
_default: {
|
_default: {
|
||||||
title: 'Your settings file has an issue',
|
title: 'Your settings file has an issue',
|
||||||
description: 'A check on your settings file flagged something worth a look.',
|
description: 'A check on your settings file flagged something worth a look.',
|
||||||
|
|
@ -234,10 +259,15 @@ export const TRANSLATIONS = {
|
||||||
description: 'Without scoping, the rule loads on every conversation regardless of which files you\'re working with.',
|
description: 'Without scoping, the rule loads on every conversation regardless of which files you\'re working with.',
|
||||||
recommendation: 'Add a scoping block at the top of the file to limit when the rule loads (see the details).',
|
recommendation: 'Add a scoping block at the top of the file to limit when the rule loads (see the details).',
|
||||||
},
|
},
|
||||||
'Rule uses deprecated "globs" field': {
|
'Large path-scoped rule is lost after compaction': {
|
||||||
title: 'A rule uses an old field name',
|
title: 'A large scoped rule can quietly drop out mid-session',
|
||||||
description: 'The field was renamed; the old name still works for now but may stop working in a future release.',
|
description: 'Scoped rules load only when you open a matching file, and they fall out of context when Claude trims older history — they do not come back until you open a matching file again.',
|
||||||
recommendation: 'Rename the field to the current equivalent shown in the details.',
|
recommendation: 'If part of it must always apply, move that part into the main `CLAUDE.md`, which is restored automatically.',
|
||||||
|
},
|
||||||
|
'Rule uses "globs" instead of documented "paths"': {
|
||||||
|
title: 'A rule uses an unrecognized scoping field',
|
||||||
|
description: 'Claude Code\'s docs use `paths:` to scope a rule; `globs:` is not the documented field, so the rule may not scope the way you intend.',
|
||||||
|
recommendation: 'Rename the field to `paths:` (see the details).',
|
||||||
},
|
},
|
||||||
'Rule file is not .md': {
|
'Rule file is not .md': {
|
||||||
title: 'A rule file uses an unexpected extension',
|
title: 'A rule file uses an unexpected extension',
|
||||||
|
|
@ -405,12 +435,12 @@ export const TRANSLATIONS = {
|
||||||
recommendation: 'Consider moving team-wide settings to project scope and keeping personal ones at user or local scope.',
|
recommendation: 'Consider moving team-wide settings to project scope and keeping personal ones at user or local scope.',
|
||||||
},
|
},
|
||||||
'CLAUDE.md not modular': {
|
'CLAUDE.md not modular': {
|
||||||
title: 'Your instructions file is one big block',
|
title: 'Your instructions all live in one file',
|
||||||
description: 'Splitting long instructions into smaller linked files makes them easier to maintain and easier on the loading time.',
|
description: 'Splitting your instructions into smaller linked files with `@import` or `.claude/rules/` keeps each part focused and easier to maintain.',
|
||||||
recommendation: 'Break out long sections into separate files and link them with `@import`.',
|
recommendation: 'Break out long sections into separate files and link them with `@import`.',
|
||||||
},
|
},
|
||||||
'No path-scoped rules': {
|
'No path-scoped rules': {
|
||||||
title: 'Your rules all load on every conversation',
|
title: 'You haven\'t set up path-scoped rules yet',
|
||||||
description: 'Path-scoped rules only load when you\'re working with files that match — keeps each conversation focused.',
|
description: 'Path-scoped rules only load when you\'re working with files that match — keeps each conversation focused.',
|
||||||
recommendation: 'Add scoping to your rules so they only load for the files they apply to.',
|
recommendation: 'Add scoping to your rules so they only load for the files they apply to.',
|
||||||
},
|
},
|
||||||
|
|
@ -460,7 +490,7 @@ export const TRANSLATIONS = {
|
||||||
recommendation: 'Add fields like `model`, `tools`, or `description` to your skill files where useful.',
|
recommendation: 'Add fields like `model`, `tools`, or `description` to your skill files where useful.',
|
||||||
},
|
},
|
||||||
'No subagent isolation': {
|
'No subagent isolation': {
|
||||||
title: 'Your subagents share Claude\'s main work folder',
|
title: 'You haven\'t set up subagent isolation yet',
|
||||||
description: 'Isolated subagents run in their own copy of the repo so they can\'t accidentally disturb your main work.',
|
description: 'Isolated subagents run in their own copy of the repo so they can\'t accidentally disturb your main work.',
|
||||||
recommendation: 'Add `isolation: worktree` to subagents that do destructive or experimental work.',
|
recommendation: 'Add `isolation: worktree` to subagents that do destructive or experimental work.',
|
||||||
},
|
},
|
||||||
|
|
@ -539,6 +569,11 @@ export const TRANSLATIONS = {
|
||||||
description: 'Skill descriptions load on every turn whether you use the skill or not. Long descriptions add up.',
|
description: 'Skill descriptions load on every turn whether you use the skill or not. Long descriptions add up.',
|
||||||
recommendation: 'Trim the description to one short sentence and move details into the skill body.',
|
recommendation: 'Trim the description to one short sentence and move details into the skill body.',
|
||||||
},
|
},
|
||||||
|
'Stale plugin-cache versions (disk cleanup, zero live-context impact)': {
|
||||||
|
title: 'Old plugin versions are sitting on disk (safe to delete)',
|
||||||
|
description: 'Your plugin cache holds older versions that newer installs have replaced. They take up disk space but are never loaded into a conversation — so they cost zero tokens per turn. This is housekeeping, not a performance problem.',
|
||||||
|
recommendation: 'Delete the old version folders to reclaim disk. The details list exactly which ones; the active version of each plugin stays untouched.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
patterns: [
|
patterns: [
|
||||||
{
|
{
|
||||||
|
|
@ -659,11 +694,6 @@ export const TRANSLATIONS = {
|
||||||
description: 'The settings block tells Claude what tools and model the agent should use.',
|
description: 'The settings block tells Claude what tools and model the agent should use.',
|
||||||
recommendation: 'Add a settings block (delimited by `---`) at the top of the file.',
|
recommendation: 'Add a settings block (delimited by `---`) at the top of the file.',
|
||||||
},
|
},
|
||||||
'Cross-plugin command name conflict': {
|
|
||||||
title: 'Two plugins both define a command with the same name',
|
|
||||||
description: 'When two plugins use the same command name, only one wins.',
|
|
||||||
recommendation: 'Rename the command in one of the plugins, or disable the one you don\'t need.',
|
|
||||||
},
|
|
||||||
'No plugins found': {
|
'No plugins found': {
|
||||||
title: 'No plugins are installed in this location',
|
title: 'No plugins are installed in this location',
|
||||||
description: 'The location was checked but contains no plugins (or no plugins Claude Code recognizes).',
|
description: 'The location was checked but contains no plugins (or no plugins Claude Code recognizes).',
|
||||||
|
|
@ -691,6 +721,14 @@ export const TRANSLATIONS = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
patterns: [
|
patterns: [
|
||||||
|
{
|
||||||
|
regex: /^Plugin agent sets ".+", which Claude Code ignores$/,
|
||||||
|
translation: {
|
||||||
|
title: 'A plugin agent sets a field Claude Code ignores',
|
||||||
|
description: 'Plugin subagents ignore the `hooks`, `mcpServers`, and `permissionMode` settings — only agents in `.claude/agents/` honor them. The field shown here has no effect, and `permissionMode` can give a false sense of restriction.',
|
||||||
|
recommendation: 'Remove the field, or move the agent into `.claude/agents/`, where it takes effect.',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
regex: /^Missing required field in plugin\.json/,
|
regex: /^Missing required field in plugin\.json/,
|
||||||
translation: {
|
translation: {
|
||||||
|
|
@ -723,6 +761,38 @@ export const TRANSLATIONS = {
|
||||||
recommendation: 'Add the missing setting shown in the details.',
|
recommendation: 'Add the missing setting shown in the details.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
regex: /^Command name ".+" used by multiple plugins$/,
|
||||||
|
translation: {
|
||||||
|
title: 'Several plugins define a command with the same name',
|
||||||
|
description: 'Each plugin\'s commands are namespaced (like `/plugin:command`), so they all still work — but a shared command name makes error messages, search results, and the command listing ambiguous about which plugin you mean.',
|
||||||
|
recommendation: 'Rename the command in one of the plugins so each name points to a single plugin.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: /^Plugin namespace collision:/,
|
||||||
|
translation: {
|
||||||
|
title: 'Two plugins share the same namespace, so one hides the other',
|
||||||
|
description: 'Claude Code names a plugin\'s commands, skills, and agents after the plugin (like `/name:command`). When two plugins declare the same name, they share one namespace and only one is reachable — the other\'s commands, skills, and agents silently disappear.',
|
||||||
|
recommendation: 'Give each plugin a distinct `name` in its `plugin.json`. The folder name doesn\'t matter — the `name` field is what forms the namespace.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: /^plugin\.json ".+" path shadows the default /,
|
||||||
|
translation: {
|
||||||
|
title: 'A plugin folder is silently ignored because the manifest points elsewhere',
|
||||||
|
description: 'The plugin\'s `plugin.json` points a component type (like commands or agents) at a custom path. When it does, Claude Code stops scanning the default folder of the same name — so everything still sitting in that folder silently disappears.',
|
||||||
|
recommendation: 'Either delete the unused default folder, or keep it by listing it explicitly alongside the custom path. The details show which folder and field.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: /^plugin\.json "skills" entry /,
|
||||||
|
translation: {
|
||||||
|
title: 'A plugin lists a skill path that doesn\'t point to a real skill folder',
|
||||||
|
description: 'The plugin\'s `plugin.json` lists a `skills` path that isn\'t a folder inside the plugin — it\'s missing, points at a file, sits outside the plugin, or isn\'t text. Claude Code loads no skill from it.',
|
||||||
|
recommendation: 'Point each `skills` entry at a real folder inside the plugin that contains a `SKILL.md`. The details show which entry and what\'s wrong.',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
_default: {
|
_default: {
|
||||||
title: 'A plugin has a configuration issue',
|
title: 'A plugin has a configuration issue',
|
||||||
|
|
@ -747,6 +817,11 @@ export const TRANSLATIONS = {
|
||||||
description: 'Claude Code keeps every active skill\'s description in one shared listing it reads to choose which skill to use, and that listing has a limited size. Added up, your skills\' descriptions run past that size on a smaller setup, so Claude Code may drop some of them — and stop seeing those skills. This is an estimate; a larger setup has more room.',
|
description: 'Claude Code keeps every active skill\'s description in one shared listing it reads to choose which skill to use, and that listing has a limited size. Added up, your skills\' descriptions run past that size on a smaller setup, so Claude Code may drop some of them — and stop seeing those skills. This is an estimate; a larger setup has more room.',
|
||||||
recommendation: 'Free up room: turn off bundled skills you do not use, collapse the heaviest ones so only their names show, or shorten the longest descriptions. The details show the measured total and the room available.',
|
recommendation: 'Free up room: turn off bundled skills you do not use, collapse the heaviest ones so only their names show, or shorten the longest descriptions. The details show the measured total and the room available.',
|
||||||
},
|
},
|
||||||
|
'Skill body is large (loads on demand when the skill runs)': {
|
||||||
|
title: 'A skill\'s body is large (it loads only when that skill runs)',
|
||||||
|
description: 'This skill\'s instructions run longer than the rough guidance for a skill body. The body is not part of the always-loaded listing Claude reads every turn — it loads only when you invoke the skill, so it costs nothing until then. Once it loads, though, it stays in context for the rest of that session.',
|
||||||
|
recommendation: 'Move reference material into supporting files the skill opens only when needed, so the body stays lean. For a heavy skill you can also run its body in a separate context with `context: fork` in the skill\'s settings.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
patterns: [],
|
patterns: [],
|
||||||
_default: {
|
_default: {
|
||||||
|
|
@ -755,4 +830,54 @@ export const TRANSLATIONS = {
|
||||||
recommendation: 'See the details for which skill to trim or turn off.',
|
recommendation: 'See the details for which skill to trim or turn off.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// OST — Output-Style Validation
|
||||||
|
// Category: Configuration mistake
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
OST: {
|
||||||
|
static: {
|
||||||
|
'Custom output style removes built-in coding instructions': {
|
||||||
|
title: 'A custom output style turns off Claude\'s coding know-how',
|
||||||
|
description: 'This style replaces Claude Code\'s built-in coding guidance with only your own text, so while it\'s active Claude forgets how to scope changes, comment, and verify work. The setting that keeps that guidance is off by default.',
|
||||||
|
recommendation: 'Add `keep-coding-instructions: true` to the top of the style file to keep that guidance. If you meant to drop it for a non-coding style, leave it as is.',
|
||||||
|
},
|
||||||
|
'Plugin output style overrides your selected output style': {
|
||||||
|
title: 'A plugin is forcing its own output style on you',
|
||||||
|
description: 'This plugin applies its own output style automatically whenever it\'s on, replacing the one you picked. If two plugins both do this, the first one to load wins.',
|
||||||
|
recommendation: 'If you didn\'t want this, turn off the plugin or remove the force setting from its style. Otherwise there\'s nothing to do — the plugin works this way on purpose.',
|
||||||
|
},
|
||||||
|
'Configured output style does not exist': {
|
||||||
|
title: 'Your chosen output style can\'t be found',
|
||||||
|
description: 'Your settings point to an output style that doesn\'t exist by that name, so Claude Code quietly uses the default instead. The style you wanted never takes effect.',
|
||||||
|
recommendation: 'Check the spelling against your styles (built-ins are Default, Explanatory, Learning, Proactive), add the missing style file, or remove the setting.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
patterns: [],
|
||||||
|
_default: {
|
||||||
|
title: 'Something about your output styles needs a look',
|
||||||
|
description: 'A check on your output styles flagged something worth reviewing.',
|
||||||
|
recommendation: 'See the details for which output style to adjust.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// OPT — Optimization Lens (mechanism-fit)
|
||||||
|
// Category: Missed opportunity
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
OPT: {
|
||||||
|
static: {
|
||||||
|
'A multi-step procedure in CLAUDE.md belongs in a skill': {
|
||||||
|
title: 'A long checklist in CLAUDE.md could be a skill instead',
|
||||||
|
description: 'Your CLAUDE.md has a multi-step procedure that loads on every turn, costing tokens whether or not you\'re doing that task. Procedures fit better as a skill, whose steps load only when you actually run them.',
|
||||||
|
recommendation: 'Move the steps into a skill under `.claude/skills/`. Keep CLAUDE.md for facts Claude should always know, not step-by-step procedures.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
patterns: [],
|
||||||
|
_default: {
|
||||||
|
title: 'Your setup could fit Claude Code a little better',
|
||||||
|
description: 'A check found a setup that works but where a different mechanism would fit the job better.',
|
||||||
|
recommendation: 'See the details for the suggested change.',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,23 @@ const SCANNER_TO_CATEGORY = {
|
||||||
TOK: 'Wasted tokens',
|
TOK: 'Wasted tokens',
|
||||||
CPS: 'Wasted tokens',
|
CPS: 'Wasted tokens',
|
||||||
SKL: 'Wasted tokens',
|
SKL: 'Wasted tokens',
|
||||||
|
AGT: 'Wasted tokens',
|
||||||
DIS: 'Dead config',
|
DIS: 'Dead config',
|
||||||
GAP: 'Missed opportunity',
|
GAP: 'Missed opportunity',
|
||||||
PLH: 'Configuration mistake',
|
PLH: 'Configuration mistake',
|
||||||
|
OST: 'Configuration mistake',
|
||||||
|
OPT: 'Missed opportunity',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-finding `category` values that override the scanner-default impact label.
|
||||||
|
* Needed when one finding inside a scanner means something different from the
|
||||||
|
* scanner's usual bucket — e.g. stale plugin-cache versions are emitted by TOK
|
||||||
|
* (normally "Wasted tokens") but load on ZERO turns, so their honest impact is
|
||||||
|
* "Dead config" (present on disk, never loaded), not wasted per-turn tokens.
|
||||||
|
*/
|
||||||
|
const CATEGORY_TO_IMPACT = {
|
||||||
|
'plugin-cache-hygiene': 'Dead config',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -123,7 +137,8 @@ export function humanizeFinding(finding) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const translation = lookupTranslation(finding.scanner, finding.title);
|
const translation = lookupTranslation(finding.scanner, finding.title);
|
||||||
const category = SCANNER_TO_CATEGORY[finding.scanner] || 'Other';
|
const category =
|
||||||
|
CATEGORY_TO_IMPACT[finding.category] || SCANNER_TO_CATEGORY[finding.scanner] || 'Other';
|
||||||
const action = SEVERITY_TO_ACTION[finding.severity] || 'FYI';
|
const action = SEVERITY_TO_ACTION[finding.severity] || 'FYI';
|
||||||
const relevance = computeRelevanceContext(finding.file);
|
const relevance = computeRelevanceContext(finding.file);
|
||||||
|
|
||||||
|
|
|
||||||
94
scanners/lib/knowledge-refresh.mjs
Normal file
94
scanners/lib/knowledge-refresh.mjs
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
/**
|
||||||
|
* knowledge-refresh — deterministic freshness core for the best-practices register.
|
||||||
|
*
|
||||||
|
* The "living" half of the v5.7 living knowledge base (Chunk 3). This module is the
|
||||||
|
* PURE, deterministic part of the hybrid `/config-audit knowledge-refresh` motor: given a
|
||||||
|
* register and an injected reference date, it classifies each entry as `fresh` or `stale`
|
||||||
|
* by the age of its `source.verified` stamp. It NEVER touches the network and NEVER writes
|
||||||
|
* — candidate discovery (polling CC changelog + Anthropic blog) and the human-approved
|
||||||
|
* writes live in the command layer (Verifiseringsplikt: no unverified claim is auto-written).
|
||||||
|
*
|
||||||
|
* `referenceDate` is injected (not read from the clock here) so the function is fully
|
||||||
|
* deterministic and unit-testable; the CLI passes today's date. See
|
||||||
|
* docs/v5.7-optimization-lens-plan.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Default re-verify cadence: a confirmed best-practice older than this needs a re-check. */
|
||||||
|
export const STALE_AFTER_DAYS_DEFAULT = 90;
|
||||||
|
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a reference date (Date or YYYY-MM-DD string) to a UTC-midnight {iso, ms}.
|
||||||
|
* Throws TypeError on anything else — the reference date is required and must be valid.
|
||||||
|
*/
|
||||||
|
function normalizeReferenceDate(value) {
|
||||||
|
let iso;
|
||||||
|
if (value instanceof Date) {
|
||||||
|
if (Number.isNaN(value.getTime())) throw new TypeError('referenceDate is an invalid Date');
|
||||||
|
iso = value.toISOString().slice(0, 10);
|
||||||
|
} else if (typeof value === 'string' && DATE_RE.test(value)) {
|
||||||
|
iso = value;
|
||||||
|
} else {
|
||||||
|
throw new TypeError('referenceDate must be a Date or a YYYY-MM-DD string');
|
||||||
|
}
|
||||||
|
const ms = Date.parse(`${iso}T00:00:00Z`);
|
||||||
|
if (Number.isNaN(ms)) throw new TypeError(`referenceDate is not a real calendar date: ${iso}`);
|
||||||
|
return { iso, ms };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an entry's `source.verified` to UTC-midnight ms, or null if missing/unparseable. */
|
||||||
|
function verifiedMs(entry) {
|
||||||
|
const v = entry && entry.source && entry.source.verified;
|
||||||
|
if (typeof v !== 'string' || !DATE_RE.test(v)) return null;
|
||||||
|
const ms = Date.parse(`${v}T00:00:00Z`);
|
||||||
|
return Number.isNaN(ms) ? null : ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify every register entry as fresh or stale by the age of its source.verified stamp.
|
||||||
|
*
|
||||||
|
* @param {{entries:object[]}} register
|
||||||
|
* @param {{ referenceDate: string|Date, staleAfterDays?: number }} opts
|
||||||
|
* @returns {{
|
||||||
|
* referenceDate: string,
|
||||||
|
* staleAfterDays: number,
|
||||||
|
* stale: Array<{id:string, verified:string|undefined, ageDays:number|null, url:string|undefined, claim:string|undefined}>,
|
||||||
|
* fresh: Array<{id:string, verified:string|undefined, ageDays:number}>,
|
||||||
|
* counts: { total:number, stale:number, fresh:number }
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function assessFreshness(register, opts = {}) {
|
||||||
|
const ref = normalizeReferenceDate(opts.referenceDate);
|
||||||
|
const staleAfterDays =
|
||||||
|
typeof opts.staleAfterDays === 'number' ? opts.staleAfterDays : STALE_AFTER_DAYS_DEFAULT;
|
||||||
|
|
||||||
|
const entries = (register && Array.isArray(register.entries)) ? register.entries : [];
|
||||||
|
const stale = [];
|
||||||
|
const fresh = [];
|
||||||
|
|
||||||
|
for (const e of entries) {
|
||||||
|
const verified = e && e.source ? e.source.verified : undefined;
|
||||||
|
const vms = verifiedMs(e);
|
||||||
|
if (vms === null) {
|
||||||
|
// No re-checkable date → needs attention. Stale with ageDays null.
|
||||||
|
stale.push({ id: e && e.id, verified, ageDays: null, url: e && e.source && e.source.url, claim: e && e.claim });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ageDays = Math.floor((ref.ms - vms) / DAY_MS);
|
||||||
|
if (ageDays > staleAfterDays) {
|
||||||
|
stale.push({ id: e.id, verified, ageDays, url: e.source && e.source.url, claim: e.claim });
|
||||||
|
} else {
|
||||||
|
fresh.push({ id: e.id, verified, ageDays });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
referenceDate: ref.iso,
|
||||||
|
staleAfterDays,
|
||||||
|
stale,
|
||||||
|
fresh,
|
||||||
|
counts: { total: entries.length, stale: stale.length, fresh: fresh.length },
|
||||||
|
};
|
||||||
|
}
|
||||||
114
scanners/lib/lens-prefilter.mjs
Normal file
114
scanners/lib/lens-prefilter.mjs
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
/**
|
||||||
|
* lens-prefilter — deterministic, recall-oriented candidate generator for the
|
||||||
|
* v5.7 optimization lens (CA-OPT) hybrid motor, Chunk 2b.
|
||||||
|
*
|
||||||
|
* The OPT *scanner* (Chunk 2a) handles the one mechanism-fit case it can decide
|
||||||
|
* deterministically with high precision (a long numbered procedure → skill). The
|
||||||
|
* other three cases in the register are PROSE-JUDGMENT calls — whether a line is
|
||||||
|
* really lifecycle automation, a path-specific constraint, or an absolute
|
||||||
|
* prohibition depends on reading intent, which a regex cannot settle. So the
|
||||||
|
* hybrid motor splits the work:
|
||||||
|
*
|
||||||
|
* pre-filter (this module, CHEAP, recall-oriented)
|
||||||
|
* → surfaces candidate lines tagged with the register rule they might fit
|
||||||
|
* opus optimization-lens-agent (PRECISION gate)
|
||||||
|
* → reads each candidate in context, keeps only genuine mechanism-fit
|
||||||
|
* opportunities, cites the register rule + source
|
||||||
|
*
|
||||||
|
* Therefore this pre-filter deliberately errs toward recall: a false candidate
|
||||||
|
* costs the agent a moment's judgement, a missed line is never recoverable. It
|
||||||
|
* does, however, avoid the two obvious noise sources — fenced code blocks and
|
||||||
|
* (when the caller passes the parsed body) YAML frontmatter — and it requires an
|
||||||
|
* imperative-looking line for the path-specific class so plain "see docs/x.md"
|
||||||
|
* references don't flood the candidate list.
|
||||||
|
*
|
||||||
|
* The detector names mirror the `lensCheck` fields of the register entries
|
||||||
|
* (knowledge/best-practices.json), so the agent can map each candidate straight
|
||||||
|
* back to its provenance.
|
||||||
|
*
|
||||||
|
* Zero external dependencies. Pure: input text → candidate array.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three prose-judgment detectors, keyed to their register entries.
|
||||||
|
* `mechanism` is the better-fit mechanism the register recommends.
|
||||||
|
*/
|
||||||
|
export const LENS_DETECTORS = Object.freeze([
|
||||||
|
{ lensCheck: 'claude-md-lifecycle-phrasing', registerId: 'BP-MECH-001', mechanism: 'hook' },
|
||||||
|
{ lensCheck: 'unscoped-path-specific-instruction', registerId: 'BP-MECH-002', mechanism: 'rule' },
|
||||||
|
{ lensCheck: 'never-instruction', registerId: 'BP-MECH-004', mechanism: 'permission' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Lifecycle automation phrased as an instruction: "after every commit", "before
|
||||||
|
// each push", "every time you …", "whenever you …", "always run". Recall-first.
|
||||||
|
const LIFECYCLE_RE =
|
||||||
|
/\b(?:after (?:every|each)|before (?:every|each)|on (?:every|each)|every time|each time|always run|whenever)\b/i;
|
||||||
|
|
||||||
|
// Absolute prohibition: a standalone "never" followed by an action word. Kept
|
||||||
|
// permissive (recall); the agent decides whether it is a real hard rule.
|
||||||
|
const NEVER_RE = /\bnever\s+[a-z]/i;
|
||||||
|
|
||||||
|
// A concrete path / glob / known-extension filename anywhere in the line.
|
||||||
|
const PATH_RE =
|
||||||
|
/(?:(?:\.{0,2}\/)?[\w.-]+\/[\w.*/-]+|\*\*?\/[\w.*-]+|\b[\w-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|md|json|ya?ml|toml|go|rs|java|rb|php|c|cpp|h|hpp|sh|sql|css|scss|html|env)\b)/;
|
||||||
|
|
||||||
|
// An imperative / modal verb that marks a line as an instruction rather than a
|
||||||
|
// bare cross-reference. Gates the path-specific class to cut "see foo/bar.md".
|
||||||
|
const INSTRUCTION_RE =
|
||||||
|
/\b(?:use|edit|run|always|must|should|put|place|write|add|modify|update|format|lint|test|name|store|keep|never|generate|build|deploy|commit)\b/i;
|
||||||
|
|
||||||
|
const getDetector = (lensCheck) => LENS_DETECTORS.find((d) => d.lensCheck === lensCheck);
|
||||||
|
|
||||||
|
function candidate(lensCheck, lineNo, lineText) {
|
||||||
|
const d = getDetector(lensCheck);
|
||||||
|
return {
|
||||||
|
lensCheck,
|
||||||
|
registerId: d.registerId,
|
||||||
|
mechanism: d.mechanism,
|
||||||
|
line: lineNo,
|
||||||
|
text: lineText.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan CLAUDE.md text for prose-judgment mechanism-fit candidates.
|
||||||
|
*
|
||||||
|
* Pass the file body (frontmatter stripped) for clean line numbers; the caller
|
||||||
|
* is then responsible for offsetting `line` by the body's start line. Raw text
|
||||||
|
* also works — fenced code is skipped either way.
|
||||||
|
*
|
||||||
|
* @param {string} text
|
||||||
|
* @returns {Array<{lensCheck:string, registerId:string, mechanism:string, line:number, text:string}>}
|
||||||
|
*/
|
||||||
|
export function prefilterClaudeMd(text) {
|
||||||
|
const lines = String(text == null ? '' : text).split('\n');
|
||||||
|
const out = [];
|
||||||
|
let inFence = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const raw = lines[i];
|
||||||
|
const lineNo = i + 1;
|
||||||
|
|
||||||
|
// Toggle fenced code blocks (``` or ~~~). Fence lines themselves are skipped.
|
||||||
|
if (/^\s*(?:```|~~~)/.test(raw)) {
|
||||||
|
inFence = !inFence;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inFence) continue;
|
||||||
|
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === '') continue;
|
||||||
|
|
||||||
|
if (LIFECYCLE_RE.test(raw)) {
|
||||||
|
out.push(candidate('claude-md-lifecycle-phrasing', lineNo, raw));
|
||||||
|
}
|
||||||
|
if (NEVER_RE.test(raw)) {
|
||||||
|
out.push(candidate('never-instruction', lineNo, raw));
|
||||||
|
}
|
||||||
|
if (PATH_RE.test(raw) && INSTRUCTION_RE.test(raw)) {
|
||||||
|
out.push(candidate('unscoped-path-specific-instruction', lineNo, raw));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
206
scanners/lib/mcp-deferral.mjs
Normal file
206
scanners/lib/mcp-deferral.mjs
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
/**
|
||||||
|
* MCP tool-schema deferral assessment (v5.10 B4).
|
||||||
|
*
|
||||||
|
* By default Claude Code DEFERS MCP tool schemas: only tool *names* enter the
|
||||||
|
* always-loaded prefix (~120 tokens total) and full schemas load on demand via
|
||||||
|
* tool search. Certain conditions force ALL full schemas into the always-loaded
|
||||||
|
* prefix instead — paid on every turn whether or not a tool is used.
|
||||||
|
*
|
||||||
|
* This module is a STATIC, CONFIG-FILE assessment. It triggers only on signals
|
||||||
|
* that live in config files a scanner can read deterministically:
|
||||||
|
* - settings.json `env.ENABLE_TOOL_SEARCH` = "false" (HIGH confidence)
|
||||||
|
* - settings.json `permissions.deny` contains "ToolSearch" (HIGH confidence)
|
||||||
|
* - settings.json `model` is a Haiku model (MEDIUM confidence)
|
||||||
|
* - a per-server `.mcp.json` `alwaysLoad: true` (HIGH confidence)
|
||||||
|
*
|
||||||
|
* It deliberately does NOT read process.env shell variables. Tool search is also
|
||||||
|
* disabled on Vertex AI, with a custom ANTHROPIC_BASE_URL (non-first-party host),
|
||||||
|
* or after a runtime `/model` switch to Haiku — but those are launch/runtime
|
||||||
|
* state, not config files, so a static scan cannot see them without becoming
|
||||||
|
* machine-dependent. They are disclosed (DEFERRAL_DISCLOSURE), never triggered.
|
||||||
|
*
|
||||||
|
* Mechanism verified 2026-06-23 against code.claude.com/docs:
|
||||||
|
* context-window.md (MCP tools deferred, ~120 tok), mcp.md#configure-tool-search
|
||||||
|
* + #exempt-a-server-from-deferral, costs.md (reduce MCP server overhead).
|
||||||
|
*
|
||||||
|
* The pure `assessMcpDeferral` takes already-parsed inputs so it is fully
|
||||||
|
* unit-testable without file IO. `assessMcpDeferralForRepo` is the thin IO
|
||||||
|
* wrapper shared by token-hotspots and feature-gap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { readTextFile } from './file-discovery.mjs';
|
||||||
|
import { parseJson } from './yaml-parser.mjs';
|
||||||
|
import { readActiveMcpServers } from './active-config-reader.mjs';
|
||||||
|
|
||||||
|
// Aggregate forced-upfront schema cost (tokens) → severity ladder. MCP token
|
||||||
|
// estimates are base 500 + ~200/tool (active-config-reader estimateTokens), so
|
||||||
|
// these anchor on a couple of small servers (medium) vs a large one / several
|
||||||
|
// (high). Heuristic, not measured — disclosed in every finding.
|
||||||
|
export const FORCED_SCHEMA_TOKENS_MEDIUM = 1500;
|
||||||
|
export const FORCED_SCHEMA_TOKENS_HIGH = 5000;
|
||||||
|
|
||||||
|
// Appended to every CA-TOK-006 finding: the launch/runtime conditions a static
|
||||||
|
// config scan cannot see, so the user knows to check them manually.
|
||||||
|
export const DEFERRAL_DISCLOSURE =
|
||||||
|
'static config-file check: tool search is ALSO disabled (all MCP schemas forced ' +
|
||||||
|
'upfront) on Vertex AI, with a custom ANTHROPIC_BASE_URL (non-first-party host), ' +
|
||||||
|
'or after a runtime /model switch to a Haiku model — none visible to a static ' +
|
||||||
|
'scan, so verify at launch. Tool-level "anthropic/alwaysLoad" set server-side is ' +
|
||||||
|
'likewise invisible. Per-server alwaysLoad requires Claude Code v2.1.121+.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does a permissions.deny list disable the ToolSearch tool? Matches a bare
|
||||||
|
* "ToolSearch" tool name (with or without an argument suffix), same shape Claude
|
||||||
|
* Code uses for built-in tool denies.
|
||||||
|
*/
|
||||||
|
function denyListDisablesToolSearch(deny) {
|
||||||
|
if (!Array.isArray(deny)) return false;
|
||||||
|
return deny.some((entry) => {
|
||||||
|
if (typeof entry !== 'string') return false;
|
||||||
|
const tool = entry.replace(/\(.*\)$/, '').trim();
|
||||||
|
return tool === 'ToolSearch';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHaikuModel(model) {
|
||||||
|
return typeof model === 'string' && /haiku/i.test(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map severity for the forced-upfront aggregate. High-confidence reasons scale
|
||||||
|
* with the token cost; medium-confidence reasons (inferred from a configured
|
||||||
|
* model) are capped at medium so the finding never overstates certainty.
|
||||||
|
*
|
||||||
|
* @param {number} aggregateTokens
|
||||||
|
* @param {'high'|'medium'} confidence
|
||||||
|
* @returns {'high'|'medium'|'low'}
|
||||||
|
*/
|
||||||
|
export function severityForForcedSchemas(aggregateTokens, confidence) {
|
||||||
|
const tok = typeof aggregateTokens === 'number' ? aggregateTokens : 0;
|
||||||
|
if (confidence === 'high') {
|
||||||
|
if (tok >= FORCED_SCHEMA_TOKENS_HIGH) return 'high';
|
||||||
|
if (tok >= FORCED_SCHEMA_TOKENS_MEDIUM) return 'medium';
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
// medium confidence: cap at medium
|
||||||
|
if (tok >= FORCED_SCHEMA_TOKENS_HIGH) return 'medium';
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assess whether MCP tool schemas are forced into the always-loaded prefix.
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {{ env?: object, permissions?: { deny?: string[] }, model?: string }} [args.settings]
|
||||||
|
* merged settings.json view (env block, permissions, model).
|
||||||
|
* @param {Array<{name:string, source?:string, enabled?:boolean, toolCount?:number,
|
||||||
|
* estimatedTokens?:number, alwaysLoad?:boolean}>} [args.mcpServers]
|
||||||
|
* @returns {{
|
||||||
|
* toolSearchDisabled: boolean, reason: string|null,
|
||||||
|
* confidence: 'high'|'medium'|null, thresholdMode: boolean,
|
||||||
|
* alwaysLoadServers: object[], affectedServers: object[],
|
||||||
|
* aggregateTokens: number, forcedUpfront: boolean,
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function assessMcpDeferral({ settings = {}, mcpServers = [] } = {}) {
|
||||||
|
const s = settings || {};
|
||||||
|
const tsRaw = s.env && typeof s.env === 'object' ? s.env.ENABLE_TOOL_SEARCH : undefined;
|
||||||
|
const ts = String(tsRaw ?? '').trim().toLowerCase();
|
||||||
|
const denyTS = denyListDisablesToolSearch(s.permissions?.deny);
|
||||||
|
const haiku = isHaikuModel(s.model);
|
||||||
|
|
||||||
|
let toolSearchDisabled = false;
|
||||||
|
let reason = null;
|
||||||
|
let confidence = null;
|
||||||
|
let thresholdMode = false;
|
||||||
|
|
||||||
|
if (ts === 'false') {
|
||||||
|
toolSearchDisabled = true;
|
||||||
|
reason = 'enable-tool-search-false';
|
||||||
|
confidence = 'high';
|
||||||
|
} else if (denyTS) {
|
||||||
|
toolSearchDisabled = true;
|
||||||
|
reason = 'deny-tool-search';
|
||||||
|
confidence = 'high';
|
||||||
|
} else if (haiku) {
|
||||||
|
// Haiku lacks tool_reference support, so tool search cannot run even when
|
||||||
|
// ENABLE_TOOL_SEARCH=true. Medium confidence: the configured model can be
|
||||||
|
// switched at runtime (/model), which a static scan cannot observe.
|
||||||
|
toolSearchDisabled = true;
|
||||||
|
reason = 'haiku-model';
|
||||||
|
confidence = 'medium';
|
||||||
|
} else if (ts === 'true') {
|
||||||
|
toolSearchDisabled = false;
|
||||||
|
} else if (ts.startsWith('auto')) {
|
||||||
|
// Threshold mode (auto / auto:N): schemas load upfront only when they fit a
|
||||||
|
// percentage of the context window — not a clear always-load. Info, not a
|
||||||
|
// forced-upfront trigger.
|
||||||
|
thresholdMode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = (Array.isArray(mcpServers) ? mcpServers : []).filter(
|
||||||
|
(m) => m && m.enabled !== false,
|
||||||
|
);
|
||||||
|
const alwaysLoadServers = active.filter((m) => m.alwaysLoad === true);
|
||||||
|
const affectedServers = toolSearchDisabled ? active : alwaysLoadServers;
|
||||||
|
const aggregateTokens = affectedServers.reduce(
|
||||||
|
(sum, m) => sum + (typeof m.estimatedTokens === 'number' ? m.estimatedTokens : 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
toolSearchDisabled,
|
||||||
|
reason,
|
||||||
|
confidence,
|
||||||
|
thresholdMode,
|
||||||
|
alwaysLoadServers,
|
||||||
|
affectedServers,
|
||||||
|
aggregateTokens,
|
||||||
|
forcedUpfront: affectedServers.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge project + local settings.json for the deferral check. Scoped to the
|
||||||
|
* audited path (NOT the user cascade) so the result stays deterministic and free
|
||||||
|
* of ambient HOME leakage — mirrors Pattern G's project-local scoping. Local
|
||||||
|
* overrides project; env/permissions shallow-merge, deny lists concatenate.
|
||||||
|
*/
|
||||||
|
async function readMergedProjectSettings(repoPath) {
|
||||||
|
const merged = { env: {}, permissions: {}, model: undefined };
|
||||||
|
for (const rel of ['.claude/settings.json', '.claude/settings.local.json']) {
|
||||||
|
const content = await readTextFile(resolve(repoPath, rel));
|
||||||
|
if (!content) continue;
|
||||||
|
const parsed = parseJson(content);
|
||||||
|
if (!parsed || typeof parsed !== 'object') continue;
|
||||||
|
if (parsed.env && typeof parsed.env === 'object') Object.assign(merged.env, parsed.env);
|
||||||
|
if (parsed.permissions && typeof parsed.permissions === 'object') {
|
||||||
|
const deny = [
|
||||||
|
...(Array.isArray(merged.permissions.deny) ? merged.permissions.deny : []),
|
||||||
|
...(Array.isArray(parsed.permissions.deny) ? parsed.permissions.deny : []),
|
||||||
|
];
|
||||||
|
merged.permissions = { ...merged.permissions, ...parsed.permissions, deny };
|
||||||
|
}
|
||||||
|
if (typeof parsed.model === 'string') merged.model = parsed.model;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IO wrapper: assess MCP deferral for a repo path. Scopes MCP servers to active
|
||||||
|
* project-local `.mcp.json` (plugin / ~/.claude.json servers are the manifest's
|
||||||
|
* concern). Pass `mcpServers` (e.g. an already-loaded activeConfig.mcpServers) to
|
||||||
|
* avoid a re-read; otherwise they are read via readActiveMcpServers.
|
||||||
|
*
|
||||||
|
* @param {string} repoPath
|
||||||
|
* @param {{ mcpServers?: object[] }} [opts]
|
||||||
|
*/
|
||||||
|
export async function assessMcpDeferralForRepo(repoPath, opts = {}) {
|
||||||
|
const all = Array.isArray(opts.mcpServers)
|
||||||
|
? opts.mcpServers
|
||||||
|
: await readActiveMcpServers(repoPath);
|
||||||
|
const projectLocal = all.filter((m) => m && m.enabled && m.source === '.mcp.json');
|
||||||
|
const settings = await readMergedProjectSettings(repoPath);
|
||||||
|
return assessMcpDeferral({ settings, mcpServers: projectLocal });
|
||||||
|
}
|
||||||
|
|
@ -55,8 +55,12 @@ export function paramMatches(pattern, value) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Does the deny entry fully cover the allow entry, making the allow dead config?
|
* Does the deny entry fully cover the allow entry, making the allow dead config?
|
||||||
* Used by DIS. Bare deny covers everything; a specific deny only covers the
|
* Used by DIS. Bare deny — and the equivalent `Tool(*)` deny-all glob — covers
|
||||||
* matching (or wildcard-subsumed) param — and does NOT cover a bare allow.
|
* everything (including a bare allow); a specific deny only covers the matching
|
||||||
|
* (or wildcard-subsumed) param and does NOT kill a bare allow.
|
||||||
|
*
|
||||||
|
* CC: "`Bash(*)` is equivalent to `Bash` ... As a deny rule, both forms remove
|
||||||
|
* the tool from Claude's context." (code.claude.com/docs/en/permissions)
|
||||||
* @param {string} denyEntry
|
* @param {string} denyEntry
|
||||||
* @param {string} allowEntry
|
* @param {string} allowEntry
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
|
|
@ -65,7 +69,7 @@ export function dominates(denyEntry, allowEntry) {
|
||||||
const d = parseRule(denyEntry);
|
const d = parseRule(denyEntry);
|
||||||
const a = parseRule(allowEntry);
|
const a = parseRule(allowEntry);
|
||||||
if (!d.tool || !a.tool || d.tool !== a.tool) return false;
|
if (!d.tool || !a.tool || d.tool !== a.tool) return false;
|
||||||
if (d.param === null) return true; // bare deny covers all params
|
if (d.param === null || d.param === '*') return true; // bare / Tool(*) deny covers all params
|
||||||
if (a.param === null) return false; // specific deny does not kill a bare allow
|
if (a.param === null) return false; // specific deny does not kill a bare allow
|
||||||
if (d.param === a.param) return true;
|
if (d.param === a.param) return true;
|
||||||
return paramMatches(d.param, a.param); // wildcard deny covers a matching literal allow
|
return paramMatches(d.param, a.param); // wildcard deny covers a matching literal allow
|
||||||
|
|
@ -87,3 +91,96 @@ export function rulesIntersect(ruleA, ruleB) {
|
||||||
if (a.param === b.param) return true;
|
if (a.param === b.param) return true;
|
||||||
return paramMatches(a.param, b.param) || paramMatches(b.param, a.param);
|
return paramMatches(a.param, b.param) || paramMatches(b.param, a.param);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this `permissions.allow` entry an UNANCHORED tool-name glob that Claude
|
||||||
|
* Code silently skips? CC accepts tool-name globs in an ALLOW rule only after a
|
||||||
|
* literal `mcp__<server>__` prefix (the server segment must be glob-free).
|
||||||
|
* Unanchored globs like `*`, `B*`, or `mcp__*` are skipped with a warning and
|
||||||
|
* auto-approve nothing — dead config the author believes is granting access.
|
||||||
|
*
|
||||||
|
* Tool-name globs apply to the bare-name form only (no `(...)` specifier); a
|
||||||
|
* glob INSIDE a specifier such as `Bash(npm run *)` is normal and valid.
|
||||||
|
*
|
||||||
|
* CC: "An unanchored allow glob such as `"*"`, `"B*"`, or `"mcp__*"` is skipped
|
||||||
|
* with a warning and does not auto-approve anything."
|
||||||
|
* (code.claude.com/docs/en/permissions — "Tool name wildcards")
|
||||||
|
*
|
||||||
|
* NOTE: deny/ask rules DO accept tool-name globs, so this predicate is for the
|
||||||
|
* allow list only.
|
||||||
|
* @param {string} entry
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isIneffectiveAllowGlob(entry) {
|
||||||
|
if (typeof entry !== 'string') return false;
|
||||||
|
if (entry.includes('(')) return false; // specifier form — glob lives inside the param
|
||||||
|
if (!entry.includes('*')) return false; // no glob — a normal match-all-tool allow
|
||||||
|
if (entry.startsWith('mcp__')) {
|
||||||
|
const rest = entry.slice(5); // after 'mcp__'
|
||||||
|
const sep = rest.indexOf('__');
|
||||||
|
// mcp__<server>__<rest> with a glob-free server segment is anchored & valid
|
||||||
|
if (sep > 0 && !rest.slice(0, sep).includes('*')) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tools whose canonicalizing input field collides with `Tool(param:value)`
|
||||||
|
* matching. CC ignores a rule whose param key is the tool's own field and
|
||||||
|
* emits a startup warning, because the rule would be bypassable (e.g. a
|
||||||
|
* compound command defeats `Bash(command:rm *)`).
|
||||||
|
*
|
||||||
|
* CC: "Fields that a tool already matches with its own canonicalizing rules are
|
||||||
|
* not matchable this way: `command` for Bash and PowerShell, `file_path` for
|
||||||
|
* Read, Edit, and Write, `path` for Grep and Glob, `notebook_path` for
|
||||||
|
* NotebookEdit, and `url` for WebFetch."
|
||||||
|
* (code.claude.com/docs/en/permissions — "Match by input parameter")
|
||||||
|
*/
|
||||||
|
const FORBIDDEN_PARAMS = Object.freeze({
|
||||||
|
Bash: 'command',
|
||||||
|
PowerShell: 'command',
|
||||||
|
Read: 'file_path',
|
||||||
|
Edit: 'file_path',
|
||||||
|
Write: 'file_path',
|
||||||
|
Grep: 'path',
|
||||||
|
Glob: 'path',
|
||||||
|
NotebookEdit: 'notebook_path',
|
||||||
|
WebFetch: 'url',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Correct specifier syntax to suggest in place of the forbidden param form. */
|
||||||
|
const FORBIDDEN_PARAM_HINT = Object.freeze({
|
||||||
|
Bash: 'Bash(rm *)',
|
||||||
|
PowerShell: 'PowerShell(Remove-Item *)',
|
||||||
|
Read: 'Read(./path)',
|
||||||
|
Edit: 'Edit(/src/**)',
|
||||||
|
Write: 'Write(/src/**)',
|
||||||
|
Grep: 'a Read rule (covers Grep)',
|
||||||
|
Glob: 'a Read rule (covers Glob)',
|
||||||
|
NotebookEdit: 'Edit(/notebooks/**)',
|
||||||
|
WebFetch: 'WebFetch(domain:host)',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this entry a `Tool(param:value)` rule whose param KEY is the tool's own
|
||||||
|
* canonicalizing field? CC silently ignores these (any list) and emits a
|
||||||
|
* startup warning. Returns `{ tool, key, hint }` or `null`.
|
||||||
|
*
|
||||||
|
* Only the `param:value` form (a colon present) is forbidden — `Bash(command)`
|
||||||
|
* is a literal command-prefix match and stays valid. The key must equal the
|
||||||
|
* tool's forbidden field, so `Bash(npm:*)`, `WebFetch(domain:x)`, and
|
||||||
|
* `Agent(model:opus)` are NOT flagged.
|
||||||
|
* @param {string} entry
|
||||||
|
* @returns {{ tool: string, key: string, hint: string }|null}
|
||||||
|
*/
|
||||||
|
export function forbiddenParamRule(entry) {
|
||||||
|
const { tool, param } = parseRule(entry);
|
||||||
|
if (!tool || param === null) return null;
|
||||||
|
const forbidden = FORBIDDEN_PARAMS[tool];
|
||||||
|
if (!forbidden) return null;
|
||||||
|
const colon = param.indexOf(':');
|
||||||
|
if (colon === -1) return null; // no `param:value` — literal specifier, valid
|
||||||
|
const key = param.slice(0, colon).trim();
|
||||||
|
if (key !== forbidden) return null;
|
||||||
|
return { tool, key, hint: FORBIDDEN_PARAM_HINT[tool] };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,8 +166,11 @@ const SCANNER_AREA_MAP = {
|
||||||
TOK: 'Token Efficiency',
|
TOK: 'Token Efficiency',
|
||||||
CPS: 'Token Efficiency',
|
CPS: 'Token Efficiency',
|
||||||
SKL: 'Token Efficiency',
|
SKL: 'Token Efficiency',
|
||||||
|
AGT: 'Token Efficiency',
|
||||||
DIS: 'Settings',
|
DIS: 'Settings',
|
||||||
COL: 'Plugin Hygiene',
|
COL: 'Plugin Hygiene',
|
||||||
|
OST: 'Settings',
|
||||||
|
OPT: 'CLAUDE.md',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
199
scanners/lib/skill-listing-budget.mjs
Normal file
199
scanners/lib/skill-listing-budget.mjs
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
/**
|
||||||
|
* Skill-listing budget — single source of truth.
|
||||||
|
*
|
||||||
|
* Claude Code shows the model a listing of every active skill's `description`
|
||||||
|
* so it can decide which skill to invoke. That listing is budgeted two ways:
|
||||||
|
* - per description: capped at 1,536 chars (CC 2.1.105, changelog L1502);
|
||||||
|
* anything past the cap is silently truncated.
|
||||||
|
* - in aggregate: the whole listing is allotted ~2% of the context window
|
||||||
|
* (CC 2.1.32, changelog L2860). We do NOT know the user's context window,
|
||||||
|
* so the aggregate budget anchors on a conservative 200k window (4,000 tok)
|
||||||
|
* and discloses the assumption.
|
||||||
|
*
|
||||||
|
* Two scanners consume this module so the budget is defined in exactly one place:
|
||||||
|
* - SKL (skill-listing-scanner) DIAGNOSES overflow (CA-SKL-001 per-description
|
||||||
|
* cap, CA-SKL-002 aggregate).
|
||||||
|
* - GAP (feature-gap-scanner) PRESCRIBES the remedy: when the listing is over
|
||||||
|
* budget and `disableBundledSkills` is un-pulled, it recommends that lever.
|
||||||
|
*
|
||||||
|
* Zero external dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { estimateTokens, enumeratePlugins, enumerateSkills } from './active-config-reader.mjs';
|
||||||
|
import { readTextFile } from './file-discovery.mjs';
|
||||||
|
import { parseFrontmatter, parseJson } from './yaml-parser.mjs';
|
||||||
|
import { CONTEXT_WINDOW_ANCHOR, LARGE_CONTEXT_WINDOW, withCommas } from './context-window.mjs';
|
||||||
|
|
||||||
|
// Verified per-description skill-listing cap (CC 2.1.105, changelog L1502).
|
||||||
|
// Descriptions longer than this are truncated in the listing the model sees.
|
||||||
|
export const DESCRIPTION_CAP = 1536;
|
||||||
|
|
||||||
|
// Aggregate listing budget (CC 2.1.32, changelog L2860): the skill listing the
|
||||||
|
// model reads is allotted ~2% of the context window. The context window is
|
||||||
|
// unknown, so we anchor on a conservative 200k window — the smallest common
|
||||||
|
// size, which fires earliest — and disclose the assumption in the evidence.
|
||||||
|
// The 200k/1M window constants live in context-window.mjs (single source of
|
||||||
|
// truth, shared with the CML CLAUDE.md char-budget check); re-exported here so
|
||||||
|
// existing importers of this module keep working.
|
||||||
|
export const BUDGET_FRACTION = 0.02;
|
||||||
|
export const AGGREGATE_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * CONTEXT_WINDOW_ANCHOR); // 4000
|
||||||
|
export const LARGE_CONTEXT_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * LARGE_CONTEXT_WINDOW); // 20000
|
||||||
|
export { CONTEXT_WINDOW_ANCHOR, LARGE_CONTEXT_WINDOW, withCommas };
|
||||||
|
|
||||||
|
// The honest framing required because the budget depends on a context window we
|
||||||
|
// cannot observe (jf. TOK CALIBRATION_NOTE). Appended to budget-overflow evidence.
|
||||||
|
export const BUDGET_CALIBRATION_NOTE =
|
||||||
|
'the budget scales with the context window - this anchors on a conservative 200k ' +
|
||||||
|
`window; at ${withCommas(LARGE_CONTEXT_WINDOW)} context the budget is ~${withCommas(LARGE_CONTEXT_BUDGET_TOKENS)} ` +
|
||||||
|
'tok and you are likely within it. this is an estimate, not measured telemetry';
|
||||||
|
|
||||||
|
// Skill-body size guidance (CA-SKL-003). A SKILL.md body over ~5,000 tokens
|
||||||
|
// (~500 lines / ~20k chars) should split reference content into supporting files
|
||||||
|
// (Claude Code skill-authoring guidance). Unlike the listing budget above, the
|
||||||
|
// body is an ON-DEMAND cost: it loads only when the skill is invoked, not every
|
||||||
|
// turn — so this is a LOW-severity efficiency signal, not an always-loaded bill.
|
||||||
|
export const BODY_TOKEN_THRESHOLD = 5000;
|
||||||
|
|
||||||
|
// Honest framing for the body-size finding: distinguishes on-demand from
|
||||||
|
// always-loaded cost and flags the figure as an estimate. Appended to evidence.
|
||||||
|
export const BODY_CALIBRATION_NOTE =
|
||||||
|
'this is the skill BODY (SKILL.md below the frontmatter), which loads ON DEMAND only when the ' +
|
||||||
|
'skill is invoked - NOT every turn like the always-loaded listing. estimate (chars/4), not measured telemetry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} BudgetAssessment
|
||||||
|
* @property {number} scanned - number of descriptions assessed
|
||||||
|
* @property {number} aggregateChars - sum of each length capped at DESCRIPTION_CAP
|
||||||
|
* @property {number} aggregateTokens - estimateTokens(aggregateChars, 'markdown')
|
||||||
|
* @property {number} budgetTokens - AGGREGATE_BUDGET_TOKENS (the 200k-anchored budget)
|
||||||
|
* @property {boolean} overBudget - aggregateTokens strictly greater than budgetTokens
|
||||||
|
* @property {number} overBy - tokens over budget (0 when not over)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure aggregate-budget assessment. Each description contributes only up to the
|
||||||
|
* cap (the tail past the cap is dropped from the listing, and CA-SKL-001 already
|
||||||
|
* flags it — so the aggregate does not double-count it).
|
||||||
|
*
|
||||||
|
* @param {number[]} descLengths - one entry per active skill (description char count)
|
||||||
|
* @param {number} [budgetTokens=AGGREGATE_BUDGET_TOKENS] - the listing budget to
|
||||||
|
* measure against. Defaults to the 200k-anchored 4,000 tok; B8 passes a
|
||||||
|
* window-calibrated budget. Defaulting keeps existing callers byte-stable.
|
||||||
|
* @returns {BudgetAssessment}
|
||||||
|
*/
|
||||||
|
export function assessSkillListingBudget(descLengths, budgetTokens = AGGREGATE_BUDGET_TOKENS) {
|
||||||
|
let aggregateChars = 0;
|
||||||
|
for (const len of descLengths) {
|
||||||
|
const safe = (typeof len === 'number' && Number.isFinite(len) && len > 0) ? len : 0;
|
||||||
|
aggregateChars += Math.min(safe, DESCRIPTION_CAP);
|
||||||
|
}
|
||||||
|
const aggregateTokens = estimateTokens(aggregateChars, 'markdown');
|
||||||
|
const overBudget = aggregateTokens > budgetTokens;
|
||||||
|
return {
|
||||||
|
scanned: descLengths.length,
|
||||||
|
aggregateChars,
|
||||||
|
aggregateTokens,
|
||||||
|
budgetTokens,
|
||||||
|
overBudget,
|
||||||
|
overBy: overBudget ? aggregateTokens - budgetTokens : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} ActiveSkillEntry
|
||||||
|
* @property {string} name
|
||||||
|
* @property {'user'|'plugin'} source
|
||||||
|
* @property {string|null} pluginName
|
||||||
|
* @property {string} path
|
||||||
|
* @property {number} descLength
|
||||||
|
* @property {number} bodyChars - SKILL.md body length below the frontmatter (on-demand cost)
|
||||||
|
* @property {number} bodyLines - body line count
|
||||||
|
* @property {number} bodyTokens - estimateTokens(bodyChars, 'markdown')
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerate every active skill (user + plugin) and measure the listing budget.
|
||||||
|
* HOME-scoped: resolves ~/.claude via process.env.HOME (enumeratePlugins /
|
||||||
|
* enumerateSkills). Callers that run under test MUST override HOME (see the
|
||||||
|
* hermetic-home helper / runScannerWithHome pattern).
|
||||||
|
*
|
||||||
|
* @param {number} [budgetTokens=AGGREGATE_BUDGET_TOKENS] - listing budget for the
|
||||||
|
* aggregate assessment (B8 window-calibration); defaults keep callers byte-stable.
|
||||||
|
* @returns {Promise<{ skills: ActiveSkillEntry[], aggregate: BudgetAssessment }>}
|
||||||
|
*/
|
||||||
|
export async function measureActiveSkillListing(budgetTokens = AGGREGATE_BUDGET_TOKENS) {
|
||||||
|
const plugins = await enumeratePlugins();
|
||||||
|
const allSkills = await enumerateSkills(plugins);
|
||||||
|
|
||||||
|
const skills = [];
|
||||||
|
for (const skill of allSkills) {
|
||||||
|
if (!skill || typeof skill.path !== 'string') continue;
|
||||||
|
const content = await readTextFile(skill.path);
|
||||||
|
if (!content) continue;
|
||||||
|
const parsed = parseFrontmatter(content);
|
||||||
|
const fm = parsed?.frontmatter || null;
|
||||||
|
const desc = (fm && typeof fm.description === 'string') ? fm.description : '';
|
||||||
|
const body = (parsed && typeof parsed.body === 'string') ? parsed.body : '';
|
||||||
|
const bodyChars = body.length;
|
||||||
|
skills.push({
|
||||||
|
name: skill.name,
|
||||||
|
source: skill.source,
|
||||||
|
pluginName: skill.pluginName,
|
||||||
|
path: skill.path,
|
||||||
|
descLength: desc.length,
|
||||||
|
bodyChars,
|
||||||
|
bodyLines: bodyChars === 0 ? 0 : body.split('\n').length,
|
||||||
|
bodyTokens: estimateTokens(bodyChars, 'markdown'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregate = assessSkillListingBudget(skills.map((s) => s.descLength), budgetTokens);
|
||||||
|
return { skills, aggregate };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read an env flag, treating null, "", "0", "false", "no", "off" as un-set.
|
||||||
|
* @param {string|undefined} v
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function envFlag(v) {
|
||||||
|
if (v == null) return false;
|
||||||
|
const s = String(v).trim().toLowerCase();
|
||||||
|
return s !== '' && s !== '0' && s !== 'false' && s !== 'no' && s !== 'off';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve whether the `disableBundledSkills` lever is effectively ON, reading the
|
||||||
|
* env var and the settings cascade directly (user ~/.claude, then project, then
|
||||||
|
* project-local).
|
||||||
|
*
|
||||||
|
* Reads the files directly rather than relying on config-discovery
|
||||||
|
* classification: when discovery walks ~/.claude from the .claude root, the
|
||||||
|
* user settings.json has a relPath of "settings.json" (no ".claude" segment)
|
||||||
|
* and is NOT tagged as settings-json — so the dominant user-scope location for
|
||||||
|
* this global preference would otherwise be missed. HOME-scoped via
|
||||||
|
* process.env.HOME.
|
||||||
|
*
|
||||||
|
* @param {string} [projectPath] - project root, to also read project + local settings
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function isBundledSkillsDisabled(projectPath) {
|
||||||
|
if (envFlag(process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS)) return true;
|
||||||
|
|
||||||
|
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||||
|
const candidates = [];
|
||||||
|
if (home) candidates.push(join(home, '.claude', 'settings.json'));
|
||||||
|
if (projectPath) {
|
||||||
|
candidates.push(join(projectPath, '.claude', 'settings.json'));
|
||||||
|
candidates.push(join(projectPath, '.claude', 'settings.local.json'));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of candidates) {
|
||||||
|
const content = await readTextFile(p);
|
||||||
|
if (!content) continue;
|
||||||
|
const parsed = parseJson(content);
|
||||||
|
if (parsed && parsed.disableBundledSkills === true) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,41 @@ export function isSimilar(a, b, threshold = 0.8) {
|
||||||
return similarity >= threshold;
|
return similarity >= threshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Levenshtein edit distance between two strings (insertions, deletions,
|
||||||
|
* substitutions; a transposition counts as 2). Used for typo detection on
|
||||||
|
* settings keys. Zero external dependencies, O(a*b) with two rolling rows.
|
||||||
|
* @param {string} a
|
||||||
|
* @param {string} b
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function levenshtein(a, b) {
|
||||||
|
if (a === b) return 0;
|
||||||
|
const al = a.length;
|
||||||
|
const bl = b.length;
|
||||||
|
if (al === 0) return bl;
|
||||||
|
if (bl === 0) return al;
|
||||||
|
let prev = new Array(bl + 1);
|
||||||
|
let curr = new Array(bl + 1);
|
||||||
|
for (let j = 0; j <= bl; j++) prev[j] = j;
|
||||||
|
for (let i = 1; i <= al; i++) {
|
||||||
|
curr[0] = i;
|
||||||
|
const ac = a.charCodeAt(i - 1);
|
||||||
|
for (let j = 1; j <= bl; j++) {
|
||||||
|
const cost = ac === b.charCodeAt(j - 1) ? 0 : 1;
|
||||||
|
curr[j] = Math.min(
|
||||||
|
prev[j] + 1, // deletion
|
||||||
|
curr[j - 1] + 1, // insertion
|
||||||
|
prev[j - 1] + cost, // substitution
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const tmp = prev;
|
||||||
|
prev = curr;
|
||||||
|
curr = tmp;
|
||||||
|
}
|
||||||
|
return prev[bl];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract all key-like patterns from a settings.json or similar config.
|
* Extract all key-like patterns from a settings.json or similar config.
|
||||||
* @param {object} obj
|
* @param {object} obj
|
||||||
|
|
|
||||||
|
|
@ -34,17 +34,51 @@ export function parseSimpleYaml(yaml) {
|
||||||
let currentKey = null;
|
let currentKey = null;
|
||||||
let multiLineValue = '';
|
let multiLineValue = '';
|
||||||
let inMultiLine = false;
|
let inMultiLine = false;
|
||||||
|
// Block-sequence state: an empty-valued key may be a YAML block sequence
|
||||||
|
// key:
|
||||||
|
// - item
|
||||||
|
// - item
|
||||||
|
// We defer the decision until the next line: a `- item` line starts an array;
|
||||||
|
// anything else (or end-of-input) leaves the empty-valued key as `null` —
|
||||||
|
// indistinguishable from a plain null value, preserving backwards compatibility.
|
||||||
|
let seqKey = null; // key awaiting/collecting block-sequence items
|
||||||
|
let seqItems = null; // null until the first `- ` item is seen
|
||||||
|
let seqPending = false; // an empty-valued key was seen; items may follow
|
||||||
|
|
||||||
|
// Commit the current block-sequence (or empty-valued key) to result.
|
||||||
|
const commitSeq = () => {
|
||||||
|
if (seqKey !== null) {
|
||||||
|
result[normalizeKey(seqKey)] = seqItems !== null ? seqItems : null;
|
||||||
|
}
|
||||||
|
seqKey = null;
|
||||||
|
seqItems = null;
|
||||||
|
seqPending = false;
|
||||||
|
};
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
// Skip comments and empty lines
|
// Skip comments and empty lines (these do NOT terminate a block sequence)
|
||||||
if (line.trim().startsWith('#') || line.trim() === '') {
|
if (line.trim().startsWith('#') || line.trim() === '') {
|
||||||
if (inMultiLine) multiLineValue += '\n';
|
if (inMultiLine) multiLineValue += '\n';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block-sequence item: only while a sequence is pending or active
|
||||||
|
const seqMatch = (seqPending || seqItems !== null) && !inMultiLine
|
||||||
|
? line.match(/^\s+-\s*(.*)$/)
|
||||||
|
: null;
|
||||||
|
if (seqMatch) {
|
||||||
|
if (seqItems === null) seqItems = [];
|
||||||
|
const itemVal = parseValue(seqMatch[1].trim());
|
||||||
|
if (itemVal !== null && itemVal !== '') seqItems.push(itemVal);
|
||||||
|
seqPending = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Key-value pair
|
// Key-value pair
|
||||||
const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)/);
|
const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)/);
|
||||||
if (kvMatch && !inMultiLine) {
|
if (kvMatch && !inMultiLine) {
|
||||||
|
// A new key terminates any pending/active block sequence.
|
||||||
|
commitSeq();
|
||||||
if (currentKey && multiLineValue) {
|
if (currentKey && multiLineValue) {
|
||||||
result[normalizeKey(currentKey)] = multiLineValue.trim();
|
result[normalizeKey(currentKey)] = multiLineValue.trim();
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +92,15 @@ export function parseSimpleYaml(yaml) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (value === '') {
|
||||||
|
// Defer: this may be a block sequence (next line) or a null value.
|
||||||
|
seqKey = currentKey;
|
||||||
|
seqItems = null;
|
||||||
|
seqPending = true;
|
||||||
|
currentKey = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
result[normalizeKey(currentKey)] = parseValue(value);
|
result[normalizeKey(currentKey)] = parseValue(value);
|
||||||
currentKey = null;
|
currentKey = null;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -79,6 +122,13 @@ export function parseSimpleYaml(yaml) {
|
||||||
currentKey = null;
|
currentKey = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-item, non-key line while a sequence is pending/active terminates it
|
||||||
|
// (then the line is ignored, as before the fix).
|
||||||
|
if (seqPending || seqItems !== null) {
|
||||||
|
commitSeq();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,6 +136,8 @@ export function parseSimpleYaml(yaml) {
|
||||||
if (inMultiLine && currentKey) {
|
if (inMultiLine && currentKey) {
|
||||||
result[normalizeKey(currentKey)] = multiLineValue.trim();
|
result[normalizeKey(currentKey)] = multiLineValue.trim();
|
||||||
}
|
}
|
||||||
|
// Flush a trailing block sequence / empty-valued key
|
||||||
|
commitSeq();
|
||||||
|
|
||||||
// Normalize arrays for known list fields
|
// Normalize arrays for known list fields
|
||||||
for (const field of ['allowed_tools', 'tools', 'paths', 'globs']) {
|
for (const field of ['allowed_tools', 'tools', 'paths', 'globs']) {
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,29 @@
|
||||||
* {
|
* {
|
||||||
* meta: { repoPath, generatedAt, durationMs },
|
* meta: { repoPath, generatedAt, durationMs },
|
||||||
* sources: [
|
* sources: [
|
||||||
* { kind: 'claude-md'|'plugin'|'skill'|'mcp-server'|'hook',
|
* { kind: 'claude-md'|'skill'|'rule'|'agent'|'output-style'|'mcp-server'|'hook',
|
||||||
* name: string, source: string, estimated_tokens: number },
|
* name: string, source: string, estimated_tokens: number,
|
||||||
|
* loadPattern: 'always'|'on-demand'|'external'|'unknown',
|
||||||
|
* survivesCompaction: 'yes'|'no'|'n/a',
|
||||||
|
* derivationConfidence: 'confirmed'|'inferred' },
|
||||||
* ...
|
* ...
|
||||||
* ],
|
* ],
|
||||||
|
* summary: {
|
||||||
|
* always: { tokens, count }, // enter context every turn before you type
|
||||||
|
* onDemand: { tokens, count }, // loaded on invoke / on file read
|
||||||
|
* external: { tokens, count }, // run outside the context window (hooks)
|
||||||
|
* unknown: { tokens, count },
|
||||||
|
* },
|
||||||
* total: <sum of sources.estimated_tokens>
|
* total: <sum of sources.estimated_tokens>
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
|
* v5.6 B — load-pattern accounting. Sources are component-level: the coarse
|
||||||
|
* "plugin" roll-up was dropped because a plugin's contributions (skills, rules,
|
||||||
|
* agents, output styles, hooks, MCP) are each enumerated once on their own —
|
||||||
|
* keeping the roll-up double-counted them and corrupted the always-loaded
|
||||||
|
* subtotal. Every record now carries the load pattern derived from the
|
||||||
|
* published Claude Code loading model (deriveLoadPattern).
|
||||||
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* node manifest.mjs [path] [--json] [--output-file <path>]
|
* node manifest.mjs [path] [--json] [--output-file <path>]
|
||||||
*
|
*
|
||||||
|
|
@ -25,64 +41,188 @@
|
||||||
|
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { writeFile, stat } from 'node:fs/promises';
|
import { writeFile, stat } from 'node:fs/promises';
|
||||||
import { readActiveConfig } from './lib/active-config-reader.mjs';
|
import { readActiveConfig, deriveLoadPattern } from './lib/active-config-reader.mjs';
|
||||||
|
|
||||||
|
// CLAUDE.md cascade files are all discovered by walking UP from the repo, so
|
||||||
|
// each one is always-loaded; the scope only changes the derivation confidence.
|
||||||
|
const CLAUDE_MD_SCOPE_KIND = {
|
||||||
|
project: 'claude-md-root',
|
||||||
|
local: 'claude-md-root',
|
||||||
|
user: 'claude-md-user',
|
||||||
|
managed: 'claude-md-managed',
|
||||||
|
import: 'claude-md-import',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Spread the three load-pattern fields onto a source record. */
|
||||||
|
function withLoadPattern(record, lp) {
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
loadPattern: lp.loadPattern,
|
||||||
|
survivesCompaction: lp.survivesCompaction,
|
||||||
|
derivationConfidence: lp.derivationConfidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLabel = (item, fallback) =>
|
||||||
|
item.pluginName ? `plugin:${item.pluginName}` : item.source || fallback;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flatten an activeConfig snapshot into a single ranked array of sources.
|
* Flatten an activeConfig snapshot into a single ranked array of sources, each
|
||||||
|
* tagged with its load pattern, plus a load-pattern summary.
|
||||||
*/
|
*/
|
||||||
export function buildManifest(activeConfig) {
|
export function buildManifest(activeConfig) {
|
||||||
const sources = [];
|
const sources = [];
|
||||||
|
|
||||||
for (const f of activeConfig.claudeMd?.files || []) {
|
for (const f of activeConfig.claudeMd?.files || []) {
|
||||||
const tokens = estimateClaudeMdEntryTokens(f, activeConfig);
|
const tokens = estimateClaudeMdEntryTokens(f, activeConfig);
|
||||||
sources.push({
|
const kind = CLAUDE_MD_SCOPE_KIND[f.scope] || 'claude-md-root';
|
||||||
|
sources.push(withLoadPattern({
|
||||||
kind: 'claude-md',
|
kind: 'claude-md',
|
||||||
name: f.path,
|
name: f.path,
|
||||||
source: f.scope,
|
source: f.scope,
|
||||||
estimated_tokens: tokens,
|
estimated_tokens: tokens,
|
||||||
});
|
}, deriveLoadPattern(kind)));
|
||||||
}
|
|
||||||
|
|
||||||
for (const p of activeConfig.plugins || []) {
|
|
||||||
sources.push({
|
|
||||||
kind: 'plugin',
|
|
||||||
name: p.name,
|
|
||||||
source: p.path,
|
|
||||||
estimated_tokens: p.estimatedTokens || 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skills: the measured tokens are the skill BODY (full file), paid on invoke.
|
||||||
|
// The always-loaded part (name+description listing) is small and tracked
|
||||||
|
// separately (skill-listing-budget / posture), so the body is tagged
|
||||||
|
// on-demand here rather than inflating the always-loaded subtotal.
|
||||||
for (const s of activeConfig.skills || []) {
|
for (const s of activeConfig.skills || []) {
|
||||||
sources.push({
|
sources.push(withLoadPattern({
|
||||||
kind: 'skill',
|
kind: 'skill',
|
||||||
name: s.name,
|
name: s.name,
|
||||||
source: s.pluginName ? `plugin:${s.pluginName}` : s.source || 'user',
|
source: sourceLabel(s, 'user'),
|
||||||
estimated_tokens: s.estimatedTokens || 0,
|
estimated_tokens: s.estimatedTokens || 0,
|
||||||
});
|
}, deriveLoadPattern('skill-body')));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules / agents / output styles — the foundation enumeration already derived
|
||||||
|
// the load pattern (rules vary by `scoped`), so propagate it verbatim.
|
||||||
|
for (const r of activeConfig.rules || []) {
|
||||||
|
sources.push(withLoadPattern({
|
||||||
|
kind: 'rule',
|
||||||
|
name: r.name,
|
||||||
|
source: sourceLabel(r, 'project'),
|
||||||
|
estimated_tokens: r.estimatedTokens || 0,
|
||||||
|
}, r));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a of activeConfig.agents || []) {
|
||||||
|
sources.push(withLoadPattern({
|
||||||
|
kind: 'agent',
|
||||||
|
name: a.name,
|
||||||
|
source: sourceLabel(a, 'project'),
|
||||||
|
estimated_tokens: a.estimatedTokens || 0,
|
||||||
|
}, a));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const o of activeConfig.outputStyles || []) {
|
||||||
|
sources.push(withLoadPattern({
|
||||||
|
kind: 'output-style',
|
||||||
|
name: o.name,
|
||||||
|
source: sourceLabel(o, 'project'),
|
||||||
|
estimated_tokens: o.estimatedTokens || 0,
|
||||||
|
}, o));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const m of activeConfig.mcpServers || []) {
|
for (const m of activeConfig.mcpServers || []) {
|
||||||
if (m && m.enabled === false) continue;
|
if (m && m.enabled === false) continue;
|
||||||
sources.push({
|
sources.push(withLoadPattern({
|
||||||
kind: 'mcp-server',
|
kind: 'mcp-server',
|
||||||
name: m.name,
|
name: m.name,
|
||||||
source: m.source || 'unknown',
|
source: m.source || 'unknown',
|
||||||
estimated_tokens: m.estimatedTokens || 0,
|
estimated_tokens: m.estimatedTokens || 0,
|
||||||
});
|
}, deriveLoadPattern('mcp')));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const h of activeConfig.hooks || []) {
|
for (const h of activeConfig.hooks || []) {
|
||||||
sources.push({
|
sources.push(withLoadPattern({
|
||||||
kind: 'hook',
|
kind: 'hook',
|
||||||
name: `${h.event}${h.matcher ? `:${h.matcher}` : ''}`,
|
name: `${h.event}${h.matcher ? `:${h.matcher}` : ''}`,
|
||||||
source: h.source || h.sourcePath || 'unknown',
|
source: h.source || h.sourcePath || 'unknown',
|
||||||
estimated_tokens: h.estimatedTokens || 0,
|
estimated_tokens: h.estimatedTokens || 0,
|
||||||
});
|
}, deriveLoadPattern('hook')));
|
||||||
}
|
}
|
||||||
|
|
||||||
sources.sort((a, b) => b.estimated_tokens - a.estimated_tokens);
|
sources.sort((a, b) => b.estimated_tokens - a.estimated_tokens);
|
||||||
const total = sources.reduce((s, x) => s + (x.estimated_tokens || 0), 0);
|
const total = sources.reduce((s, x) => s + (x.estimated_tokens || 0), 0);
|
||||||
return { sources, total };
|
const summary = summarizeByLoadPattern(sources);
|
||||||
|
return { sources, total, summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bucket sources by load pattern into {tokens, count} subtotals. The `always`
|
||||||
|
* bucket is the headline: tokens that enter context every turn before the user
|
||||||
|
* types anything.
|
||||||
|
*/
|
||||||
|
export function summarizeByLoadPattern(sources) {
|
||||||
|
const mk = () => ({ tokens: 0, count: 0 });
|
||||||
|
const summary = { always: mk(), onDemand: mk(), external: mk(), unknown: mk() };
|
||||||
|
const BUCKET = { always: 'always', 'on-demand': 'onDemand', external: 'external' };
|
||||||
|
for (const s of sources) {
|
||||||
|
const key = BUCKET[s.loadPattern] || 'unknown';
|
||||||
|
summary[key].tokens += s.estimated_tokens || 0;
|
||||||
|
summary[key].count += 1;
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source strings (the `source` field buildManifest stamps) that belong to the
|
||||||
|
* SHARED GLOBAL layer — config paid once per machine and identical in every
|
||||||
|
* repo: the global ~/.claude CLAUDE.md (`user`) and managed enterprise policy
|
||||||
|
* (`managed`). Installed plugins are also shared but are matched by the
|
||||||
|
* `plugin:` prefix below, not by this set.
|
||||||
|
*
|
||||||
|
* Deliberately NOT here: `~/.claude.json:projects`. Although that file lives in
|
||||||
|
* HOME, `readClaudeJsonProjectSlice` returns the slice keyed to the SPECIFIC
|
||||||
|
* repo path — those MCP servers are per-repo, load only in their own project,
|
||||||
|
* and differ across repos, so they are a delta (folding them into the
|
||||||
|
* once-counted shared layer would drop every repo's slice but the first). The
|
||||||
|
* only machine-global MCP is plugin-provided (caught by the `plugin:` prefix).
|
||||||
|
*/
|
||||||
|
const SHARED_GLOBAL_SOURCES = Object.freeze(new Set(['user', 'managed']));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify one manifest source as part of the once-counted shared global layer
|
||||||
|
* or a per-repo delta (v5.9 B2b). Anything not positively identified as global
|
||||||
|
* (project / local / .mcp.json / ~/.claude.json:projects / @import / unrecognized)
|
||||||
|
* falls to `delta`, so a source is never silently folded into the shared layer —
|
||||||
|
* a wrong fold would HIDE machine-wide cost, whereas a wrong delta is at worst
|
||||||
|
* attributed visibly to a repo.
|
||||||
|
* @param {string} source
|
||||||
|
* @returns {'shared'|'delta'}
|
||||||
|
*/
|
||||||
|
export function classifyOwnership(source) {
|
||||||
|
if (typeof source === 'string') {
|
||||||
|
if (source.startsWith('plugin:')) return 'shared'; // installed plugins are machine-global
|
||||||
|
if (SHARED_GLOBAL_SOURCES.has(source)) return 'shared';
|
||||||
|
}
|
||||||
|
return 'delta';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partition manifest sources by ownership for the machine-wide token roll-up,
|
||||||
|
* returning two load-pattern summaries in the exact shape `summarizeByLoadPattern`
|
||||||
|
* emits ({always,onDemand,external,unknown:{tokens,count}}), so the campaign
|
||||||
|
* ledger setters (`setSharedGlobal` / `setRepoTokens`) consume them verbatim.
|
||||||
|
*
|
||||||
|
* - `shared`: the global layer, identical across repos — set ONCE on the ledger
|
||||||
|
* root so the roll-up counts it exactly once (the structural double-count guard).
|
||||||
|
* - `delta`: this repo's own project/local contribution beyond the shared layer.
|
||||||
|
*
|
||||||
|
* The split is total: every source lands in exactly one layer.
|
||||||
|
* @param {Array<{source:string, loadPattern:string, estimated_tokens:number}>} sources
|
||||||
|
* @returns {{shared:object, delta:object}}
|
||||||
|
*/
|
||||||
|
export function splitManifestByOwnership(sources) {
|
||||||
|
const shared = [];
|
||||||
|
const delta = [];
|
||||||
|
for (const s of sources || []) {
|
||||||
|
(classifyOwnership(s.source) === 'shared' ? shared : delta).push(s);
|
||||||
|
}
|
||||||
|
return { shared: summarizeByLoadPattern(shared), delta: summarizeByLoadPattern(delta) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -138,6 +278,7 @@ async function main() {
|
||||||
durationMs: Date.now() - start,
|
durationMs: Date.now() - start,
|
||||||
},
|
},
|
||||||
sources: manifest.sources,
|
sources: manifest.sources,
|
||||||
|
summary: manifest.summary,
|
||||||
total: manifest.total,
|
total: manifest.total,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ const VALID_SERVER_TYPES = new Set(['stdio', 'http', 'sse']);
|
||||||
// not a per-server .mcp.json field. Verified against code.claude.com/docs 2026-06-18.
|
// not a per-server .mcp.json field. Verified against code.claude.com/docs 2026-06-18.
|
||||||
const VALID_SERVER_FIELDS = new Set([
|
const VALID_SERVER_FIELDS = new Set([
|
||||||
'type', 'command', 'args', 'env', 'url', 'headers', 'timeout',
|
'type', 'command', 'args', 'env', 'url', 'headers', 'timeout',
|
||||||
|
// alwaysLoad: exempt a server from MCP tool-schema deferral (CC v2.1.121+).
|
||||||
|
// Verified against code.claude.com/docs/en/mcp.md#exempt-a-server-from-deferral 2026-06-23.
|
||||||
|
'alwaysLoad',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Match only bare ${IDENTIFIER} references. POSIX expansions like ${VAR%pattern}
|
// Match only bare ${IDENTIFIER} references. POSIX expansions like ${VAR%pattern}
|
||||||
|
|
|
||||||
146
scanners/optimization-lens-scanner.mjs
Normal file
146
scanners/optimization-lens-scanner.mjs
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
/**
|
||||||
|
* OPT Scanner — Optimization Lens / mechanism-fit (v5.7 Fase 1 Chunk 2a)
|
||||||
|
*
|
||||||
|
* The first detector of the "is the config OPTIMAL?" axis (vs. the existing
|
||||||
|
* "is it CORRECT?" scanners). It reads the machine-readable best-practices
|
||||||
|
* register (knowledge/best-practices.json) and flags config that works but uses
|
||||||
|
* a mechanism a better one would fit — the deterministic half of the hybrid
|
||||||
|
* motor (the opus analyzer for prose-judgment cases is Chunk 2b).
|
||||||
|
*
|
||||||
|
* CA-OPT-001 A multi-step procedure in CLAUDE.md should be a SKILL (BP-MECH-003).
|
||||||
|
* CLAUDE.md is for facts Claude holds every turn; a procedure there
|
||||||
|
* costs always-loaded tokens whether or not you run it, and a skill's
|
||||||
|
* body loads only on invoke. Detection is deliberately CONSERVATIVE
|
||||||
|
* (a run of >= 6 consecutive numbered steps) to keep precision high —
|
||||||
|
* the negative corpus in the tests proves null false-positives.
|
||||||
|
* Framed as a Missed opportunity (humanizer), severity LOW.
|
||||||
|
*
|
||||||
|
* Provenance: the recommendation + claim come from the register entry (only a
|
||||||
|
* `confirmed` entry is used user-facing — Verifiseringsplikt); an inline default
|
||||||
|
* is the graceful fallback if the register is unavailable. Fixture-gated: the
|
||||||
|
* marketplace-medium CLAUDE.md has no numbered lists, so it emits nothing (SC-5
|
||||||
|
* byte-stable; the additive OPT scanner entry is stripped from frozen baselines).
|
||||||
|
*
|
||||||
|
* Zero external dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { finding, scannerResult } from './lib/output.mjs';
|
||||||
|
import { SEVERITY } from './lib/severity.mjs';
|
||||||
|
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||||
|
import { loadRegister, getEntry } from './lib/best-practices-register.mjs';
|
||||||
|
|
||||||
|
const SCANNER = 'OPT';
|
||||||
|
const STEP_THRESHOLD = 6;
|
||||||
|
const STEP_RE = /^\s*\d+\.\s+\S/;
|
||||||
|
const PROCEDURE_TITLE = 'A multi-step procedure in CLAUDE.md belongs in a skill';
|
||||||
|
|
||||||
|
// Graceful fallback if the register is missing/unreadable (the register is the
|
||||||
|
// source of truth; this keeps the scanner working without it).
|
||||||
|
const DEFAULT_MECH_003 = {
|
||||||
|
claim:
|
||||||
|
'A multi-step procedure in CLAUDE.md should be a skill — CLAUDE.md is for facts Claude ' +
|
||||||
|
'should hold all the time; procedures belong in skills.',
|
||||||
|
recommendation:
|
||||||
|
'Extract the procedure into .claude/skills/; its body then loads only on invoke instead ' +
|
||||||
|
'of every turn.',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Return the confirmed register entry for `id`, or null (→ caller uses default). */
|
||||||
|
function confirmedEntry(id) {
|
||||||
|
try {
|
||||||
|
const e = getEntry(loadRegister(), id);
|
||||||
|
return e && e.confidence === 'confirmed' ? e : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Longest run of consecutive numbered-list items. Blank lines and indented
|
||||||
|
* continuation lines neither extend nor break a run; any other non-step line
|
||||||
|
* breaks it. Conservative by design (a wrapped, non-indented step line ends the
|
||||||
|
* run → undercount, never overcount).
|
||||||
|
* @param {string} text
|
||||||
|
* @returns {{count:number, startIndex:number, firstStep:string}}
|
||||||
|
*/
|
||||||
|
function longestNumberedRun(text) {
|
||||||
|
const lines = String(text).split('\n');
|
||||||
|
let maxRun = 0;
|
||||||
|
let maxStartIdx = 0;
|
||||||
|
let maxFirstStep = '';
|
||||||
|
let run = 0;
|
||||||
|
let runStartIdx = 0;
|
||||||
|
let runFirstStep = '';
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (STEP_RE.test(line)) {
|
||||||
|
if (run === 0) {
|
||||||
|
runStartIdx = i;
|
||||||
|
runFirstStep = line.trim();
|
||||||
|
}
|
||||||
|
run++;
|
||||||
|
if (run > maxRun) {
|
||||||
|
maxRun = run;
|
||||||
|
maxStartIdx = runStartIdx;
|
||||||
|
maxFirstStep = runFirstStep;
|
||||||
|
}
|
||||||
|
} else if (line.trim() === '' || /^\s+\S/.test(line)) {
|
||||||
|
// blank or indented continuation — part of the list, neither step nor break
|
||||||
|
} else {
|
||||||
|
run = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { count: maxRun, startIndex: maxStartIdx, firstStep: maxFirstStep };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scan(targetPath, discovery) {
|
||||||
|
const start = Date.now();
|
||||||
|
const findings = [];
|
||||||
|
const claudeMdFiles = ((discovery && discovery.files) || []).filter((f) => f.type === 'claude-md');
|
||||||
|
|
||||||
|
const entry = confirmedEntry('BP-MECH-003');
|
||||||
|
const claim = (entry && entry.claim) || DEFAULT_MECH_003.claim;
|
||||||
|
const recommendation = (entry && entry.recommendation) || DEFAULT_MECH_003.recommendation;
|
||||||
|
|
||||||
|
let filesScanned = 0;
|
||||||
|
for (const file of claudeMdFiles) {
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
content = await readFile(file.absPath, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
filesScanned++;
|
||||||
|
|
||||||
|
const parsed = parseFrontmatter(content);
|
||||||
|
const body = parsed.body || content;
|
||||||
|
const bodyStartLine = parsed.bodyStartLine || 1;
|
||||||
|
const run = longestNumberedRun(body);
|
||||||
|
|
||||||
|
if (run.count >= STEP_THRESHOLD) {
|
||||||
|
findings.push(
|
||||||
|
finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.low,
|
||||||
|
title: PROCEDURE_TITLE,
|
||||||
|
description: claim,
|
||||||
|
file: file.relPath || file.absPath,
|
||||||
|
line: bodyStartLine + run.startIndex,
|
||||||
|
evidence: run.firstStep,
|
||||||
|
recommendation,
|
||||||
|
category: 'mechanism-fit',
|
||||||
|
details: {
|
||||||
|
mechanism: 'skill',
|
||||||
|
steps: run.count,
|
||||||
|
register: entry ? entry.id : null,
|
||||||
|
source: entry ? entry.source.url : null,
|
||||||
|
confidence: entry ? entry.confidence : null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||||
|
}
|
||||||
179
scanners/optimize-lens-cli.mjs
Normal file
179
scanners/optimize-lens-cli.mjs
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* optimize-lens CLI — feeds the v5.7 optimization lens (CA-OPT) `/config-audit
|
||||||
|
* optimize` command. It produces the two halves of the hybrid motor as one JSON
|
||||||
|
* payload:
|
||||||
|
*
|
||||||
|
* 1. `deterministic` — the OPT scanner's high-precision findings (CA-OPT-001:
|
||||||
|
* a long numbered procedure in CLAUDE.md → skill). Already part of the
|
||||||
|
* orchestrated audit; surfaced here so /optimize is a complete view.
|
||||||
|
* 2. `candidates` — recall-oriented prose-judgment candidates from the
|
||||||
|
* lens-prefilter (lifecycle → hook, unscoped path-specific → rule, "never"
|
||||||
|
* → permission), each stamped with the CONFIRMED register entry it might fit
|
||||||
|
* (claim / recommendation / source / severity). The opus
|
||||||
|
* optimization-lens-agent is the precision gate over these.
|
||||||
|
*
|
||||||
|
* Only CONFIRMED register entries are attached (Verifiseringsplikt); a candidate
|
||||||
|
* whose register rule is missing or unconfirmed is dropped, so the agent never
|
||||||
|
* sees an unverifiable recommendation.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node optimize-lens-cli.mjs [path] [--output-file <path>] [--global]
|
||||||
|
*
|
||||||
|
* Exit codes: 0=ok, 3=unrecoverable error. Zero external dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolve, sep } from 'node:path';
|
||||||
|
import { writeFile, readFile, stat } from 'node:fs/promises';
|
||||||
|
import { discoverConfigFiles } from './lib/file-discovery.mjs';
|
||||||
|
import { resetCounter } from './lib/output.mjs';
|
||||||
|
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||||
|
import { loadRegister, getEntry } from './lib/best-practices-register.mjs';
|
||||||
|
import { prefilterClaudeMd, LENS_DETECTORS } from './lib/lens-prefilter.mjs';
|
||||||
|
import { scan as optScan } from './optimization-lens-scanner.mjs';
|
||||||
|
|
||||||
|
// Files under `.claude/plugins/` are shipped by an installed plugin — vendored
|
||||||
|
// CLAUDE.md plus its bundled tests/fixtures and examples. They are not the user's
|
||||||
|
// authored config, so a mechanism-fit suggestion against them is not actionable
|
||||||
|
// (the user can't edit a file the plugin overwrites on update). Excluded from the
|
||||||
|
// lens regardless of active/stale version. (M-BUG-11; mirrors the M-BUG-2 rule
|
||||||
|
// that keeps plugin-bundled config out of the conflict detector.)
|
||||||
|
const PLUGIN_TREE_MARKER = `.claude${sep}plugins${sep}`;
|
||||||
|
const isPluginBundled = (file) => (file.absPath || '').includes(PLUGIN_TREE_MARKER);
|
||||||
|
|
||||||
|
/** Confirmed register entry for `id`, or null. */
|
||||||
|
function confirmedEntry(register, id) {
|
||||||
|
const e = getEntry(register, id);
|
||||||
|
return e && e.confidence === 'confirmed' ? e : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
let targetPath = '.';
|
||||||
|
let outputFile = null;
|
||||||
|
let includeGlobal = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
if (args[i] === '--global') includeGlobal = true;
|
||||||
|
else if (args[i] === '--output-file' && args[i + 1]) outputFile = args[++i];
|
||||||
|
else if (!args[i].startsWith('-')) targetPath = args[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
const absPath = resolve(targetPath);
|
||||||
|
try {
|
||||||
|
const s = await stat(absPath);
|
||||||
|
if (!s.isDirectory()) {
|
||||||
|
process.stderr.write(`Error: ${absPath} is not a directory\n`);
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the register once; tolerate its absence (deterministic half still runs).
|
||||||
|
let register = null;
|
||||||
|
try {
|
||||||
|
register = loadRegister();
|
||||||
|
} catch {
|
||||||
|
register = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetCounter();
|
||||||
|
const rawDiscovery = await discoverConfigFiles(absPath, { includeGlobal });
|
||||||
|
// Scope the lens to the user's authored config: drop plugin-bundled files for
|
||||||
|
// BOTH halves of the motor (the OPT scanner reads discovery.files directly).
|
||||||
|
const discovery = {
|
||||||
|
...rawDiscovery,
|
||||||
|
files: (rawDiscovery.files || []).filter((f) => !isPluginBundled(f)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Deterministic half: the OPT scanner (CA-OPT-001) ──
|
||||||
|
const opt = await optScan(absPath, discovery);
|
||||||
|
|
||||||
|
// ── Recall half: prose-judgment candidates from the pre-filter ──
|
||||||
|
const claudeMdFiles = (discovery.files || []).filter((f) => f.type === 'claude-md');
|
||||||
|
const candidates = [];
|
||||||
|
for (const file of claudeMdFiles) {
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
content = await readFile(file.absPath, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = parseFrontmatter(content);
|
||||||
|
const body = parsed.body || content;
|
||||||
|
const bodyStartLine = parsed.bodyStartLine || 1;
|
||||||
|
for (const cand of prefilterClaudeMd(body)) {
|
||||||
|
const entry = register ? confirmedEntry(register, cand.registerId) : null;
|
||||||
|
if (!entry) continue; // never surface an unverifiable recommendation
|
||||||
|
candidates.push({
|
||||||
|
// Absolute path: unique + readable. relPath collides across scopes
|
||||||
|
// (a repo-root `CLAUDE.md` and the user-global `~/.claude/CLAUDE.md`
|
||||||
|
// both relPath to `CLAUDE.md`), which would send the agent's Read() to
|
||||||
|
// the wrong file. (M-BUG-11)
|
||||||
|
file: file.absPath,
|
||||||
|
line: bodyStartLine - 1 + cand.line,
|
||||||
|
lensCheck: cand.lensCheck,
|
||||||
|
mechanism: cand.mechanism,
|
||||||
|
signalText: cand.text,
|
||||||
|
register: {
|
||||||
|
id: entry.id,
|
||||||
|
claim: entry.claim,
|
||||||
|
recommendation: entry.recommendation || null,
|
||||||
|
severity: entry.severity || 'low',
|
||||||
|
source: entry.source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CONFIRMED prose-judgment entries, so the agent has full provenance even
|
||||||
|
// for a detector class that produced no candidates this run.
|
||||||
|
const registerEntries = register
|
||||||
|
? LENS_DETECTORS.map((d) => confirmedEntry(register, d.registerId))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
lensCheck: e.lensCheck,
|
||||||
|
claim: e.claim,
|
||||||
|
recommendation: e.recommendation || null,
|
||||||
|
mechanism: e.mechanism || null,
|
||||||
|
severity: e.severity || 'low',
|
||||||
|
source: e.source,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
status: 'ok',
|
||||||
|
target: absPath,
|
||||||
|
deterministic: opt.findings || [],
|
||||||
|
candidates,
|
||||||
|
register: registerEntries,
|
||||||
|
counts: {
|
||||||
|
deterministic: (opt.findings || []).length,
|
||||||
|
candidates: candidates.length,
|
||||||
|
byLensCheck: candidates.reduce((acc, c) => {
|
||||||
|
acc[c.lensCheck] = (acc[c.lensCheck] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const json = JSON.stringify(payload, null, 2);
|
||||||
|
if (outputFile) {
|
||||||
|
await writeFile(outputFile, json, 'utf-8');
|
||||||
|
}
|
||||||
|
if (!outputFile) {
|
||||||
|
process.stdout.write(json + '\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||||
|
if (isDirectRun) {
|
||||||
|
main().catch((err) => {
|
||||||
|
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||||
|
process.exit(3);
|
||||||
|
});
|
||||||
|
}
|
||||||
179
scanners/output-style-scanner.mjs
Normal file
179
scanners/output-style-scanner.mjs
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
/**
|
||||||
|
* OST Scanner — Output-style validation (v5.6 C)
|
||||||
|
*
|
||||||
|
* Output styles are live (the standalone `/output-style` command was removed in
|
||||||
|
* v2.1.91; styles are now managed via `/config`). They are the most surprising
|
||||||
|
* steering surface because they rewrite the system prompt:
|
||||||
|
*
|
||||||
|
* CA-OST-001 A custom (user/project) output style that does NOT set
|
||||||
|
* `keep-coding-instructions: true` → when active, Claude Code
|
||||||
|
* REMOVES its built-in software-engineering instructions (how to
|
||||||
|
* scope changes, write comments, verify work) and keeps only the
|
||||||
|
* style's text. `keep-coding-instructions` defaults to false, so
|
||||||
|
* this is the headline footgun. Severity medium.
|
||||||
|
*
|
||||||
|
* CA-OST-002 A PLUGIN output style with `force-for-plugin: true` → Claude
|
||||||
|
* Code auto-applies it whenever the plugin is enabled, OVERRIDING
|
||||||
|
* the user's selected `outputStyle`. If several enabled plugins set
|
||||||
|
* it, the first loaded wins. Severity low (awareness). Note:
|
||||||
|
* `force-for-plugin` is plugin-styles-only per the docs, so this
|
||||||
|
* keys on `source === 'plugin'` — a user/project style cannot
|
||||||
|
* trigger the override (it would simply be ignored).
|
||||||
|
*
|
||||||
|
* CA-OST-003 A settings `outputStyle` value that matches no built-in and no
|
||||||
|
* discovered custom style → dead config: Claude Code falls back to
|
||||||
|
* the default style, so the configured behavior is silently not
|
||||||
|
* applied. Severity medium.
|
||||||
|
*
|
||||||
|
* Every claim traces to a CONFIRMED row of docs/v5.5-steering-model-plan.md
|
||||||
|
* (V9/V10/V11/V12), verified against code.claude.com/docs/en/output-styles and
|
||||||
|
* .../plugins-reference. The scanner is fixture-gated: with no output styles and
|
||||||
|
* no `outputStyle` setting it emits nothing (keeps the SC-5 snapshot byte-stable).
|
||||||
|
*
|
||||||
|
* Zero external dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { finding, scannerResult } from './lib/output.mjs';
|
||||||
|
import { SEVERITY } from './lib/severity.mjs';
|
||||||
|
import { readActiveConfig } from './lib/active-config-reader.mjs';
|
||||||
|
import { parseFrontmatter, parseJson } from './lib/yaml-parser.mjs';
|
||||||
|
|
||||||
|
const SCANNER = 'OST';
|
||||||
|
|
||||||
|
// Built-in output styles, verified against code.claude.com/docs/en/output-styles.
|
||||||
|
// Compared case-insensitively so OST-003 never false-flags a valid built-in.
|
||||||
|
const BUILTIN_STYLES = ['default', 'explanatory', 'learning', 'proactive'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read + parse the frontmatter of each enumerated output style once.
|
||||||
|
* @param {Array<object>} styles - readActiveConfig().outputStyles entries
|
||||||
|
*/
|
||||||
|
async function withFrontmatter(styles) {
|
||||||
|
const out = [];
|
||||||
|
for (const s of styles) {
|
||||||
|
let frontmatter = null;
|
||||||
|
try {
|
||||||
|
frontmatter = parseFrontmatter(await readFile(s.path, 'utf-8')).frontmatter;
|
||||||
|
} catch { /* unreadable → treat as no frontmatter */ }
|
||||||
|
out.push({ ...s, frontmatter });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the effective `outputStyle` setting from the cascade (user → project →
|
||||||
|
* local; later scope wins). Returns null when unset everywhere.
|
||||||
|
* @param {object} activeConfig
|
||||||
|
* @returns {Promise<{value:string, scope:string, path:string} | null>}
|
||||||
|
*/
|
||||||
|
async function resolveOutputStyleSetting(activeConfig) {
|
||||||
|
const cascade = (activeConfig.settings && activeConfig.settings.cascade) || [];
|
||||||
|
let resolved = null;
|
||||||
|
for (const entry of cascade) {
|
||||||
|
if (!entry.exists || !entry.path) continue;
|
||||||
|
let json = null;
|
||||||
|
try { json = parseJson(await readFile(entry.path, 'utf-8')); } catch { continue; }
|
||||||
|
if (json && typeof json.outputStyle === 'string' && json.outputStyle.trim()) {
|
||||||
|
resolved = { value: json.outputStyle.trim(), scope: entry.scope, path: entry.path };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main scanner entry point.
|
||||||
|
* @param {string} targetPath - repo root to scan
|
||||||
|
* @param {object} _discovery - unused (OST reads the active config cascade itself)
|
||||||
|
*/
|
||||||
|
export async function scan(targetPath, _discovery) {
|
||||||
|
const start = Date.now();
|
||||||
|
const findings = [];
|
||||||
|
|
||||||
|
const activeConfig = await readActiveConfig(targetPath);
|
||||||
|
const styles = await withFrontmatter(activeConfig.outputStyles || []);
|
||||||
|
|
||||||
|
// CA-OST-001 — user/project custom style missing keep-coding-instructions:true.
|
||||||
|
for (const s of styles) {
|
||||||
|
if (s.source !== 'project' && s.source !== 'user') continue;
|
||||||
|
const kci = s.frontmatter ? s.frontmatter.keep_coding_instructions : undefined;
|
||||||
|
if (kci === true) continue;
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.medium,
|
||||||
|
title: 'Custom output style removes built-in coding instructions',
|
||||||
|
description:
|
||||||
|
`The ${s.source} output style "${s.name}" does not set ` +
|
||||||
|
'`keep-coding-instructions: true`. While this style is active, Claude Code ' +
|
||||||
|
'drops its built-in software-engineering instructions — how to scope changes, ' +
|
||||||
|
'write comments, and verify work — and keeps only this style\'s text. The ' +
|
||||||
|
'frontmatter flag defaults to false, so the strip is easy to miss.',
|
||||||
|
file: s.path,
|
||||||
|
evidence:
|
||||||
|
`output_style="${s.name}"; source=${s.source}; ` +
|
||||||
|
`keep-coding-instructions=${kci === undefined ? 'unset (default false)' : String(kci)}`,
|
||||||
|
recommendation:
|
||||||
|
'To keep Claude Code\'s software-engineering behavior while applying this style, ' +
|
||||||
|
'add `keep-coding-instructions: true` to the frontmatter. If the strip is ' +
|
||||||
|
'intentional (a non-coding persona), no change is needed.',
|
||||||
|
category: 'output-styles',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// CA-OST-002 — plugin output style with force-for-plugin:true (overrides user choice).
|
||||||
|
for (const s of styles) {
|
||||||
|
if (s.source !== 'plugin') continue;
|
||||||
|
const ffp = s.frontmatter ? s.frontmatter.force_for_plugin : undefined;
|
||||||
|
if (ffp !== true) continue;
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.low,
|
||||||
|
title: 'Plugin output style overrides your selected output style',
|
||||||
|
description:
|
||||||
|
`The plugin "${s.pluginName}" ships an output style "${s.name}" with ` +
|
||||||
|
'`force-for-plugin: true`, so Claude Code applies it automatically whenever the ' +
|
||||||
|
'plugin is enabled — overriding whatever `outputStyle` you selected. When more ' +
|
||||||
|
'than one enabled plugin does this, the first one loaded wins.',
|
||||||
|
file: s.path,
|
||||||
|
evidence: `output_style="${s.name}"; source=plugin:${s.pluginName}; force-for-plugin=true`,
|
||||||
|
recommendation:
|
||||||
|
'If you did not expect this style, disable the plugin or remove ' +
|
||||||
|
'`force-for-plugin: true` from its output style. This is awareness only — the ' +
|
||||||
|
'plugin is behaving as designed.',
|
||||||
|
category: 'output-styles',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// CA-OST-003 — settings outputStyle resolving to a non-existent style (dead config).
|
||||||
|
const resolved = await resolveOutputStyleSetting(activeConfig);
|
||||||
|
if (resolved) {
|
||||||
|
const known = new Set([
|
||||||
|
...BUILTIN_STYLES,
|
||||||
|
...styles.map(s => String(s.name).toLowerCase()),
|
||||||
|
]);
|
||||||
|
if (!known.has(resolved.value.toLowerCase())) {
|
||||||
|
const customNames = styles.map(s => s.name);
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.medium,
|
||||||
|
title: 'Configured output style does not exist',
|
||||||
|
description:
|
||||||
|
`Your ${resolved.scope} settings set \`outputStyle: "${resolved.value}"\`, but no ` +
|
||||||
|
'built-in or discovered custom style has that name. Claude Code falls back to the ' +
|
||||||
|
'default style, so the output behavior you configured is silently never applied.',
|
||||||
|
file: resolved.path,
|
||||||
|
evidence:
|
||||||
|
`outputStyle="${resolved.value}"; scope=${resolved.scope}; ` +
|
||||||
|
`builtins=[${BUILTIN_STYLES.join(', ')}]; ` +
|
||||||
|
`known_custom=[${customNames.join(', ')}]`,
|
||||||
|
recommendation:
|
||||||
|
'Fix the value to match an existing style (built-ins: Default, Explanatory, ' +
|
||||||
|
'Learning, Proactive), create the missing style under `.claude/output-styles/`, ' +
|
||||||
|
'or remove the `outputStyle` setting.',
|
||||||
|
category: 'output-styles',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scannerResult(SCANNER, 'ok', findings, styles.length, Date.now() - start);
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readdir, stat, readFile } from 'node:fs/promises';
|
import { readdir, stat, readFile } from 'node:fs/promises';
|
||||||
import { join, basename, resolve } from 'node:path';
|
import { join, basename, resolve, sep } from 'node:path';
|
||||||
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
|
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
|
||||||
import { SEVERITY } from './lib/severity.mjs';
|
import { SEVERITY } from './lib/severity.mjs';
|
||||||
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||||
|
|
@ -19,20 +19,127 @@ const SCANNER = 'PLH';
|
||||||
|
|
||||||
const REQUIRED_PLUGIN_JSON_FIELDS = ['name', 'description', 'version'];
|
const REQUIRED_PLUGIN_JSON_FIELDS = ['name', 'description', 'version'];
|
||||||
const RECOMMENDED_CLAUDE_MD_SECTIONS = ['commands', 'agents', 'hooks'];
|
const RECOMMENDED_CLAUDE_MD_SECTIONS = ['commands', 'agents', 'hooks'];
|
||||||
// Keys as they appear after yaml-parser normalizeKey (hyphens → underscores)
|
|
||||||
|
// A CLAUDE.md need only document the component types the plugin actually ships. Mirrors the
|
||||||
|
// optional-frontmatter rule: do not demand docs for commands/agents/hooks that do not exist.
|
||||||
|
async function pluginShipsComponent(pluginDir, section) {
|
||||||
|
if (section === 'hooks') {
|
||||||
|
try { await readFile(join(pluginDir, 'hooks', 'hooks.json'), 'utf-8'); return true; }
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const entries = await readdir(join(pluginDir, section));
|
||||||
|
return entries.some(f => f.endsWith('.md'));
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
// Keys as they appear after yaml-parser normalizeKey (hyphens → underscores).
|
||||||
|
// Field requirements are pinned to the primary docs, NOT to "every field a plugin could set":
|
||||||
|
// - Commands/skills (code.claude.com/docs slash-commands): "All fields are optional. Only
|
||||||
|
// `description` is recommended." `name` defaults to the directory name; `model` and
|
||||||
|
// `allowed-tools` are optional. So only `description` is flagged.
|
||||||
|
// - Subagents (code.claude.com/docs sub-agents): "Only `name` and `description` are required."
|
||||||
|
// `model` (defaults to `inherit`) and `tools` (inherits all) are optional.
|
||||||
const REQUIRED_COMMAND_FRONTMATTER = [
|
const REQUIRED_COMMAND_FRONTMATTER = [
|
||||||
{ key: 'name', display: 'name' },
|
|
||||||
{ key: 'description', display: 'description' },
|
{ key: 'description', display: 'description' },
|
||||||
{ key: 'model', display: 'model' },
|
|
||||||
{ key: 'allowed_tools', display: 'allowed-tools' },
|
|
||||||
];
|
];
|
||||||
const REQUIRED_AGENT_FRONTMATTER = [
|
const REQUIRED_AGENT_FRONTMATTER = [
|
||||||
{ key: 'name', display: 'name' },
|
{ key: 'name', display: 'name' },
|
||||||
{ key: 'description', display: 'description' },
|
{ key: 'description', display: 'description' },
|
||||||
{ key: 'model', display: 'model' },
|
|
||||||
{ key: 'tools', display: 'tools' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Plugin subagents silently ignore these frontmatter keys — they are honored
|
||||||
|
// ONLY for user/project agents in .claude/agents/ (code.claude.com/docs
|
||||||
|
// sub-agents, "ignored for plugin subagents"). Setting them in a plugin agent
|
||||||
|
// is dead config; permissionMode is MEDIUM because it implies a restriction
|
||||||
|
// that Claude Code does not actually apply (false sense of security).
|
||||||
|
const PLUGIN_AGENT_IGNORED_FIELDS = [
|
||||||
|
{ key: 'permissionMode', severity: SEVERITY.medium },
|
||||||
|
{ key: 'hooks', severity: SEVERITY.low },
|
||||||
|
{ key: 'mcpServers', severity: SEVERITY.low },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Component-path keys that REPLACE the default folder (per code.claude.com/docs
|
||||||
|
// plugins-reference#path-behavior-rules). When such a key is set, Claude Code
|
||||||
|
// stops scanning the default folder; if that folder still exists, its contents
|
||||||
|
// are silently ignored (dead config). CC v2.1.140+ flags this in /doctor and
|
||||||
|
// `claude plugin list`. Excluded by design: `skills` (ADDS to the default —
|
||||||
|
// both load, never a shadow), and `hooks`/`mcpServers`/`lspServers` (own merge
|
||||||
|
// rules, not a folder shadow). Experimental themes/monitors are omitted: the
|
||||||
|
// docs warn their manifest schema may change between releases.
|
||||||
|
const SHADOWING_PATH_FIELDS = [
|
||||||
|
{ key: 'commands', defaultDir: 'commands' },
|
||||||
|
{ key: 'agents', defaultDir: 'agents' },
|
||||||
|
{ key: 'outputStyles', defaultDir: 'output-styles' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Normalize a manifest path: strip a leading "./" and trailing slashes. */
|
||||||
|
function normalizeManifestPath(p) {
|
||||||
|
return String(p).replace(/^\.\//, '').replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a custom manifest path addresses the default folder (equals it or
|
||||||
|
* points inside it) — Claude Code shows no warning in that case because the
|
||||||
|
* folder is referenced explicitly (e.g. "commands": ["./commands/deploy.md"]).
|
||||||
|
*/
|
||||||
|
function addressesDefaultDir(customPath, defaultDir) {
|
||||||
|
const norm = normalizeManifestPath(customPath);
|
||||||
|
return norm === defaultDir || norm.startsWith(defaultDir + '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `p` exists and is a directory. */
|
||||||
|
async function dirExists(p) {
|
||||||
|
try {
|
||||||
|
return (await stat(p)).isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stat `p`, or null when it does not exist (distinguishes missing from file/dir). */
|
||||||
|
async function statOrNull(p) {
|
||||||
|
try {
|
||||||
|
return await stat(p);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a `skills` entry resolves outside the plugin root. Installed plugins
|
||||||
|
* cannot reference files outside their own directory (docs: path-traversal
|
||||||
|
* limitations), so "../shared" or an absolute path will not load.
|
||||||
|
*/
|
||||||
|
function skillsEntryEscapesRoot(pluginDir, entry) {
|
||||||
|
const resolved = resolve(pluginDir, entry.replace(/^\.\//, ''));
|
||||||
|
return resolved !== pluginDir && !resolved.startsWith(pluginDir + sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-problem prose for a malformed `skills` entry. Each `title` starts with
|
||||||
|
// `plugin.json "skills" entry` so the family is greppable.
|
||||||
|
const SKILLS_ENTRY_MESSAGES = {
|
||||||
|
'non-string': {
|
||||||
|
title: e => `plugin.json "skills" entry is not a string: ${JSON.stringify(e)}`,
|
||||||
|
description: 'Each "skills" entry must be a relative path string (starting with "./") to a skill directory.',
|
||||||
|
recommendation: 'Replace the non-string entry with a path like "./my-skill/", or remove it.',
|
||||||
|
},
|
||||||
|
'escapes-root': {
|
||||||
|
title: e => `plugin.json "skills" entry escapes the plugin root: ${e}`,
|
||||||
|
description: 'Installed plugins cannot reference files outside their own directory, so a skills path that traverses outside the plugin root (e.g. "../shared") will not load.',
|
||||||
|
recommendation: 'Point the entry at a directory inside the plugin, or vendor the skill into the plugin.',
|
||||||
|
},
|
||||||
|
'not-found': {
|
||||||
|
title: e => `plugin.json "skills" entry does not exist: ${e}`,
|
||||||
|
description: 'The "skills" entry points at a path that does not exist in the plugin, so no skill loads from it.',
|
||||||
|
recommendation: 'Create the directory, fix the path, or remove the entry.',
|
||||||
|
},
|
||||||
|
'not-a-directory': {
|
||||||
|
title: e => `plugin.json "skills" entry is a file, not a directory: ${e}`,
|
||||||
|
description: 'A "skills" entry must be a directory containing a SKILL.md (or <name>/SKILL.md), not a file.',
|
||||||
|
recommendation: 'Point the entry at the skill directory (the folder that contains SKILL.md), not the file.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discover plugins under a path.
|
* Discover plugins under a path.
|
||||||
* Looks for .claude-plugin/plugin.json pattern.
|
* Looks for .claude-plugin/plugin.json pattern.
|
||||||
|
|
@ -99,6 +206,9 @@ async function scanSinglePlugin(pluginDir) {
|
||||||
const pluginName = basename(pluginDir);
|
const pluginName = basename(pluginDir);
|
||||||
let commandCount = 0;
|
let commandCount = 0;
|
||||||
let agentCount = 0;
|
let agentCount = 0;
|
||||||
|
// Declared namespace from plugin.json `name` (the prefix for /name:command,
|
||||||
|
// name:skill, agent "name"). Folder basename is NOT the namespace.
|
||||||
|
let declaredName = null;
|
||||||
|
|
||||||
// 1. Validate plugin.json
|
// 1. Validate plugin.json
|
||||||
const pluginJsonPath = join(pluginDir, '.claude-plugin', 'plugin.json');
|
const pluginJsonPath = join(pluginDir, '.claude-plugin', 'plugin.json');
|
||||||
|
|
@ -119,6 +229,9 @@ async function scanSinglePlugin(pluginDir) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
|
if (typeof parsed.name === 'string' && parsed.name.trim()) {
|
||||||
|
declaredName = parsed.name.trim();
|
||||||
|
}
|
||||||
for (const field of REQUIRED_PLUGIN_JSON_FIELDS) {
|
for (const field of REQUIRED_PLUGIN_JSON_FIELDS) {
|
||||||
if (!parsed[field]) {
|
if (!parsed[field]) {
|
||||||
findings.push(finding({
|
findings.push(finding({
|
||||||
|
|
@ -131,6 +244,69 @@ async function scanSinglePlugin(pluginDir) {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shadow check: a manifest component-path key that REPLACES a default
|
||||||
|
// folder which still exists → that folder is silently ignored (dead config).
|
||||||
|
for (const { key, defaultDir } of SHADOWING_PATH_FIELDS) {
|
||||||
|
const value = parsed[key];
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
const customPaths = (Array.isArray(value) ? value : [value]).filter(p => typeof p === 'string');
|
||||||
|
if (customPaths.length === 0) continue;
|
||||||
|
// If any custom path addresses the default folder, CC keeps scanning it → no shadow.
|
||||||
|
if (customPaths.some(p => addressesDefaultDir(p, defaultDir))) continue;
|
||||||
|
if (!(await dirExists(join(pluginDir, defaultDir)))) continue;
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.medium,
|
||||||
|
title: `plugin.json "${key}" path shadows the default ${defaultDir}/ folder`,
|
||||||
|
description:
|
||||||
|
`Plugin "${pluginName}" sets "${key}" in plugin.json to ${customPaths.map(p => `"${p}"`).join(', ')}, ` +
|
||||||
|
`which replaces the default ${defaultDir}/ folder. That folder still exists but Claude Code no longer ` +
|
||||||
|
`scans it, so its contents are silently ignored (dead config). Claude Code flags this in /doctor and ` +
|
||||||
|
'`claude plugin list` (v2.1.140+).',
|
||||||
|
file: pluginJsonPath,
|
||||||
|
evidence: `${key}=${JSON.stringify(value)}; ignored folder=${defaultDir}/`,
|
||||||
|
recommendation:
|
||||||
|
`Either remove the unused ${defaultDir}/ folder, or keep it by listing it explicitly in "${key}" ` +
|
||||||
|
`(e.g. "${key}": ["./${defaultDir}/", ...]).`,
|
||||||
|
category: 'plugin-hygiene',
|
||||||
|
details: { field: key, ignoredDir: defaultDir, customPaths },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// skills:-array validation: each entry must resolve to an existing
|
||||||
|
// directory inside the plugin root. Mirrors `claude plugin validate`.
|
||||||
|
// skills is string|array (a single string is one entry). Unlike the
|
||||||
|
// shadow check, skills ADDS to the default skills/ scan, so a custom path
|
||||||
|
// here is never a shadow — it just has to be a real directory.
|
||||||
|
if (parsed.skills !== undefined && parsed.skills !== null) {
|
||||||
|
const entries = Array.isArray(parsed.skills) ? parsed.skills : [parsed.skills];
|
||||||
|
for (const entry of entries) {
|
||||||
|
let problem = null;
|
||||||
|
if (typeof entry !== 'string') {
|
||||||
|
problem = 'non-string';
|
||||||
|
} else if (skillsEntryEscapesRoot(pluginDir, entry)) {
|
||||||
|
problem = 'escapes-root';
|
||||||
|
} else {
|
||||||
|
const st = await statOrNull(resolve(pluginDir, entry.replace(/^\.\//, '')));
|
||||||
|
if (!st) problem = 'not-found';
|
||||||
|
else if (!st.isDirectory()) problem = 'not-a-directory';
|
||||||
|
}
|
||||||
|
if (!problem) continue;
|
||||||
|
const m = SKILLS_ENTRY_MESSAGES[problem];
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.medium,
|
||||||
|
title: m.title(entry),
|
||||||
|
description: `Plugin "${pluginName}": ${m.description}`,
|
||||||
|
file: pluginJsonPath,
|
||||||
|
evidence: `skills entry=${JSON.stringify(entry)}; problem=${problem}`,
|
||||||
|
recommendation: m.recommendation,
|
||||||
|
category: 'plugin-hygiene',
|
||||||
|
details: { field: 'skills', entry, problem },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
findings.push(finding({
|
findings.push(finding({
|
||||||
|
|
@ -150,6 +326,9 @@ async function scanSinglePlugin(pluginDir) {
|
||||||
const lower = content.toLowerCase();
|
const lower = content.toLowerCase();
|
||||||
|
|
||||||
for (const section of RECOMMENDED_CLAUDE_MD_SECTIONS) {
|
for (const section of RECOMMENDED_CLAUDE_MD_SECTIONS) {
|
||||||
|
// Only require a section for a component the plugin actually ships (mirrors the
|
||||||
|
// optional-frontmatter rule — no docs demanded for absent commands/agents/hooks).
|
||||||
|
if (!(await pluginShipsComponent(pluginDir, section))) continue;
|
||||||
// Look for markdown table header or section header
|
// Look for markdown table header or section header
|
||||||
const hasSection = lower.includes(`## ${section}`) ||
|
const hasSection = lower.includes(`## ${section}`) ||
|
||||||
lower.includes(`| ${section}`) ||
|
lower.includes(`| ${section}`) ||
|
||||||
|
|
@ -195,7 +374,7 @@ async function scanSinglePlugin(pluginDir) {
|
||||||
title: 'Command missing frontmatter',
|
title: 'Command missing frontmatter',
|
||||||
description: `Command "${file}" in plugin "${pluginName}" has no frontmatter`,
|
description: `Command "${file}" in plugin "${pluginName}" has no frontmatter`,
|
||||||
file: filePath,
|
file: filePath,
|
||||||
recommendation: 'Add YAML frontmatter with name, description, model',
|
recommendation: 'Add YAML frontmatter with a description (other command fields are optional)',
|
||||||
}));
|
}));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -234,7 +413,7 @@ async function scanSinglePlugin(pluginDir) {
|
||||||
title: 'Agent missing frontmatter',
|
title: 'Agent missing frontmatter',
|
||||||
description: `Agent "${file}" in plugin "${pluginName}" has no frontmatter`,
|
description: `Agent "${file}" in plugin "${pluginName}" has no frontmatter`,
|
||||||
file: filePath,
|
file: filePath,
|
||||||
recommendation: 'Add YAML frontmatter with name, description, model, tools',
|
recommendation: 'Add YAML frontmatter with name and description (model and tools are optional)',
|
||||||
}));
|
}));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -251,6 +430,23 @@ async function scanSinglePlugin(pluginDir) {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plugin subagents ignore hooks/mcpServers/permissionMode frontmatter (V15)
|
||||||
|
// — dead config (permissionMode = medium: false sense of restriction).
|
||||||
|
for (const { key, severity } of PLUGIN_AGENT_IGNORED_FIELDS) {
|
||||||
|
if (frontmatter[key] !== undefined) {
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity,
|
||||||
|
title: `Plugin agent sets "${key}", which Claude Code ignores`,
|
||||||
|
description: `Agent "${file}" in plugin "${pluginName}" sets "${key}" in frontmatter, but Claude Code ignores ${key} for plugin subagents — ${key === 'permissionMode' ? 'the agent runs with default permissions, not the restricted mode this implies' : 'this configuration has no effect'}.`,
|
||||||
|
file: filePath,
|
||||||
|
evidence: `${key}: ${JSON.stringify(frontmatter[key])}`,
|
||||||
|
recommendation: `Remove "${key}" from the agent frontmatter, or ship the agent as a user/project agent in .claude/agents/, where ${key} is honored.`,
|
||||||
|
autoFixable: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch { /* no agents dir */ }
|
} catch { /* no agents dir */ }
|
||||||
|
|
||||||
|
|
@ -309,7 +505,7 @@ async function scanSinglePlugin(pluginDir) {
|
||||||
}
|
}
|
||||||
} catch { /* skip */ }
|
} catch { /* skip */ }
|
||||||
|
|
||||||
return { name: pluginName, findings, commandCount, agentCount };
|
return { name: pluginName, declaredName, findings, commandCount, agentCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -344,10 +540,16 @@ export async function scan(targetPath) {
|
||||||
allFindings.push(...result.findings);
|
allFindings.push(...result.findings);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-plugin checks: command name conflicts
|
// Cross-plugin checks: command-name ambiguity across DIFFERENT plugin namespaces.
|
||||||
const commandNames = new Map(); // name → plugin
|
// Commands are namespaced by the plugin's declared name (/name:command), so a
|
||||||
|
// shared command name across DIFFERENT plugins is ambiguity — not a hard
|
||||||
|
// conflict — mirroring COL's plugin-vs-plugin skill check (low). When two
|
||||||
|
// plugins share the SAME declared namespace, the namespace-collision finding
|
||||||
|
// below already covers it, so this check keys on the namespace and fires only
|
||||||
|
// when a command name spans 2+ DISTINCT namespaces.
|
||||||
|
const commandsByNamespace = new Map(); // cmdName → Map<namespace, { path }>
|
||||||
for (let idx = 0; idx < pluginResults.length; idx++) {
|
for (let idx = 0; idx < pluginResults.length; idx++) {
|
||||||
const pr = pluginResults[idx];
|
const namespace = pluginResults[idx].declaredName || basename(pluginDirs[idx]);
|
||||||
const commandsDir = join(pluginDirs[idx], 'commands');
|
const commandsDir = join(pluginDirs[idx], 'commands');
|
||||||
try {
|
try {
|
||||||
const entries = await readdir(commandsDir);
|
const entries = await readdir(commandsDir);
|
||||||
|
|
@ -357,22 +559,78 @@ export async function scan(targetPath) {
|
||||||
const { frontmatter } = parseFrontmatter(content);
|
const { frontmatter } = parseFrontmatter(content);
|
||||||
if (frontmatter && frontmatter.name) {
|
if (frontmatter && frontmatter.name) {
|
||||||
const cmdName = frontmatter.name;
|
const cmdName = frontmatter.name;
|
||||||
if (commandNames.has(cmdName)) {
|
if (!commandsByNamespace.has(cmdName)) commandsByNamespace.set(cmdName, new Map());
|
||||||
allFindings.push(finding({
|
const nsMap = commandsByNamespace.get(cmdName);
|
||||||
scanner: SCANNER,
|
if (!nsMap.has(namespace)) nsMap.set(namespace, { path: filePath });
|
||||||
severity: SEVERITY.high,
|
|
||||||
title: 'Cross-plugin command name conflict',
|
|
||||||
description: `Command "${cmdName}" exists in both "${commandNames.get(cmdName)}" and "${pr.name}"`,
|
|
||||||
file: filePath,
|
|
||||||
recommendation: `Rename one of the conflicting commands to avoid ambiguity`,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
commandNames.set(cmdName, pr.name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* no commands dir */ }
|
} catch { /* no commands dir */ }
|
||||||
}
|
}
|
||||||
|
for (const [cmdName, nsMap] of commandsByNamespace) {
|
||||||
|
if (nsMap.size < 2) continue; // single namespace → no cross-plugin ambiguity
|
||||||
|
const entries = [...nsMap.entries()].map(([namespace, v]) => ({ namespace, path: v.path }));
|
||||||
|
const namespaceList = entries.map(e => e.namespace).join(', ');
|
||||||
|
allFindings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.low,
|
||||||
|
title: `Command name "${cmdName}" used by multiple plugins`,
|
||||||
|
description:
|
||||||
|
`${entries.length} plugins (${namespaceList}) expose a command named "${cmdName}". ` +
|
||||||
|
'Even when invocation is namespaced via /plugin:command, shared names create ambiguity ' +
|
||||||
|
'in error messages, search results, and the command listing.',
|
||||||
|
file: entries[0].path,
|
||||||
|
evidence: `name="${cmdName}"; plugins=${entries.map(e => e.namespace).join(',')}`,
|
||||||
|
recommendation:
|
||||||
|
'Coordinate command naming across plugins, or rename one to clarify intent. The shared ' +
|
||||||
|
'name forces every reader to disambiguate by plugin.',
|
||||||
|
category: 'plugin-hygiene',
|
||||||
|
details: {
|
||||||
|
namespaces: entries.map(e => ({ source: `plugin:${e.namespace}`, name: cmdName, path: e.path })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-plugin checks: plugin namespace (declared name) collisions.
|
||||||
|
// Claude Code namespaces every plugin component by the plugin's declared
|
||||||
|
// `name` (/name:command, name:skill, agent "name"). Two plugins that declare
|
||||||
|
// the SAME name collapse into one namespace; the resolution between two
|
||||||
|
// installed plugins is undocumented, so one plugin's components are silently
|
||||||
|
// shadowed. Name-less plugins are flagged elsewhere and never grouped here.
|
||||||
|
const byDeclaredName = new Map(); // declaredName → string[] of plugin dirs
|
||||||
|
for (let idx = 0; idx < pluginResults.length; idx++) {
|
||||||
|
const declaredName = pluginResults[idx].declaredName;
|
||||||
|
if (!declaredName) continue;
|
||||||
|
if (!byDeclaredName.has(declaredName)) byDeclaredName.set(declaredName, []);
|
||||||
|
byDeclaredName.get(declaredName).push(pluginDirs[idx]);
|
||||||
|
}
|
||||||
|
for (const [declaredName, dirs] of byDeclaredName) {
|
||||||
|
if (dirs.length < 2) continue;
|
||||||
|
allFindings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.medium,
|
||||||
|
title: `Plugin namespace collision: "${declaredName}"`,
|
||||||
|
description:
|
||||||
|
`${dirs.length} plugins declare the same name "${declaredName}" in plugin.json. ` +
|
||||||
|
`Claude Code namespaces every plugin component by that name ` +
|
||||||
|
`(/${declaredName}:command, ${declaredName}:skill, agent "${declaredName}"), so the ` +
|
||||||
|
'namespaces collapse into one. Resolution between two installed plugins of the same ' +
|
||||||
|
"name is undocumented — one plugin's commands, skills, and agents are silently shadowed " +
|
||||||
|
'and become unreachable.',
|
||||||
|
file: join(dirs[0], '.claude-plugin', 'plugin.json'),
|
||||||
|
evidence: `name="${declaredName}"; plugins=${dirs.map(d => basename(d)).join(',')}`,
|
||||||
|
recommendation:
|
||||||
|
'Rename one plugin\'s "name" in plugin.json so each plugin owns a distinct namespace. ' +
|
||||||
|
'The folder name does not matter — the declared "name" field is the namespace.',
|
||||||
|
category: 'plugin-hygiene',
|
||||||
|
details: {
|
||||||
|
namespaces: dirs.map(d => ({
|
||||||
|
source: `plugin:${basename(d)}`,
|
||||||
|
name: declaredName,
|
||||||
|
path: join(d, '.claude-plugin', 'plugin.json'),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
return scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start);
|
return scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { writeFile } from 'node:fs/promises';
|
import { writeFile } from 'node:fs/promises';
|
||||||
import { runAllScanners } from './scan-orchestrator.mjs';
|
import { runAllScanners } from './scan-orchestrator.mjs';
|
||||||
|
import { humanizeEnvelope } from './lib/humanizer.mjs';
|
||||||
import {
|
import {
|
||||||
calculateUtilization,
|
calculateUtilization,
|
||||||
determineMaturityLevel,
|
determineMaturityLevel,
|
||||||
|
|
@ -63,10 +64,13 @@ async function main() {
|
||||||
let rawMode = false;
|
let rawMode = false;
|
||||||
let includeGlobal = false;
|
let includeGlobal = false;
|
||||||
let fullMachine = false;
|
let fullMachine = false;
|
||||||
|
let contextWindow = null;
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
if (args[i] === '--output-file' && args[i + 1]) {
|
if (args[i] === '--output-file' && args[i + 1]) {
|
||||||
outputFile = args[++i];
|
outputFile = args[++i];
|
||||||
|
} else if (args[i] === '--context-window' && args[i + 1]) {
|
||||||
|
contextWindow = args[++i];
|
||||||
} else if (args[i] === '--json') {
|
} else if (args[i] === '--json') {
|
||||||
jsonMode = true;
|
jsonMode = true;
|
||||||
} else if (args[i] === '--raw') {
|
} else if (args[i] === '--raw') {
|
||||||
|
|
@ -89,6 +93,7 @@ async function main() {
|
||||||
fullMachine,
|
fullMachine,
|
||||||
filterFixtures,
|
filterFixtures,
|
||||||
humanizedProgress,
|
humanizedProgress,
|
||||||
|
contextWindow,
|
||||||
});
|
});
|
||||||
|
|
||||||
// stdout JSON path: --json and --raw both write the v5.0.0-shape result
|
// stdout JSON path: --json and --raw both write the v5.0.0-shape result
|
||||||
|
|
@ -110,7 +115,14 @@ async function main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (outputFile) {
|
if (outputFile) {
|
||||||
const json = JSON.stringify(result, null, 2);
|
// Consumers (feature-gap.md, posture.md) read scannerEnvelope.scanners[].findings
|
||||||
|
// and group on humanizer fields. posture's result nests the envelope under
|
||||||
|
// `scannerEnvelope`, so humanize THAT (not `result`, which has no top-level
|
||||||
|
// `scanners` array — humanizeEnvelope would no-op). --json/--raw stay raw.
|
||||||
|
const fileEnv = (jsonMode || rawMode)
|
||||||
|
? result
|
||||||
|
: { ...result, scannerEnvelope: humanizeEnvelope(result.scannerEnvelope) };
|
||||||
|
const json = JSON.stringify(fileEnv, null, 2);
|
||||||
await writeFile(outputFile, json, 'utf-8');
|
await writeFile(outputFile, json, 'utf-8');
|
||||||
process.stderr.write(`\nResults written to ${outputFile}\n`);
|
process.stderr.write(`\nResults written to ${outputFile}\n`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { SEVERITY } from './lib/severity.mjs';
|
||||||
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||||
import { lineCount, truncate } from './lib/string-utils.mjs';
|
import { lineCount, truncate } from './lib/string-utils.mjs';
|
||||||
import { readdir, stat } from 'node:fs/promises';
|
import { readdir, stat } from 'node:fs/promises';
|
||||||
import { join, resolve, relative } from 'node:path';
|
import { join, resolve, relative, sep } from 'node:path';
|
||||||
|
|
||||||
const SCANNER = 'RUL';
|
const SCANNER = 'RUL';
|
||||||
|
|
||||||
|
|
@ -30,8 +30,16 @@ export async function scan(targetPath, discovery) {
|
||||||
return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start);
|
return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all real files in the project for glob matching
|
// Rule path patterns scope relative to the rule's OWN project root (the dir
|
||||||
const projectFiles = await collectProjectFiles(targetPath);
|
// containing its .claude/), not the outer scan root. Resolve + cache per root.
|
||||||
|
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||||
|
const projectFilesByRoot = new Map();
|
||||||
|
async function projectFilesFor(root) {
|
||||||
|
if (!projectFilesByRoot.has(root)) {
|
||||||
|
projectFilesByRoot.set(root, await collectProjectFiles(root));
|
||||||
|
}
|
||||||
|
return projectFilesByRoot.get(root);
|
||||||
|
}
|
||||||
|
|
||||||
for (const file of ruleFiles) {
|
for (const file of ruleFiles) {
|
||||||
const content = await readTextFile(file.absPath);
|
const content = await readTextFile(file.absPath);
|
||||||
|
|
@ -62,11 +70,11 @@ export async function scan(targetPath, discovery) {
|
||||||
findings.push(finding({
|
findings.push(finding({
|
||||||
scanner: SCANNER,
|
scanner: SCANNER,
|
||||||
severity: SEVERITY.low,
|
severity: SEVERITY.low,
|
||||||
title: 'Rule uses deprecated "globs" field',
|
title: 'Rule uses "globs" instead of documented "paths"',
|
||||||
description: `${file.relPath} uses "globs:" which is legacy. Use "paths:" instead.`,
|
description: `${file.relPath} uses "globs:" for scoping. Claude Code's documentation specifies "paths:" as the rule-scoping field; "globs:" is not documented. Rename to "paths:" so the rule scopes as intended.`,
|
||||||
file: file.absPath,
|
file: file.absPath,
|
||||||
evidence: `globs: ${JSON.stringify(frontmatter.globs)}`,
|
evidence: `globs: ${JSON.stringify(frontmatter.globs)}`,
|
||||||
recommendation: 'Rename "globs:" to "paths:" in frontmatter.',
|
recommendation: 'Rename "globs:" to "paths:" — paths: is the documented field for path-scoped rules.',
|
||||||
autoFixable: true,
|
autoFixable: true,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -74,11 +82,20 @@ export async function scan(targetPath, discovery) {
|
||||||
if (paths) {
|
if (paths) {
|
||||||
const patterns = Array.isArray(paths) ? paths : [paths];
|
const patterns = Array.isArray(paths) ? paths : [paths];
|
||||||
|
|
||||||
|
// A rule scopes relative to its own project root (parent of its .claude/),
|
||||||
|
// not the scan root. User-global rules (root === HOME) match against the
|
||||||
|
// active project at runtime, so "matches no files here" is not meaningful.
|
||||||
|
const projectRoot = deriveProjectRoot(file.absPath) || targetPath;
|
||||||
|
const isUserGlobal = home && projectRoot === home;
|
||||||
|
|
||||||
|
if (!isUserGlobal) {
|
||||||
|
const projectFiles = await projectFilesFor(projectRoot);
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
if (typeof pattern !== 'string') continue;
|
if (typeof pattern !== 'string') continue;
|
||||||
|
|
||||||
// Check if pattern matches any real files
|
// Check if pattern matches any real files (relative to the rule's root)
|
||||||
const matchCount = countGlobMatches(pattern, projectFiles, targetPath);
|
const matchCount = countGlobMatches(pattern, projectFiles, projectRoot);
|
||||||
if (matchCount === 0) {
|
if (matchCount === 0) {
|
||||||
findings.push(finding({
|
findings.push(finding({
|
||||||
scanner: SCANNER,
|
scanner: SCANNER,
|
||||||
|
|
@ -94,6 +111,7 @@ export async function scan(targetPath, discovery) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Content quality checks ---
|
// --- Content quality checks ---
|
||||||
if (lines < 2) {
|
if (lines < 2) {
|
||||||
|
|
@ -122,6 +140,23 @@ export async function scan(targetPath, discovery) {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A large PATH-SCOPED rule follows best practice, but path-scoped rules are
|
||||||
|
// NOT re-injected after a context compaction — they reload only when a
|
||||||
|
// matching file is read again (context-window.md). A big one carrying
|
||||||
|
// must-always-hold instructions can silently drop out mid-session.
|
||||||
|
if (frontmatter?.paths && lines > 50) {
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.low,
|
||||||
|
title: 'Large path-scoped rule is lost after compaction',
|
||||||
|
description: `${file.relPath} is path-scoped (${lines} lines). Path-scoped rules load only when a matching file is read, and after a context compaction they are not re-injected until a matching file is read again — so a large scoped rule carrying must-always-hold instructions can silently drop out mid-session.`,
|
||||||
|
file: file.absPath,
|
||||||
|
evidence: `${lines} lines, path-scoped`,
|
||||||
|
recommendation: 'If parts of this rule must always apply, move them to the project-root CLAUDE.md (re-injected after compaction). Keep path-scoped rules for context only needed when those files are open.',
|
||||||
|
autoFixable: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// Check file extension
|
// Check file extension
|
||||||
if (!file.absPath.endsWith('.md')) {
|
if (!file.absPath.endsWith('.md')) {
|
||||||
findings.push(finding({
|
findings.push(finding({
|
||||||
|
|
@ -178,6 +213,18 @@ async function collectProjectFiles(targetPath, depth = 0) {
|
||||||
* @param {string} basePath
|
* @param {string} basePath
|
||||||
* @returns {number}
|
* @returns {number}
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Resolve the project root a rule scopes against: the directory containing the
|
||||||
|
* `.claude/` dir the rule lives under. `/a/b/.claude/rules/x.md` → `/a/b`.
|
||||||
|
* Returns null if the path has no `.claude` segment.
|
||||||
|
*/
|
||||||
|
function deriveProjectRoot(ruleAbsPath) {
|
||||||
|
const parts = ruleAbsPath.split(sep);
|
||||||
|
const idx = parts.lastIndexOf('.claude');
|
||||||
|
if (idx <= 0) return null;
|
||||||
|
return parts.slice(0, idx).join(sep);
|
||||||
|
}
|
||||||
|
|
||||||
function countGlobMatches(pattern, files, basePath) {
|
function countGlobMatches(pattern, files, basePath) {
|
||||||
try {
|
try {
|
||||||
const regex = globToRegex(pattern);
|
const regex = globToRegex(pattern);
|
||||||
|
|
@ -204,9 +251,9 @@ function globToRegex(pattern) {
|
||||||
.replace(/\/\*\*\//g, '{{GLOBSTAR_SLASH}}')
|
.replace(/\/\*\*\//g, '{{GLOBSTAR_SLASH}}')
|
||||||
.replace(/\*\*/g, '{{GLOBSTAR}}')
|
.replace(/\*\*/g, '{{GLOBSTAR}}')
|
||||||
.replace(/\*/g, '[^/]*')
|
.replace(/\*/g, '[^/]*')
|
||||||
|
.replace(/\?/g, '[^/]') // must run BEFORE placeholder restore — '(?:' would corrupt
|
||||||
.replace(/\{\{GLOBSTAR_SLASH\}\}/g, '(?:/.+/|/)') // **/ matches 0+ intermediate dirs
|
.replace(/\{\{GLOBSTAR_SLASH\}\}/g, '(?:/.+/|/)') // **/ matches 0+ intermediate dirs
|
||||||
.replace(/\{\{GLOBSTAR\}\}/g, '.*')
|
.replace(/\{\{GLOBSTAR\}\}/g, '.*');
|
||||||
.replace(/\?/g, '[^/]');
|
|
||||||
|
|
||||||
// Handle leading patterns
|
// Handle leading patterns
|
||||||
if (!regex.startsWith('.*') && !regex.startsWith('/')) {
|
if (!regex.startsWith('.*') && !regex.startsWith('/')) {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import { envelope } from './lib/output.mjs';
|
||||||
import { discoverConfigFiles, discoverConfigFilesMulti, discoverFullMachinePaths } from './lib/file-discovery.mjs';
|
import { discoverConfigFiles, discoverConfigFilesMulti, discoverFullMachinePaths } from './lib/file-discovery.mjs';
|
||||||
import { loadSuppressions, applySuppressions, formatSuppressionSummary } from './lib/suppression.mjs';
|
import { loadSuppressions, applySuppressions, formatSuppressionSummary } from './lib/suppression.mjs';
|
||||||
import { humanizeEnvelope } from './lib/humanizer.mjs';
|
import { humanizeEnvelope } from './lib/humanizer.mjs';
|
||||||
|
import { resolveContextWindow } from './lib/context-window.mjs';
|
||||||
|
import { resolveActiveModel } from './lib/active-model.mjs';
|
||||||
|
|
||||||
// Scanner registry — import order determines execution order
|
// Scanner registry — import order determines execution order
|
||||||
import { scan as scanClaudeMd } from './claude-md-linter.mjs';
|
import { scan as scanClaudeMd } from './claude-md-linter.mjs';
|
||||||
|
|
@ -29,6 +31,9 @@ import { scan as scanCachePrefix } from './cache-prefix-scanner.mjs';
|
||||||
import { scan as scanDisabledInSchema } from './disabled-in-schema-scanner.mjs';
|
import { scan as scanDisabledInSchema } from './disabled-in-schema-scanner.mjs';
|
||||||
import { scan as scanCollision } from './collision-scanner.mjs';
|
import { scan as scanCollision } from './collision-scanner.mjs';
|
||||||
import { scan as scanSkillListing } from './skill-listing-scanner.mjs';
|
import { scan as scanSkillListing } from './skill-listing-scanner.mjs';
|
||||||
|
import { scan as scanAgentListing } from './agent-listing-scanner.mjs';
|
||||||
|
import { scan as scanOutputStyle } from './output-style-scanner.mjs';
|
||||||
|
import { scan as scanOptimizationLens } from './optimization-lens-scanner.mjs';
|
||||||
|
|
||||||
// Directory names that identify test fixture / example directories
|
// Directory names that identify test fixture / example directories
|
||||||
const FIXTURE_DIR_NAMES = ['tests', 'examples', '__tests__', 'test-fixtures'];
|
const FIXTURE_DIR_NAMES = ['tests', 'examples', '__tests__', 'test-fixtures'];
|
||||||
|
|
@ -64,6 +69,9 @@ const SCANNERS = [
|
||||||
{ name: 'DIS', fn: scanDisabledInSchema, label: 'Disabled-In-Schema' },
|
{ name: 'DIS', fn: scanDisabledInSchema, label: 'Disabled-In-Schema' },
|
||||||
{ name: 'COL', fn: scanCollision, label: 'Plugin Skill Collision' },
|
{ name: 'COL', fn: scanCollision, label: 'Plugin Skill Collision' },
|
||||||
{ name: 'SKL', fn: scanSkillListing, label: 'Skill-Listing Budget' },
|
{ name: 'SKL', fn: scanSkillListing, label: 'Skill-Listing Budget' },
|
||||||
|
{ name: 'AGT', fn: scanAgentListing, label: 'Agent-Listing Budget' },
|
||||||
|
{ name: 'OST', fn: scanOutputStyle, label: 'Output-Style Validation' },
|
||||||
|
{ name: 'OPT', fn: scanOptimizationLens, label: 'Optimization Lens' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -74,6 +82,7 @@ const SCANNERS = [
|
||||||
* @param {boolean} [opts.fullMachine=false] - Scan all known locations across the machine
|
* @param {boolean} [opts.fullMachine=false] - Scan all known locations across the machine
|
||||||
* @param {boolean} [opts.suppress=true] - Apply suppressions from .config-audit-ignore
|
* @param {boolean} [opts.suppress=true] - Apply suppressions from .config-audit-ignore
|
||||||
* @param {boolean} [opts.filterFixtures=true] - Exclude findings from test/example paths
|
* @param {boolean} [opts.filterFixtures=true] - Exclude findings from test/example paths
|
||||||
|
* @param {boolean} [opts.excludeCache=true] - Drop stale ~/.claude/plugins/cache versions so findings reflect live config (B3)
|
||||||
* @returns {Promise<object>} Full envelope with all results
|
* @returns {Promise<object>} Full envelope with all results
|
||||||
*/
|
*/
|
||||||
// Exported for testing
|
// Exported for testing
|
||||||
|
|
@ -83,14 +92,30 @@ export async function runAllScanners(targetPath, opts = {}) {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const resolvedPath = resolve(targetPath);
|
const resolvedPath = resolve(targetPath);
|
||||||
|
|
||||||
|
// Default ON: stale cached plugin versions otherwise inflate token hotspots
|
||||||
|
// and CNF duplicate-hook findings with config that loads on zero turns. (B3)
|
||||||
|
const excludeCache = opts.excludeCache !== false;
|
||||||
|
|
||||||
|
// B8 — resolve the context window once and thread it to budget-aware scanners
|
||||||
|
// (SKL, CML). Undefined opts.contextWindow → conservative 200k anchor, which is
|
||||||
|
// byte-identical to the pre-B8 default; other scanners ignore the third arg.
|
||||||
|
// B8b — `--context-window auto` probes the configured model (settings cascade /
|
||||||
|
// ANTHROPIC_MODEL) so a 1M-tier host self-calibrates; unknown/unpinned → advisory.
|
||||||
|
let probedModel = null;
|
||||||
|
if (String(opts.contextWindow ?? '').trim().toLowerCase() === 'auto') {
|
||||||
|
probedModel = await resolveActiveModel(resolvedPath, { env: process.env });
|
||||||
|
}
|
||||||
|
const contextWindow = resolveContextWindow(opts.contextWindow, { model: probedModel });
|
||||||
|
|
||||||
// Shared file discovery — scanners reuse this
|
// Shared file discovery — scanners reuse this
|
||||||
let discovery;
|
let discovery;
|
||||||
if (opts.fullMachine) {
|
if (opts.fullMachine) {
|
||||||
const roots = await discoverFullMachinePaths();
|
const roots = await discoverFullMachinePaths();
|
||||||
discovery = await discoverConfigFilesMulti(roots);
|
discovery = await discoverConfigFilesMulti(roots, { excludeCache });
|
||||||
} else {
|
} else {
|
||||||
discovery = await discoverConfigFiles(resolvedPath, {
|
discovery = await discoverConfigFiles(resolvedPath, {
|
||||||
includeGlobal: opts.includeGlobal || false,
|
includeGlobal: opts.includeGlobal || false,
|
||||||
|
excludeCache,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,7 +125,7 @@ export async function runAllScanners(targetPath, opts = {}) {
|
||||||
resetCounter();
|
resetCounter();
|
||||||
const scanStart = Date.now();
|
const scanStart = Date.now();
|
||||||
try {
|
try {
|
||||||
const result = await scanner.fn(resolvedPath, discovery);
|
const result = await scanner.fn(resolvedPath, discovery, { contextWindow });
|
||||||
results.push(result);
|
results.push(result);
|
||||||
const count = result.findings.length;
|
const count = result.findings.length;
|
||||||
const label = opts.humanizedProgress
|
const label = opts.humanizedProgress
|
||||||
|
|
@ -194,10 +219,13 @@ async function main() {
|
||||||
let outputFile = null;
|
let outputFile = null;
|
||||||
let saveBaseline = false;
|
let saveBaseline = false;
|
||||||
let baselinePath = null;
|
let baselinePath = null;
|
||||||
|
let contextWindow = null;
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
if (args[i] === '--output-file' && args[i + 1]) {
|
if (args[i] === '--output-file' && args[i + 1]) {
|
||||||
outputFile = args[++i];
|
outputFile = args[++i];
|
||||||
|
} else if (args[i] === '--context-window' && args[i + 1]) {
|
||||||
|
contextWindow = args[++i];
|
||||||
} else if (args[i] === '--save-baseline') {
|
} else if (args[i] === '--save-baseline') {
|
||||||
saveBaseline = true;
|
saveBaseline = true;
|
||||||
} else if (args[i] === '--baseline' && args[i + 1]) {
|
} else if (args[i] === '--baseline' && args[i + 1]) {
|
||||||
|
|
@ -210,6 +238,8 @@ async function main() {
|
||||||
// handled below
|
// handled below
|
||||||
} else if (args[i] === '--include-fixtures') {
|
} else if (args[i] === '--include-fixtures') {
|
||||||
// handled below
|
// handled below
|
||||||
|
} else if (args[i] === '--exclude-cache' || args[i] === '--no-exclude-cache') {
|
||||||
|
// handled below
|
||||||
} else if (args[i] === '--json') {
|
} else if (args[i] === '--json') {
|
||||||
// handled below — explicit machine-readable mode (bypass humanizer)
|
// handled below — explicit machine-readable mode (bypass humanizer)
|
||||||
} else if (args[i] === '--raw') {
|
} else if (args[i] === '--raw') {
|
||||||
|
|
@ -223,6 +253,7 @@ async function main() {
|
||||||
const fullMachine = args.includes('--full-machine');
|
const fullMachine = args.includes('--full-machine');
|
||||||
const suppress = !args.includes('--no-suppress');
|
const suppress = !args.includes('--no-suppress');
|
||||||
const filterFixtures = !args.includes('--include-fixtures');
|
const filterFixtures = !args.includes('--include-fixtures');
|
||||||
|
const excludeCache = !args.includes('--no-exclude-cache');
|
||||||
const jsonMode = args.includes('--json');
|
const jsonMode = args.includes('--json');
|
||||||
const rawMode = args.includes('--raw');
|
const rawMode = args.includes('--raw');
|
||||||
|
|
||||||
|
|
@ -237,7 +268,9 @@ async function main() {
|
||||||
fullMachine,
|
fullMachine,
|
||||||
suppress,
|
suppress,
|
||||||
filterFixtures,
|
filterFixtures,
|
||||||
|
excludeCache,
|
||||||
humanizedProgress,
|
humanizedProgress,
|
||||||
|
contextWindow,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Default mode runs the humanizer; --json and --raw bypass for v5.0.0 byte-equal output.
|
// Default mode runs the humanizer; --json and --raw bypass for v5.0.0 byte-equal output.
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ import { readTextFile } from './lib/file-discovery.mjs';
|
||||||
import { finding, scannerResult } from './lib/output.mjs';
|
import { finding, scannerResult } from './lib/output.mjs';
|
||||||
import { SEVERITY } from './lib/severity.mjs';
|
import { SEVERITY } from './lib/severity.mjs';
|
||||||
import { parseJson } from './lib/yaml-parser.mjs';
|
import { parseJson } from './lib/yaml-parser.mjs';
|
||||||
import { extractKeys } from './lib/string-utils.mjs';
|
import { extractKeys, levenshtein } from './lib/string-utils.mjs';
|
||||||
|
|
||||||
const SCANNER = 'SET';
|
const SCANNER = 'SET';
|
||||||
|
|
||||||
/** Known top-level settings.json keys (as of CC 2.1.181 / June 2026) */
|
/** Known top-level settings.json keys (as of CC 2.1.193 / June 2026) */
|
||||||
const KNOWN_KEYS = new Set([
|
const KNOWN_KEYS = new Set([
|
||||||
'additionalDirectories',
|
'additionalDirectories',
|
||||||
'agent', 'allowAllClaudeAiMcps', 'allowedChannelPlugins', 'allowedHttpHookUrls',
|
'agent', 'allowAllClaudeAiMcps', 'allowedChannelPlugins', 'allowedHttpHookUrls',
|
||||||
|
|
@ -37,6 +37,9 @@ const KNOWN_KEYS = new Set([
|
||||||
'spinnerTipsOverride', 'spinnerVerbs', 'statusLine', 'strictKnownMarketplaces',
|
'spinnerTipsOverride', 'spinnerVerbs', 'statusLine', 'strictKnownMarketplaces',
|
||||||
'useAutoModeDuringPlan', 'voiceEnabled', 'wheelScrollAccelerationEnabled',
|
'useAutoModeDuringPlan', 'voiceEnabled', 'wheelScrollAccelerationEnabled',
|
||||||
'worktree', '$schema',
|
'worktree', '$schema',
|
||||||
|
// CC 2.1.193 binary-verified (M-BUG-10): present as quoted string literals in the binary
|
||||||
|
'agentPushNotifEnabled', 'remoteControlAtStartup', 'skipAutoPermissionPrompt',
|
||||||
|
'skipDangerousModePermissionPrompt', 'skipWorkflowUsageWarning', 'tui',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Deprecated keys with migration info */
|
/** Deprecated keys with migration info */
|
||||||
|
|
@ -75,6 +78,21 @@ const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
|
||||||
* a project root to walks/discovery, inflating per-turn cost and confusing scope. */
|
* a project root to walks/discovery, inflating per-turn cost and confusing scope. */
|
||||||
const ADDITIONAL_DIRS_THRESHOLD = 2;
|
const ADDITIONAL_DIRS_THRESHOLD = 2;
|
||||||
|
|
||||||
|
/** M-BUG-10: the CC settings schema is passthrough — it forwards unrecognized
|
||||||
|
* keys unchanged rather than rejecting them, so an arbitrary unknown key is
|
||||||
|
* valid/forward-compatible, not an error. The only real risk is a TYPO of a
|
||||||
|
* real key (the intended setting silently does nothing), so an unknown key is
|
||||||
|
* flagged ONLY when it closely matches a known key: edit distance within
|
||||||
|
* TYPO_MAX_DISTANCE and both keys at least TYPO_MIN_LEN chars (short keys are
|
||||||
|
* too noisy for reliable edit-distance matching). */
|
||||||
|
const TYPO_MAX_DISTANCE = 2;
|
||||||
|
const TYPO_MIN_LEN = 4;
|
||||||
|
|
||||||
|
/** The only valid sub-keys of `autoMode`, each a prose-rule string array
|
||||||
|
* (the literal "$defaults" is a valid entry). Verified against
|
||||||
|
* code.claude.com/docs/en/auto-mode-config. */
|
||||||
|
const AUTO_MODE_SUBKEYS = new Set(['environment', 'allow', 'soft_deny', 'hard_deny']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan all settings.json files discovered.
|
* Scan all settings.json files discovered.
|
||||||
* @param {string} targetPath
|
* @param {string} targetPath
|
||||||
|
|
@ -110,17 +128,32 @@ export async function scan(targetPath, discovery) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for unknown keys
|
// Check for unknown keys — typo gate (M-BUG-10). The CC settings schema is
|
||||||
|
// passthrough, so an unrecognized key is NOT an error; only a typo of a real
|
||||||
|
// key is (the intended setting silently does nothing). Flag a key only when
|
||||||
|
// it closely matches a known key; an unknown key far from every known key is
|
||||||
|
// treated as valid/forward-compatible and emitted nothing.
|
||||||
for (const key of Object.keys(parsed)) {
|
for (const key of Object.keys(parsed)) {
|
||||||
if (!KNOWN_KEYS.has(key)) {
|
if (KNOWN_KEYS.has(key)) continue;
|
||||||
|
let nearest = null;
|
||||||
|
let best = Infinity;
|
||||||
|
for (const known of KNOWN_KEYS) {
|
||||||
|
if (Math.min(key.length, known.length) < TYPO_MIN_LEN) continue;
|
||||||
|
const d = levenshtein(key, known);
|
||||||
|
if (d <= TYPO_MAX_DISTANCE && d < best) {
|
||||||
|
best = d;
|
||||||
|
nearest = known;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nearest) {
|
||||||
findings.push(finding({
|
findings.push(finding({
|
||||||
scanner: SCANNER,
|
scanner: SCANNER,
|
||||||
severity: SEVERITY.medium,
|
severity: SEVERITY.low,
|
||||||
title: 'Unknown settings key',
|
title: 'Possible typo in settings key',
|
||||||
description: `${file.relPath}: "${key}" is not a recognized settings.json key. It will be silently ignored.`,
|
description: `${file.relPath}: "${key}" is not a recognized settings.json key, but it closely matches "${nearest}". Claude Code forwards unrecognized keys unchanged (it does not reject them), so if "${key}" is a typo of "${nearest}" the intended setting silently has no effect.`,
|
||||||
file: file.absPath,
|
file: file.absPath,
|
||||||
evidence: key,
|
evidence: key,
|
||||||
recommendation: 'Check spelling. See https://json.schemastore.org/claude-code-settings.json for valid keys.',
|
recommendation: `Did you mean "${nearest}"? Fix the spelling, or keep "${key}" if it is intentional (e.g. a newer settings key this audit does not recognize yet).`,
|
||||||
autoFixable: false,
|
autoFixable: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -234,6 +267,75 @@ export async function scan(targetPath, discovery) {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// autoMode validation (auto-mode classifier config).
|
||||||
|
// Structure: must be an object whose only keys are the four documented
|
||||||
|
// prose-rule arrays (each an array of strings; "$defaults" is a valid entry).
|
||||||
|
// Dead-config: Claude Code does NOT read autoMode from SHARED project settings
|
||||||
|
// (.claude/settings.json) — "a checked-in repo cannot inject its own allow
|
||||||
|
// rules" — so it is only honored in user/local/managed scopes.
|
||||||
|
if (parsed.autoMode !== undefined) {
|
||||||
|
const am = parsed.autoMode;
|
||||||
|
if (typeof am !== 'object' || am === null || Array.isArray(am)) {
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.medium,
|
||||||
|
title: 'autoMode must be an object',
|
||||||
|
description: `${file.relPath}: "autoMode" must be an object with environment/allow/soft_deny/hard_deny arrays, got ${Array.isArray(am) ? 'array' : typeof am}.`,
|
||||||
|
file: file.absPath,
|
||||||
|
evidence: `autoMode: ${JSON.stringify(am)}`,
|
||||||
|
recommendation: 'Set autoMode to an object, e.g. { "environment": ["$defaults"] }.',
|
||||||
|
autoFixable: false,
|
||||||
|
details: { key: 'autoMode', problem: 'not-an-object' },
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
for (const subKey of Object.keys(am)) {
|
||||||
|
if (!AUTO_MODE_SUBKEYS.has(subKey)) {
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.medium,
|
||||||
|
title: `autoMode has an unknown sub-key: ${subKey}`,
|
||||||
|
description: `${file.relPath}: "autoMode.${subKey}" is not a recognized sub-key. Valid keys are environment, allow, soft_deny, hard_deny. It is silently ignored — a typo of a real key (e.g. "hard_denies") means those rules never apply.`,
|
||||||
|
file: file.absPath,
|
||||||
|
evidence: `autoMode.${subKey}`,
|
||||||
|
recommendation: 'Use only environment, allow, soft_deny, hard_deny. Check for typos.',
|
||||||
|
autoFixable: false,
|
||||||
|
details: { key: 'autoMode', subKey, problem: 'unknown-subkey' },
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const val = am[subKey];
|
||||||
|
const isStringArray = Array.isArray(val) && val.every(e => typeof e === 'string');
|
||||||
|
if (!isStringArray) {
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.medium,
|
||||||
|
title: `autoMode.${subKey} must be an array of strings`,
|
||||||
|
description: `${file.relPath}: "autoMode.${subKey}" must be an array of prose-rule strings (the literal "$defaults" is allowed), got ${Array.isArray(val) ? 'an array with a non-string entry' : typeof val}.`,
|
||||||
|
file: file.absPath,
|
||||||
|
evidence: `autoMode.${subKey}: ${JSON.stringify(val)}`,
|
||||||
|
recommendation: `Set "autoMode.${subKey}" to an array of strings, e.g. ["$defaults"].`,
|
||||||
|
autoFixable: false,
|
||||||
|
details: { key: 'autoMode', subKey, problem: 'not-string-array' },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dead-config: shared project settings (.claude/settings.json) is not read.
|
||||||
|
if (file.scope === 'project') {
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.low,
|
||||||
|
title: 'autoMode in shared project settings is ignored by Claude Code',
|
||||||
|
description: `${file.relPath}: Claude Code does not read "autoMode" from shared project settings (.claude/settings.json), so a checked-in repo cannot inject its own rules. This autoMode block has no effect where it is.`,
|
||||||
|
file: file.absPath,
|
||||||
|
evidence: 'autoMode in shared .claude/settings.json (project scope)',
|
||||||
|
recommendation: 'Move autoMode to user settings (~/.claude/settings.json), local settings (.claude/settings.local.json), or managed settings.',
|
||||||
|
autoFixable: false,
|
||||||
|
details: { key: 'autoMode', problem: 'shared-project-scope' },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// hooks checks (basic — detailed in hook-validator)
|
// hooks checks (basic — detailed in hook-validator)
|
||||||
if (parsed.hooks) {
|
if (parsed.hooks) {
|
||||||
if (Array.isArray(parsed.hooks)) {
|
if (Array.isArray(parsed.hooks)) {
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,12 @@
|
||||||
* context window, so CA-SKL-002 anchors on a conservative 200k window and says
|
* context window, so CA-SKL-002 anchors on a conservative 200k window and says
|
||||||
* so loudly: it leads with the measured sum (a fact) and carries a calibration
|
* so loudly: it leads with the measured sum (a fact) and carries a calibration
|
||||||
* note explaining the budget scales 5x on a 1M-context model. Severity is low
|
* note explaining the budget scales 5x on a 1M-context model. Severity is low
|
||||||
* (an estimate) versus medium for the verified per-description cap. Each
|
* (an estimate) versus medium for the verified per-description cap.
|
||||||
* description is counted only up to the 1,536-char cap, because that is all
|
*
|
||||||
* Claude Code loads into the listing — the tail past the cap is dropped and is
|
* The cap, the budget, and the enumerate-and-measure step all live in
|
||||||
* already flagged by CA-SKL-001 (so the aggregate does not double-count it).
|
* `lib/skill-listing-budget.mjs` — the single source of truth shared with the
|
||||||
|
* GAP scanner, which prescribes `disableBundledSkills` when this budget is
|
||||||
|
* exceeded. SKL only constructs the findings.
|
||||||
*
|
*
|
||||||
* Two-lens note vs TOK pattern F: TOK pattern F flags *project-local* skills
|
* Two-lens note vs TOK pattern F: TOK pattern F flags *project-local* skills
|
||||||
* with descriptions > 500 chars as a structural per-turn "bloat" heuristic.
|
* with descriptions > 500 chars as a structural per-turn "bloat" heuristic.
|
||||||
|
|
@ -34,62 +36,50 @@
|
||||||
|
|
||||||
import { finding, scannerResult } from './lib/output.mjs';
|
import { finding, scannerResult } from './lib/output.mjs';
|
||||||
import { SEVERITY } from './lib/severity.mjs';
|
import { SEVERITY } from './lib/severity.mjs';
|
||||||
import { enumeratePlugins, enumerateSkills, estimateTokens } from './lib/active-config-reader.mjs';
|
import {
|
||||||
import { readTextFile } from './lib/file-discovery.mjs';
|
DESCRIPTION_CAP,
|
||||||
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
AGGREGATE_BUDGET_TOKENS,
|
||||||
|
BUDGET_CALIBRATION_NOTE,
|
||||||
|
BODY_TOKEN_THRESHOLD,
|
||||||
|
BODY_CALIBRATION_NOTE,
|
||||||
|
measureActiveSkillListing,
|
||||||
|
} from './lib/skill-listing-budget.mjs';
|
||||||
|
import { CONTEXT_WINDOW_ANCHOR, scaleForWindow, withCommas } from './lib/context-window.mjs';
|
||||||
|
|
||||||
|
// Shared remediation for the aggregate-budget finding (byte-identical across the
|
||||||
|
// default and the B8 window-calibrated branches).
|
||||||
|
const AGGREGATE_RECOMMENDATION =
|
||||||
|
'Reclaim skill-listing budget: set `disableBundledSkills: true` to drop bundled skills you ' +
|
||||||
|
'do not use from the listing, use `skillOverrides` (`name-only` collapses a description, ' +
|
||||||
|
'`off` removes a skill) on the heaviest entries, and trim long descriptions toward their ' +
|
||||||
|
'trigger phrases.';
|
||||||
|
|
||||||
const SCANNER = 'SKL';
|
const SCANNER = 'SKL';
|
||||||
|
|
||||||
// Verified per-description skill-listing cap (CC 2.1.105, changelog L1502).
|
|
||||||
// Descriptions longer than this are truncated in the listing the model sees.
|
|
||||||
const DESCRIPTION_CAP = 1536;
|
|
||||||
|
|
||||||
// Aggregate listing budget (CC 2.1.32, changelog L2860): the skill listing the
|
|
||||||
// model reads is allotted ~2% of the context window. The context window is
|
|
||||||
// unknown, so we anchor on a conservative 200k window — the smallest common
|
|
||||||
// size, which fires earliest — and disclose the assumption in the evidence.
|
|
||||||
const BUDGET_FRACTION = 0.02;
|
|
||||||
const CONTEXT_WINDOW_ANCHOR = 200_000;
|
|
||||||
const AGGREGATE_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * CONTEXT_WINDOW_ANCHOR); // 4000
|
|
||||||
const LARGE_CONTEXT_WINDOW = 1_000_000;
|
|
||||||
const LARGE_CONTEXT_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * LARGE_CONTEXT_WINDOW); // 20000
|
|
||||||
|
|
||||||
// Dependency-free thousands separator (repo invariant: zero external deps).
|
|
||||||
const withCommas = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
||||||
|
|
||||||
// Appended to CA-SKL-002 evidence — the honest framing required because the
|
|
||||||
// budget depends on a context window we cannot observe (jf. TOK CALIBRATION_NOTE).
|
|
||||||
const BUDGET_CALIBRATION_NOTE =
|
|
||||||
'the budget scales with the context window - this anchors on a conservative 200k ' +
|
|
||||||
`window; at ${withCommas(LARGE_CONTEXT_WINDOW)} context the budget is ~${withCommas(LARGE_CONTEXT_BUDGET_TOKENS)} ` +
|
|
||||||
'tok and you are likely within it. this is an estimate, not measured telemetry';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main scanner entry point.
|
* Main scanner entry point.
|
||||||
*
|
*
|
||||||
* @param {string} _targetPath unused (skill listing is HOME-scoped)
|
* @param {string} _targetPath unused (skill listing is HOME-scoped)
|
||||||
* @param {object} _discovery unused (ignores project discovery)
|
* @param {object} _discovery unused (ignores project discovery)
|
||||||
*/
|
*/
|
||||||
export async function scan(_targetPath, _discovery) {
|
export async function scan(_targetPath, _discovery, opts = {}) {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const findings = [];
|
const findings = [];
|
||||||
|
|
||||||
const plugins = await enumeratePlugins();
|
// B8 — calibrate the aggregate budget to the resolved context window. The
|
||||||
const allSkills = await enumerateSkills(plugins);
|
// default (no opts) is the conservative 200k anchor at full severity, which is
|
||||||
|
// byte-identical to the pre-B8 behavior. An unknown (advisory) window keeps the
|
||||||
|
// anchor but downgrades the finding to info instead of firing it as a breach.
|
||||||
|
const cw = opts.contextWindow;
|
||||||
|
const window = (cw && typeof cw.window === 'number') ? cw.window : CONTEXT_WINDOW_ANCHOR;
|
||||||
|
const advisory = !!(cw && cw.advisory);
|
||||||
|
const isDefault = window === CONTEXT_WINDOW_ANCHOR && !advisory;
|
||||||
|
const budgetTokens = scaleForWindow(AGGREGATE_BUDGET_TOKENS, window);
|
||||||
|
|
||||||
let scanned = 0;
|
const { skills, aggregate } = await measureActiveSkillListing(budgetTokens);
|
||||||
let aggregateChars = 0;
|
|
||||||
for (const skill of allSkills) {
|
for (const skill of skills) {
|
||||||
if (!skill || typeof skill.path !== 'string') continue;
|
if (skill.descLength <= DESCRIPTION_CAP) continue;
|
||||||
const content = await readTextFile(skill.path);
|
|
||||||
if (!content) continue;
|
|
||||||
scanned++;
|
|
||||||
const fm = parseFrontmatter(content)?.frontmatter || null;
|
|
||||||
const desc = (fm && typeof fm.description === 'string') ? fm.description : '';
|
|
||||||
// Aggregate budget counts only what loads in the listing: each description
|
|
||||||
// up to the cap (the tail past the cap is dropped, and CA-SKL-001 flags it).
|
|
||||||
aggregateChars += Math.min(desc.length, DESCRIPTION_CAP);
|
|
||||||
if (desc.length <= DESCRIPTION_CAP) continue;
|
|
||||||
|
|
||||||
const sourceLabel = skill.source === 'plugin'
|
const sourceLabel = skill.source === 'plugin'
|
||||||
? `plugin:${skill.pluginName}`
|
? `plugin:${skill.pluginName}`
|
||||||
|
|
@ -100,14 +90,14 @@ export async function scan(_targetPath, _discovery) {
|
||||||
severity: SEVERITY.medium,
|
severity: SEVERITY.medium,
|
||||||
title: 'Skill description exceeds the listing cap (Claude Code truncates it)',
|
title: 'Skill description exceeds the listing cap (Claude Code truncates it)',
|
||||||
description:
|
description:
|
||||||
`Skill "${skill.name}" (${sourceLabel}) has a description of ${desc.length} ` +
|
`Skill "${skill.name}" (${sourceLabel}) has a description of ${skill.descLength} ` +
|
||||||
`characters (>${DESCRIPTION_CAP}). Claude Code caps each skill description in ` +
|
`characters (>${DESCRIPTION_CAP}). Claude Code caps each skill description in ` +
|
||||||
'the listing the model reads to choose a skill, so everything past ' +
|
'the listing the model reads to choose a skill, so everything past ' +
|
||||||
`${DESCRIPTION_CAP} characters is silently dropped — including any trigger ` +
|
`${DESCRIPTION_CAP} characters is silently dropped — including any trigger ` +
|
||||||
'phrases at the tail meant to route invocation.',
|
'phrases at the tail meant to route invocation.',
|
||||||
file: skill.path,
|
file: skill.path,
|
||||||
evidence:
|
evidence:
|
||||||
`description_chars=${desc.length}; cap=${DESCRIPTION_CAP}; ` +
|
`description_chars=${skill.descLength}; cap=${DESCRIPTION_CAP}; ` +
|
||||||
`skill="${skill.name}"; source=${sourceLabel}`,
|
`skill="${skill.name}"; source=${sourceLabel}`,
|
||||||
recommendation:
|
recommendation:
|
||||||
`Trim the description below ${DESCRIPTION_CAP} characters, leading with the ` +
|
`Trim the description below ${DESCRIPTION_CAP} characters, leading with the ` +
|
||||||
|
|
@ -121,32 +111,87 @@ export async function scan(_targetPath, _discovery) {
|
||||||
|
|
||||||
// CA-SKL-002 (aggregate). Emitted after the per-skill findings so the common
|
// CA-SKL-002 (aggregate). Emitted after the per-skill findings so the common
|
||||||
// "one oversized skill + aggregate" case reads 001=cap, 002=aggregate.
|
// "one oversized skill + aggregate" case reads 001=cap, 002=aggregate.
|
||||||
const aggregateTokens = estimateTokens(aggregateChars, 'markdown');
|
if (aggregate.overBudget) {
|
||||||
if (aggregateTokens > AGGREGATE_BUDGET_TOKENS) {
|
if (isDefault) {
|
||||||
|
// Conservative 200k anchor — byte-identical to the pre-B8 finding.
|
||||||
findings.push(finding({
|
findings.push(finding({
|
||||||
scanner: SCANNER,
|
scanner: SCANNER,
|
||||||
severity: SEVERITY.low,
|
severity: SEVERITY.low,
|
||||||
title: 'Aggregate skill descriptions may exceed the listing budget',
|
title: 'Aggregate skill descriptions may exceed the listing budget',
|
||||||
description:
|
description:
|
||||||
`The ${scanned} active skills carry about ${aggregateTokens} tokens of description text ` +
|
`The ${aggregate.scanned} active skills carry about ${aggregate.aggregateTokens} tokens of description text ` +
|
||||||
`(each description counted up to the ${DESCRIPTION_CAP}-char listing cap), above the ` +
|
`(each description counted up to the ${DESCRIPTION_CAP}-char listing cap), above the ` +
|
||||||
`${AGGREGATE_BUDGET_TOKENS}-token budget Claude Code allots the skill listing on a 200k ` +
|
`${AGGREGATE_BUDGET_TOKENS}-token budget Claude Code allots the skill listing on a 200k ` +
|
||||||
'context window (about 2% of context, CC 2.1.32). When the listing overflows that budget ' +
|
'context window (about 2% of context, CC 2.1.32). When the listing overflows that budget ' +
|
||||||
'Claude Code drops descriptions, so the model may stop seeing some skills entirely. This ' +
|
'Claude Code drops descriptions, so the model may stop seeing some skills entirely. This ' +
|
||||||
'is an estimate — the budget scales with your actual context window (see evidence).',
|
'is an estimate — the budget scales with your actual context window (see evidence).',
|
||||||
evidence:
|
evidence:
|
||||||
`active_skills_scanned=${scanned}; description_chars=${aggregateChars} (each capped at ` +
|
`active_skills_scanned=${aggregate.scanned}; description_chars=${aggregate.aggregateChars} (each capped at ` +
|
||||||
`${DESCRIPTION_CAP}); description_tokens~${aggregateTokens}; budget@200k=` +
|
`${DESCRIPTION_CAP}); description_tokens~${aggregate.aggregateTokens}; budget@200k=` +
|
||||||
`${AGGREGATE_BUDGET_TOKENS} tok (skill listing ~2% of context, CC 2.1.32); over_by~` +
|
`${AGGREGATE_BUDGET_TOKENS} tok (skill listing ~2% of context, CC 2.1.32); over_by~` +
|
||||||
`${aggregateTokens - AGGREGATE_BUDGET_TOKENS} tok - ${BUDGET_CALIBRATION_NOTE}`,
|
`${aggregate.overBy} tok - ${BUDGET_CALIBRATION_NOTE}`,
|
||||||
|
recommendation: AGGREGATE_RECOMMENDATION,
|
||||||
|
category: 'token-efficiency',
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// B8 — window-calibrated. Advisory (unknown window) downgrades to info.
|
||||||
|
const winLabel = withCommas(window);
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: advisory ? SEVERITY.info : SEVERITY.low,
|
||||||
|
title: 'Aggregate skill descriptions may exceed the listing budget',
|
||||||
|
description:
|
||||||
|
`The ${aggregate.scanned} active skills carry about ${aggregate.aggregateTokens} tokens of description text ` +
|
||||||
|
`(each description counted up to the ${DESCRIPTION_CAP}-char listing cap), above the ` +
|
||||||
|
`${budgetTokens}-token budget Claude Code allots the skill listing at a ${winLabel}-token ` +
|
||||||
|
'context window (about 2% of context, CC 2.1.32). When the listing overflows that budget ' +
|
||||||
|
'Claude Code drops descriptions, so the model may stop seeing some skills entirely.' +
|
||||||
|
(advisory
|
||||||
|
? ' Your context window is unknown, so this is advisory: it anchors on the conservative 200k window.'
|
||||||
|
: ''),
|
||||||
|
evidence:
|
||||||
|
`active_skills_scanned=${aggregate.scanned}; description_chars=${aggregate.aggregateChars} (each capped at ` +
|
||||||
|
`${DESCRIPTION_CAP}); description_tokens~${aggregate.aggregateTokens}; budget@${winLabel}=` +
|
||||||
|
`${budgetTokens} tok (skill listing ~2% of context, CC 2.1.32); over_by~${aggregate.overBy} tok` +
|
||||||
|
(advisory ? ` - ${BUDGET_CALIBRATION_NOTE}` : ' - this is an estimate, not measured telemetry'),
|
||||||
|
recommendation: AGGREGATE_RECOMMENDATION,
|
||||||
|
category: 'token-efficiency',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CA-SKL-003 (oversized body). Emitted last so the common single-issue cases
|
||||||
|
// read cleanly. Unlike the listing budget, this is an ON-DEMAND cost — the body
|
||||||
|
// loads only when the skill is invoked, not every turn — hence low severity and
|
||||||
|
// an explicit on-demand calibration note.
|
||||||
|
for (const skill of skills) {
|
||||||
|
if (skill.bodyTokens <= BODY_TOKEN_THRESHOLD) continue;
|
||||||
|
|
||||||
|
const sourceLabel = skill.source === 'plugin'
|
||||||
|
? `plugin:${skill.pluginName}`
|
||||||
|
: 'user';
|
||||||
|
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.low,
|
||||||
|
title: 'Skill body is large (loads on demand when the skill runs)',
|
||||||
|
description:
|
||||||
|
`Skill "${skill.name}" (${sourceLabel}) has a body of about ${skill.bodyTokens} tokens ` +
|
||||||
|
`(${skill.bodyLines} lines), over the ~${BODY_TOKEN_THRESHOLD}-token guidance for a skill body. ` +
|
||||||
|
'The body is not in the always-loaded listing — it loads only when the skill is invoked — but ' +
|
||||||
|
'once loaded a large body consumes context for the rest of that session. Claude Code skill ' +
|
||||||
|
'guidance is to keep the body lean and move heavy reference material into supporting files.',
|
||||||
|
file: skill.path,
|
||||||
|
evidence:
|
||||||
|
`body_tokens~${skill.bodyTokens}; body_lines=${skill.bodyLines}; body_chars=${skill.bodyChars}; ` +
|
||||||
|
`threshold=${BODY_TOKEN_THRESHOLD} tok; skill="${skill.name}"; source=${sourceLabel} - ${BODY_CALIBRATION_NOTE}`,
|
||||||
recommendation:
|
recommendation:
|
||||||
'Reclaim skill-listing budget: set `disableBundledSkills: true` to drop bundled skills you ' +
|
'Move reference content into supporting files the skill loads only when needed, and consider ' +
|
||||||
'do not use from the listing, use `skillOverrides` (`name-only` collapses a description, ' +
|
'`context: fork` in the skill frontmatter for heavy skills so the body runs in a forked context ' +
|
||||||
'`off` removes a skill) on the heaviest entries, and trim long descriptions toward their ' +
|
'instead of consuming the main thread.',
|
||||||
'trigger phrases.',
|
|
||||||
category: 'token-efficiency',
|
category: 'token-efficiency',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return scannerResult(SCANNER, 'ok', findings, scanned, Date.now() - start);
|
return scannerResult(SCANNER, 'ok', findings, aggregate.scanned, Date.now() - start);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,10 @@ async function main() {
|
||||||
let includeGlobal = false;
|
let includeGlobal = false;
|
||||||
let withTelemetryRecipe = false;
|
let withTelemetryRecipe = false;
|
||||||
let accurateTokens = false;
|
let accurateTokens = false;
|
||||||
|
// Default ON for this live-cost scan: stale plugin-cache versions pollute the
|
||||||
|
// hotspot ranking with config that loads on zero turns. --no-exclude-cache
|
||||||
|
// restores the full walk. (B3)
|
||||||
|
let excludeCache = true;
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
if (args[i] === '--json') jsonMode = true;
|
if (args[i] === '--json') jsonMode = true;
|
||||||
|
|
@ -63,6 +67,8 @@ async function main() {
|
||||||
else if (args[i] === '--global') includeGlobal = true;
|
else if (args[i] === '--global') includeGlobal = true;
|
||||||
else if (args[i] === '--with-telemetry-recipe') withTelemetryRecipe = true;
|
else if (args[i] === '--with-telemetry-recipe') withTelemetryRecipe = true;
|
||||||
else if (args[i] === '--accurate-tokens') accurateTokens = true;
|
else if (args[i] === '--accurate-tokens') accurateTokens = true;
|
||||||
|
else if (args[i] === '--exclude-cache') excludeCache = true;
|
||||||
|
else if (args[i] === '--no-exclude-cache') excludeCache = false;
|
||||||
else if (args[i] === '--output-file' && args[i + 1]) outputFile = args[++i];
|
else if (args[i] === '--output-file' && args[i + 1]) outputFile = args[++i];
|
||||||
else if (!args[i].startsWith('-')) targetPath = args[i];
|
else if (!args[i].startsWith('-')) targetPath = args[i];
|
||||||
}
|
}
|
||||||
|
|
@ -80,7 +86,7 @@ async function main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
resetCounter();
|
resetCounter();
|
||||||
const discovery = await discoverConfigFiles(absPath, { includeGlobal });
|
const discovery = await discoverConfigFiles(absPath, { includeGlobal, excludeCache });
|
||||||
const result = await scan(absPath, discovery);
|
const result = await scan(absPath, discovery);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|
|
||||||
|
|
@ -22,15 +22,74 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { resolve, dirname, isAbsolute } from 'node:path';
|
import { resolve, dirname, isAbsolute } from 'node:path';
|
||||||
import { stat } from 'node:fs/promises';
|
import { stat, readFile } from 'node:fs/promises';
|
||||||
import { readTextFile } from './lib/file-discovery.mjs';
|
import { readTextFile } from './lib/file-discovery.mjs';
|
||||||
import { finding, scannerResult } from './lib/output.mjs';
|
import { finding, scannerResult } from './lib/output.mjs';
|
||||||
import { SEVERITY } from './lib/severity.mjs';
|
import { SEVERITY } from './lib/severity.mjs';
|
||||||
import { findImports, parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
|
import { findImports, parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||||
import { estimateTokens, readActiveConfig } from './lib/active-config-reader.mjs';
|
import { estimateTokens, effectiveMemoryBytes, readActiveConfig, deriveLoadPattern } from './lib/active-config-reader.mjs';
|
||||||
|
import {
|
||||||
|
assessMcpDeferralForRepo,
|
||||||
|
severityForForcedSchemas,
|
||||||
|
DEFERRAL_DISCLOSURE,
|
||||||
|
} from './lib/mcp-deferral.mjs';
|
||||||
|
|
||||||
const SCANNER = 'TOK';
|
const SCANNER = 'TOK';
|
||||||
|
|
||||||
|
// v5.6 B2 — map a discovery `type` to a deriveLoadPattern kind so each ranked
|
||||||
|
// hotspot can be annotated with its load pattern. `claude-md` and `rule` are
|
||||||
|
// handled specially (by scope / scoped); the synthetic 'mcp-server' → 'mcp'.
|
||||||
|
// Harness-config files (settings/manifests) configure the CLI, not the model
|
||||||
|
// context, so they cost no per-turn context tokens (deriveLoadPattern maps
|
||||||
|
// 'harness-config' → external).
|
||||||
|
const HOTSPOT_TYPE_TO_KIND = {
|
||||||
|
'skill-md': 'skill-body',
|
||||||
|
'agent-md': 'agent',
|
||||||
|
'command-md': 'command',
|
||||||
|
'mcp-server': 'mcp',
|
||||||
|
'settings-json': 'harness-config',
|
||||||
|
'mcp-json': 'harness-config',
|
||||||
|
'hooks-json': 'harness-config',
|
||||||
|
'plugin-json': 'harness-config',
|
||||||
|
'claude-json': 'harness-config',
|
||||||
|
'keybindings-json': 'harness-config',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLAUDE_MD_SCOPE_KIND = {
|
||||||
|
project: 'claude-md-root',
|
||||||
|
local: 'claude-md-root',
|
||||||
|
user: 'claude-md-user',
|
||||||
|
managed: 'claude-md-managed',
|
||||||
|
import: 'claude-md-import',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the load-pattern triple for a ranked hotspot. Rules reuse the
|
||||||
|
* active-config enumeration (which parsed `paths:` to set `scoped`) for precise
|
||||||
|
* scoped/unscoped handling; everything else maps by type.
|
||||||
|
*
|
||||||
|
* @param {{type:string, scope?:string, absPath?:string}} h
|
||||||
|
* @param {Map<string, {loadPattern:string, survivesCompaction:string, derivationConfidence:string}>} ruleMap
|
||||||
|
*/
|
||||||
|
function hotspotLoadPattern(h, ruleMap) {
|
||||||
|
if (h.type === 'claude-md') {
|
||||||
|
return deriveLoadPattern(CLAUDE_MD_SCOPE_KIND[h.scope] || 'claude-md-root');
|
||||||
|
}
|
||||||
|
if (h.type === 'rule') {
|
||||||
|
const r = ruleMap.get(h.absPath);
|
||||||
|
if (r) {
|
||||||
|
return {
|
||||||
|
loadPattern: r.loadPattern,
|
||||||
|
survivesCompaction: r.survivesCompaction,
|
||||||
|
derivationConfidence: r.derivationConfidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return deriveLoadPattern('rule', { scoped: false });
|
||||||
|
}
|
||||||
|
// Unmapped types fall through to deriveLoadPattern's safe 'unknown' default.
|
||||||
|
return deriveLoadPattern(HOTSPOT_TYPE_TO_KIND[h.type] || h.type);
|
||||||
|
}
|
||||||
|
|
||||||
const VOLATILE_TOP_LINES = 30;
|
const VOLATILE_TOP_LINES = 30;
|
||||||
const VOLATILE_PATTERNS = [
|
const VOLATILE_PATTERNS = [
|
||||||
/\{timestamp\}/i,
|
/\{timestamp\}/i,
|
||||||
|
|
@ -202,6 +261,26 @@ function detectRedundantPermissions(settings) {
|
||||||
return issues;
|
return issues;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Byte count to feed the token estimator for a discovered file. CLAUDE.md /
|
||||||
|
* memory files are sized from their *effective* (injected) content — CC strips
|
||||||
|
* block-level HTML comments before injection — so a raw byte read over-counts
|
||||||
|
* them. Every other source uses the raw on-disk size. (M-BUG-6)
|
||||||
|
*
|
||||||
|
* @param {{type:string, absPath?:string, size:number}} f
|
||||||
|
* @returns {Promise<number>}
|
||||||
|
*/
|
||||||
|
async function tokenBytesFor(f) {
|
||||||
|
if (f.type === 'claude-md' && f.absPath) {
|
||||||
|
try {
|
||||||
|
return effectiveMemoryBytes(await readFile(f.absPath, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return f.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f.size;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the ranked hotspots array.
|
* Build the ranked hotspots array.
|
||||||
*
|
*
|
||||||
|
|
@ -213,7 +292,7 @@ async function buildHotspots(discovery, targetPath, activeConfig) {
|
||||||
const ranked = [];
|
const ranked = [];
|
||||||
for (const f of discovery.files) {
|
for (const f of discovery.files) {
|
||||||
const kind = tokenKind(f.type);
|
const kind = tokenKind(f.type);
|
||||||
const tokens = estimateTokens(f.size, kind);
|
const tokens = estimateTokens(await tokenBytesFor(f), kind);
|
||||||
if (tokens <= 0) continue;
|
if (tokens <= 0) continue;
|
||||||
ranked.push({
|
ranked.push({
|
||||||
absPath: f.absPath,
|
absPath: f.absPath,
|
||||||
|
|
@ -241,14 +320,25 @@ async function buildHotspots(discovery, targetPath, activeConfig) {
|
||||||
}
|
}
|
||||||
ranked.sort((a, b) => b.estimated_tokens - a.estimated_tokens);
|
ranked.sort((a, b) => b.estimated_tokens - a.estimated_tokens);
|
||||||
|
|
||||||
|
// v5.6 B2 — precise rule load-pattern lookup: the active-config enumeration
|
||||||
|
// already parsed `paths:` and derived the pattern, keyed by absolute path.
|
||||||
|
const ruleMap = new Map();
|
||||||
|
if (activeConfig && Array.isArray(activeConfig.rules)) {
|
||||||
|
for (const r of activeConfig.rules) ruleMap.set(r.path, r);
|
||||||
|
}
|
||||||
|
|
||||||
const top = ranked.slice(0, HOTSPOTS_MAX);
|
const top = ranked.slice(0, HOTSPOTS_MAX);
|
||||||
const out = [];
|
const out = [];
|
||||||
for (let i = 0; i < top.length; i++) {
|
for (let i = 0; i < top.length; i++) {
|
||||||
const h = top[i];
|
const h = top[i];
|
||||||
|
const lp = hotspotLoadPattern(h, ruleMap);
|
||||||
const entry = {
|
const entry = {
|
||||||
source: h.relPath || h.absPath,
|
source: h.relPath || h.absPath,
|
||||||
estimated_tokens: h.estimated_tokens,
|
estimated_tokens: h.estimated_tokens,
|
||||||
rank: i + 1,
|
rank: i + 1,
|
||||||
|
loadPattern: lp.loadPattern,
|
||||||
|
survivesCompaction: lp.survivesCompaction,
|
||||||
|
derivationConfidence: lp.derivationConfidence,
|
||||||
recommendations: hotspotRecommendations(h),
|
recommendations: hotspotRecommendations(h),
|
||||||
};
|
};
|
||||||
// Expose the on-disk path for file-backed hotspots so the
|
// Expose the on-disk path for file-backed hotspots so the
|
||||||
|
|
@ -479,13 +569,109 @@ export async function scan(targetPath, discovery) {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pattern H: stale plugin-cache versions (v5.9 B3 — disk hygiene) ──
|
||||||
|
// file-discovery populates discovery.staleCacheVersions when walking
|
||||||
|
// ~/.claude/plugins/cache: version dirs on disk NOT referenced by
|
||||||
|
// installed_plugins.json (superseded installs). They occupy disk but load on
|
||||||
|
// ZERO turns — already excluded from the hotspot ranking + CNF by
|
||||||
|
// --exclude-cache. Honest framing (category 'plugin-cache-hygiene' →
|
||||||
|
// "Dead config", not "Wasted tokens"): a disk-cleanup opportunity, NOT a
|
||||||
|
// per-turn token cost.
|
||||||
|
const stale = Array.isArray(discovery.staleCacheVersions) ? discovery.staleCacheVersions : [];
|
||||||
|
if (stale.length > 0) {
|
||||||
|
const totalFiles = stale.reduce((s, v) => s + (v.fileCount || 0), 0);
|
||||||
|
const totalBytes = stale.reduce((s, v) => s + (v.estimatedBytes || 0), 0);
|
||||||
|
const keys = stale.map(v => v.key);
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.low,
|
||||||
|
title: 'Stale plugin-cache versions (disk cleanup, zero live-context impact)',
|
||||||
|
description:
|
||||||
|
`${stale.length} plugin version${stale.length === 1 ? '' : 's'} under ` +
|
||||||
|
`~/.claude/plugins/cache ${stale.length === 1 ? 'is' : 'are'} not referenced by ` +
|
||||||
|
'installed_plugins.json — superseded by newer installs. They occupy disk but load on ' +
|
||||||
|
'ZERO turns: excluded from the hotspot ranking and conflict detection by default ' +
|
||||||
|
'(--exclude-cache). This is disk hygiene, not a per-turn token cost.',
|
||||||
|
file: null,
|
||||||
|
evidence:
|
||||||
|
`stale_versions=${keys.join(', ')}; config_files=${totalFiles}; ` +
|
||||||
|
`approx_disk_bytes=${totalBytes}; note=zero live-context impact ` +
|
||||||
|
'(installed_plugins.json points at newer versions)',
|
||||||
|
recommendation:
|
||||||
|
'Delete the listed stale version directories under ~/.claude/plugins/cache to reclaim ' +
|
||||||
|
'disk (reinstall/prune via the plugin manager, or remove the dirs directly). ' +
|
||||||
|
'Caution: do NOT delete a version a running session is still using — "stale" is judged ' +
|
||||||
|
'against installed_plugins.json (what NEW sessions load), but an already-running session ' +
|
||||||
|
'can hold an older version for its whole lifetime. Removing it mid-session pulls the files ' +
|
||||||
|
'out from under that session, which then breaks and must /exit + restart to pick up the ' +
|
||||||
|
'active version. Run with --no-exclude-cache to include cached versions in the ' +
|
||||||
|
'token/conflict scan.',
|
||||||
|
category: 'plugin-cache-hygiene',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pattern I: MCP tool-schema deferral (v5.10 B4, CA-TOK-006) ──
|
||||||
|
// By default MCP tool schemas are DEFERRED (names-only, ~120 tok); certain
|
||||||
|
// config signals force the FULL schemas into the always-loaded prefix every
|
||||||
|
// turn. Scope: project-local .mcp.json servers (mirrors Pattern G — plugin /
|
||||||
|
// global servers are the manifest's concern). Static config-file check only;
|
||||||
|
// runtime conditions (Vertex / ANTHROPIC_BASE_URL / runtime /model switch) are
|
||||||
|
// DISCLOSED (DEFERRAL_DISCLOSURE), not triggered, so the finding is deterministic.
|
||||||
|
const mcpForDeferral = (activeConfig && Array.isArray(activeConfig.mcpServers))
|
||||||
|
? activeConfig.mcpServers.filter(m => m && m.enabled && m.source === '.mcp.json')
|
||||||
|
: [];
|
||||||
|
if (mcpForDeferral.length > 0) {
|
||||||
|
const a = await assessMcpDeferralForRepo(targetPath, { mcpServers: activeConfig.mcpServers });
|
||||||
|
if (a.forcedUpfront) {
|
||||||
|
const conf = a.confidence || 'high';
|
||||||
|
const severity = severityForForcedSchemas(a.aggregateTokens, conf);
|
||||||
|
const names = a.affectedServers.map(m => m.name).join(', ');
|
||||||
|
const reasonText = {
|
||||||
|
'enable-tool-search-false': 'ENABLE_TOOL_SEARCH is set to "false" in settings',
|
||||||
|
'deny-tool-search': '"ToolSearch" is listed in permissions.deny',
|
||||||
|
'haiku-model': 'the configured model is a Haiku model (Haiku has no tool-search support)',
|
||||||
|
}[a.reason];
|
||||||
|
const description = a.toolSearchDisabled
|
||||||
|
? `Tool search is disabled (${reasonText}), so the full tool schemas of ` +
|
||||||
|
`${a.affectedServers.length} active project MCP server${a.affectedServers.length === 1 ? '' : 's'} ` +
|
||||||
|
`(~${a.aggregateTokens} tokens) load into the always-loaded prefix on every turn instead of ` +
|
||||||
|
'being deferred (tool names only, ~120 tokens total). Every schema token is re-sent each turn ' +
|
||||||
|
'whether or not a tool is used.'
|
||||||
|
: `${a.alwaysLoadServers.length} project MCP server${a.alwaysLoadServers.length === 1 ? '' : 's'} ` +
|
||||||
|
`marked alwaysLoad (${names}) load their full tool schemas (~${a.aggregateTokens} tokens) into ` +
|
||||||
|
'the always-loaded prefix on every turn regardless of tool search, instead of deferring them ' +
|
||||||
|
'(names only, ~120 tokens).';
|
||||||
|
const evidence =
|
||||||
|
`reason=${a.reason || 'alwaysLoad'}; confidence=${conf}; servers=${names}; ` +
|
||||||
|
`forced_schema_tokens~${a.aggregateTokens} — ${CALIBRATION_NOTE}. ${DEFERRAL_DISCLOSURE}`;
|
||||||
|
const recommendation = a.toolSearchDisabled
|
||||||
|
? 'Re-enable tool search so MCP schemas defer (names-only) by default: remove ' +
|
||||||
|
'ENABLE_TOOL_SEARCH="false" / the "ToolSearch" deny, or stop defaulting to a Haiku model. ' +
|
||||||
|
'Also disable unused servers via /mcp, and prefer CLI tools (gh / aws / gcloud) over MCP for ' +
|
||||||
|
'common operations — CLI adds zero context tokens until used.'
|
||||||
|
: 'Drop alwaysLoad on large-schema servers so they defer (names-only) until a tool is needed; ' +
|
||||||
|
'keep alwaysLoad only for small servers you call on most turns. Prefer CLI tools ' +
|
||||||
|
'(gh / aws / gcloud) over MCP for common operations.';
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity,
|
||||||
|
title: 'MCP tool schemas forced into the always-loaded prefix',
|
||||||
|
file: null,
|
||||||
|
evidence,
|
||||||
|
description,
|
||||||
|
recommendation,
|
||||||
|
category: 'token-efficiency',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Hotspots ranking ──
|
// ── Hotspots ranking ──
|
||||||
const hotspots = await buildHotspots(discovery, targetPath, activeConfig);
|
const hotspots = await buildHotspots(discovery, targetPath, activeConfig);
|
||||||
|
|
||||||
// ── Total estimated tokens (sum of every discovered source + activeConfig MCP) ──
|
// ── Total estimated tokens (sum of every discovered source + activeConfig MCP) ──
|
||||||
let totalTokens = 0;
|
let totalTokens = 0;
|
||||||
for (const f of discovery.files) {
|
for (const f of discovery.files) {
|
||||||
totalTokens += estimateTokens(f.size, tokenKind(f.type));
|
totalTokens += estimateTokens(await tokenBytesFor(f), tokenKind(f.type));
|
||||||
}
|
}
|
||||||
if (activeConfig && Array.isArray(activeConfig.mcpServers)) {
|
if (activeConfig && Array.isArray(activeConfig.mcpServers)) {
|
||||||
for (const m of activeConfig.mcpServers) {
|
for (const m of activeConfig.mcpServers) {
|
||||||
|
|
|
||||||
62
tests/commands/analyze-report-persistence.test.mjs
Normal file
62
tests/commands/analyze-report-persistence.test.mjs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
/**
|
||||||
|
* M-BUG-18 — analysis-report.md persistence contract.
|
||||||
|
*
|
||||||
|
* The Claude Code subagent harness instructs spawned agents NOT to write
|
||||||
|
* report/summary/findings/analysis .md files — the parent reads the agent's
|
||||||
|
* final text message, not files it creates. The analyzer-agent therefore
|
||||||
|
* cannot be the one that persists analysis-report.md (verified live: the
|
||||||
|
* agent skipped Write and returned the report inline).
|
||||||
|
*
|
||||||
|
* New contract (orchestrator-writes pattern):
|
||||||
|
* - analyzer-agent returns the complete report as its final message
|
||||||
|
* - the analyze command saves that returned report verbatim to
|
||||||
|
* ~/.claude/config-audit/sessions/{session-id}/analysis-report.md,
|
||||||
|
* which downstream phases (plan, interview, status) read.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const COMMANDS_DIR = resolve(__dirname, '..', '..', 'commands');
|
||||||
|
const AGENTS_DIR = resolve(__dirname, '..', '..', 'agents');
|
||||||
|
|
||||||
|
test('analyze.md: agent prompt does not tell the agent to write the report file', async () => {
|
||||||
|
const content = await readFile(resolve(COMMANDS_DIR, 'analyze.md'), 'utf-8');
|
||||||
|
assert.doesNotMatch(
|
||||||
|
content,
|
||||||
|
/Output to:.*analysis-report\.md/,
|
||||||
|
'the spawn prompt must not instruct the subagent to write analysis-report.md — the harness blocks agent-written report files'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analyze.md: command saves the returned report to analysis-report.md', async () => {
|
||||||
|
const content = await readFile(resolve(COMMANDS_DIR, 'analyze.md'), 'utf-8');
|
||||||
|
assert.match(
|
||||||
|
content,
|
||||||
|
/return[s]? the complete report as (its|your) final message/i,
|
||||||
|
'analyze.md must state that the agent returns the report inline'
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
content,
|
||||||
|
/Write tool[\s\S]{0,200}analysis-report\.md|analysis-report\.md[\s\S]{0,200}Write tool/,
|
||||||
|
'analyze.md must instruct the command to persist the returned report to analysis-report.md with the Write tool'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analyzer-agent.md: output contract is return-inline, not self-write', async () => {
|
||||||
|
const content = await readFile(resolve(AGENTS_DIR, 'analyzer-agent.md'), 'utf-8');
|
||||||
|
assert.match(
|
||||||
|
content,
|
||||||
|
/return the complete report as your final message/i,
|
||||||
|
'analyzer-agent must be told its final message IS the report'
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
content,
|
||||||
|
/^Write to: .*analysis-report\.md/m,
|
||||||
|
'analyzer-agent must not carry the old self-write output contract'
|
||||||
|
);
|
||||||
|
});
|
||||||
41
tests/commands/implement-log-append.test.mjs
Normal file
41
tests/commands/implement-log-append.test.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
/**
|
||||||
|
* M-BUG-20 — shared implementation-log clobbering under parallel agents.
|
||||||
|
*
|
||||||
|
* implement.md step 4 spawns implementer agents in parallel batches, and every
|
||||||
|
* agent appends its result to the SAME implementation-log.md. Dogfooding
|
||||||
|
* (2026-07-17, throwaway linkedin-posts copy) showed agents satisfying
|
||||||
|
* "Append result to:" with a full-file Write: each agent read the log, added
|
||||||
|
* its entry, and wrote the whole file back — the last writer silently
|
||||||
|
* clobbered 4 of 6 entries.
|
||||||
|
*
|
||||||
|
* Contract: both the command template and the agent prompt must pin the append
|
||||||
|
* mechanism — Bash `>>`, never the Write/Edit tool — on the shared log.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = resolve(__dirname, '..', '..');
|
||||||
|
|
||||||
|
const APPEND_MECHANISM_REGEX = />>/;
|
||||||
|
const FORBID_WRITE_TOOL_REGEX = /never[^.\n]*\bwrite\b[^.\n]*tool|\bwrite\b[^.\n]*tool[^.\n]*never/i;
|
||||||
|
|
||||||
|
test('implement.md: agent-spawn template pins Bash >> append on the shared log', async () => {
|
||||||
|
const content = await readFile(resolve(ROOT, 'commands', 'implement.md'), 'utf-8');
|
||||||
|
assert.match(content, APPEND_MECHANISM_REGEX,
|
||||||
|
'implement.md must instruct appending to implementation-log.md with Bash >>');
|
||||||
|
assert.match(content, FORBID_WRITE_TOOL_REGEX,
|
||||||
|
'implement.md must forbid the Write tool on the shared implementation log');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('implementer-agent.md: output section pins Bash >> append and forbids Write tool on the log', async () => {
|
||||||
|
const content = await readFile(resolve(ROOT, 'agents', 'implementer-agent.md'), 'utf-8');
|
||||||
|
assert.match(content, APPEND_MECHANISM_REGEX,
|
||||||
|
'implementer-agent.md must instruct appending to the log with Bash >>');
|
||||||
|
assert.match(content, FORBID_WRITE_TOOL_REGEX,
|
||||||
|
'implementer-agent.md must forbid the Write tool on the shared implementation log');
|
||||||
|
});
|
||||||
4
tests/fixtures/automode-nonobject/.claude/settings.local.json
vendored
Normal file
4
tests/fixtures/automode-nonobject/.claude/settings.local.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||||
|
"autoMode": "on"
|
||||||
|
}
|
||||||
7
tests/fixtures/automode-shared/.claude/settings.json
vendored
Normal file
7
tests/fixtures/automode-shared/.claude/settings.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||||
|
"autoMode": {
|
||||||
|
"environment": ["$defaults", "Org: acme"],
|
||||||
|
"hard_deny": ["$defaults"]
|
||||||
|
}
|
||||||
|
}
|
||||||
9
tests/fixtures/automode-structure/.claude/settings.local.json
vendored
Normal file
9
tests/fixtures/automode-structure/.claude/settings.local.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||||
|
"autoMode": {
|
||||||
|
"environment": ["$defaults"],
|
||||||
|
"allow": "oops-not-an-array",
|
||||||
|
"soft_deny": ["ok", 7],
|
||||||
|
"bogus": ["x"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
name: bad-agent
|
name: bad-agent
|
||||||
description: Missing model and tools
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Bad Agent
|
# Bad Agent
|
||||||
|
|
||||||
No model or tools in frontmatter.
|
Has a `name` but no `description`. Per CC sub-agents docs, `name` and `description` are
|
||||||
|
required for subagents; `model` and `tools` are optional (inherit / all-tools by default).
|
||||||
|
|
|
||||||
46
tests/fixtures/cps-fenced/inside-fence/CLAUDE.md
vendored
Normal file
46
tests/fixtures/cps-fenced/inside-fence/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Fenced Documentation Project
|
||||||
|
|
||||||
|
Stable preamble. All volatility below is illustrative, inside a code fence.
|
||||||
|
Body line 4.
|
||||||
|
Body line 5.
|
||||||
|
Body line 6.
|
||||||
|
Body line 7.
|
||||||
|
Body line 8.
|
||||||
|
Body line 9.
|
||||||
|
Body line 10.
|
||||||
|
Body line 11.
|
||||||
|
Body line 12.
|
||||||
|
Body line 13.
|
||||||
|
Body line 14.
|
||||||
|
Body line 15.
|
||||||
|
Body line 16.
|
||||||
|
Body line 17.
|
||||||
|
Body line 18.
|
||||||
|
Body line 19.
|
||||||
|
Body line 20.
|
||||||
|
Body line 21.
|
||||||
|
Body line 22.
|
||||||
|
Body line 23.
|
||||||
|
Body line 24.
|
||||||
|
Body line 25.
|
||||||
|
Body line 26.
|
||||||
|
Body line 27.
|
||||||
|
Body line 28.
|
||||||
|
Body line 29.
|
||||||
|
Body line 30.
|
||||||
|
Body line 31.
|
||||||
|
Body line 32.
|
||||||
|
Body line 33.
|
||||||
|
Body line 34.
|
||||||
|
```bash
|
||||||
|
export STAMP=${TIMESTAMP}
|
||||||
|
!deploy.sh --at 2026-06-26T10:00:00
|
||||||
|
echo {date} > /tmp/log
|
||||||
|
[2026-06-26 12:00] starting
|
||||||
|
```
|
||||||
|
Body line 41.
|
||||||
|
Body line 42.
|
||||||
|
Body line 43.
|
||||||
|
Body line 44.
|
||||||
|
Body line 45.
|
||||||
|
Body line 46.
|
||||||
55
tests/fixtures/cps-fenced/mixed/CLAUDE.md
vendored
Normal file
55
tests/fixtures/cps-fenced/mixed/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Fenced Plus Prose Project
|
||||||
|
|
||||||
|
Stable preamble. A fence below, plus one genuine volatile prose line.
|
||||||
|
Body line 4.
|
||||||
|
Body line 5.
|
||||||
|
Body line 6.
|
||||||
|
Body line 7.
|
||||||
|
Body line 8.
|
||||||
|
Body line 9.
|
||||||
|
Body line 10.
|
||||||
|
Body line 11.
|
||||||
|
Body line 12.
|
||||||
|
Body line 13.
|
||||||
|
Body line 14.
|
||||||
|
Body line 15.
|
||||||
|
Body line 16.
|
||||||
|
Body line 17.
|
||||||
|
Body line 18.
|
||||||
|
Body line 19.
|
||||||
|
Body line 20.
|
||||||
|
Body line 21.
|
||||||
|
Body line 22.
|
||||||
|
Body line 23.
|
||||||
|
Body line 24.
|
||||||
|
Body line 25.
|
||||||
|
Body line 26.
|
||||||
|
Body line 27.
|
||||||
|
Body line 28.
|
||||||
|
Body line 29.
|
||||||
|
Body line 30.
|
||||||
|
Body line 31.
|
||||||
|
Body line 32.
|
||||||
|
Body line 33.
|
||||||
|
Body line 34.
|
||||||
|
```bash
|
||||||
|
export STAMP=${TIMESTAMP} # documented, not live
|
||||||
|
```
|
||||||
|
Body line 38.
|
||||||
|
Body line 39.
|
||||||
|
Body line 40.
|
||||||
|
Body line 41.
|
||||||
|
Body line 42.
|
||||||
|
Body line 43.
|
||||||
|
Body line 44.
|
||||||
|
Body line 45.
|
||||||
|
Body line 46.
|
||||||
|
Body line 47.
|
||||||
|
Body line 48.
|
||||||
|
Body line 49.
|
||||||
|
!git log -1 # genuine shell-exec in live prose at line 50
|
||||||
|
Body line 51.
|
||||||
|
Body line 52.
|
||||||
|
Body line 53.
|
||||||
|
Body line 54.
|
||||||
|
Body line 55.
|
||||||
55
tests/fixtures/cps-inline-code/mixed/CLAUDE.md
vendored
Normal file
55
tests/fixtures/cps-inline-code/mixed/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Inline Code Mixed Project
|
||||||
|
|
||||||
|
Stable preamble. Backticked template plus a live var in prose.
|
||||||
|
Body line 4.
|
||||||
|
Body line 5.
|
||||||
|
Body line 6.
|
||||||
|
Body line 7.
|
||||||
|
Body line 8.
|
||||||
|
Body line 9.
|
||||||
|
Body line 10.
|
||||||
|
Body line 11.
|
||||||
|
Body line 12.
|
||||||
|
Body line 13.
|
||||||
|
Body line 14.
|
||||||
|
Body line 15.
|
||||||
|
Body line 16.
|
||||||
|
Body line 17.
|
||||||
|
Body line 18.
|
||||||
|
Body line 19.
|
||||||
|
Body line 20.
|
||||||
|
Body line 21.
|
||||||
|
Body line 22.
|
||||||
|
Body line 23.
|
||||||
|
Body line 24.
|
||||||
|
Body line 25.
|
||||||
|
Body line 26.
|
||||||
|
Body line 27.
|
||||||
|
Body line 28.
|
||||||
|
Body line 29.
|
||||||
|
Body line 30.
|
||||||
|
Body line 31.
|
||||||
|
Body line 32.
|
||||||
|
Body line 33.
|
||||||
|
Body line 34.
|
||||||
|
Body line 35.
|
||||||
|
Body line 36.
|
||||||
|
Body line 37.
|
||||||
|
Body line 38.
|
||||||
|
Body line 39.
|
||||||
|
Output template is `run-{date}.md` (documented).
|
||||||
|
Body line 41.
|
||||||
|
Body line 42.
|
||||||
|
Body line 43.
|
||||||
|
Body line 44.
|
||||||
|
Body line 45.
|
||||||
|
Body line 46.
|
||||||
|
Body line 47.
|
||||||
|
Body line 48.
|
||||||
|
Body line 49.
|
||||||
|
Deployed at ${RELEASE_STAMP} on every push.
|
||||||
|
Body line 51.
|
||||||
|
Body line 52.
|
||||||
|
Body line 53.
|
||||||
|
Body line 54.
|
||||||
|
Body line 55.
|
||||||
46
tests/fixtures/cps-inline-code/pure/CLAUDE.md
vendored
Normal file
46
tests/fixtures/cps-inline-code/pure/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Inline Code Doc Project
|
||||||
|
|
||||||
|
Stable preamble. A filename template is documented in backticks below.
|
||||||
|
Body line 4.
|
||||||
|
Body line 5.
|
||||||
|
Body line 6.
|
||||||
|
Body line 7.
|
||||||
|
Body line 8.
|
||||||
|
Body line 9.
|
||||||
|
Body line 10.
|
||||||
|
Body line 11.
|
||||||
|
Body line 12.
|
||||||
|
Body line 13.
|
||||||
|
Body line 14.
|
||||||
|
Body line 15.
|
||||||
|
Body line 16.
|
||||||
|
Body line 17.
|
||||||
|
Body line 18.
|
||||||
|
Body line 19.
|
||||||
|
Body line 20.
|
||||||
|
Body line 21.
|
||||||
|
Body line 22.
|
||||||
|
Body line 23.
|
||||||
|
Body line 24.
|
||||||
|
Body line 25.
|
||||||
|
Body line 26.
|
||||||
|
Body line 27.
|
||||||
|
Body line 28.
|
||||||
|
Body line 29.
|
||||||
|
Body line 30.
|
||||||
|
Body line 31.
|
||||||
|
Body line 32.
|
||||||
|
Body line 33.
|
||||||
|
Body line 34.
|
||||||
|
Body line 35.
|
||||||
|
Body line 36.
|
||||||
|
Body line 37.
|
||||||
|
Body line 38.
|
||||||
|
Body line 39.
|
||||||
|
| `--brief <path>` | writes to `.claude/plans/run-{date}-{slug}.md` |
|
||||||
|
Body line 41.
|
||||||
|
Body line 42.
|
||||||
|
Body line 43.
|
||||||
|
Body line 44.
|
||||||
|
Body line 45.
|
||||||
|
Body line 46.
|
||||||
46
tests/fixtures/cps-stable-vars/non-whitelisted/CLAUDE.md
vendored
Normal file
46
tests/fixtures/cps-stable-vars/non-whitelisted/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Non-Whitelisted Var Project
|
||||||
|
|
||||||
|
Stable preamble with a genuine runtime substitution below.
|
||||||
|
Body line 4.
|
||||||
|
Body line 5.
|
||||||
|
Body line 6.
|
||||||
|
Body line 7.
|
||||||
|
Body line 8.
|
||||||
|
Body line 9.
|
||||||
|
Body line 10.
|
||||||
|
Body line 11.
|
||||||
|
Body line 12.
|
||||||
|
Body line 13.
|
||||||
|
Body line 14.
|
||||||
|
Body line 15.
|
||||||
|
Body line 16.
|
||||||
|
Body line 17.
|
||||||
|
Body line 18.
|
||||||
|
Body line 19.
|
||||||
|
Body line 20.
|
||||||
|
Body line 21.
|
||||||
|
Body line 22.
|
||||||
|
Body line 23.
|
||||||
|
Body line 24.
|
||||||
|
Body line 25.
|
||||||
|
Body line 26.
|
||||||
|
Body line 27.
|
||||||
|
Body line 28.
|
||||||
|
Body line 29.
|
||||||
|
Body line 30.
|
||||||
|
Body line 31.
|
||||||
|
Body line 32.
|
||||||
|
Body line 33.
|
||||||
|
Body line 34.
|
||||||
|
Body line 35.
|
||||||
|
Body line 36.
|
||||||
|
Body line 37.
|
||||||
|
Body line 38.
|
||||||
|
Body line 39.
|
||||||
|
Deployed build tag: ${DEPLOY_TIMESTAMP} (changes every release).
|
||||||
|
Body line 41.
|
||||||
|
Body line 42.
|
||||||
|
Body line 43.
|
||||||
|
Body line 44.
|
||||||
|
Body line 45.
|
||||||
|
Body line 46.
|
||||||
46
tests/fixtures/cps-stable-vars/whitelisted/CLAUDE.md
vendored
Normal file
46
tests/fixtures/cps-stable-vars/whitelisted/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Stable CC Vars Project
|
||||||
|
|
||||||
|
Stable preamble — only CC-provided path vars appear below.
|
||||||
|
Body line 4.
|
||||||
|
Body line 5.
|
||||||
|
Body line 6.
|
||||||
|
Body line 7.
|
||||||
|
Body line 8.
|
||||||
|
Body line 9.
|
||||||
|
Body line 10.
|
||||||
|
Body line 11.
|
||||||
|
Body line 12.
|
||||||
|
Body line 13.
|
||||||
|
Body line 14.
|
||||||
|
Body line 15.
|
||||||
|
Body line 16.
|
||||||
|
Body line 17.
|
||||||
|
Body line 18.
|
||||||
|
Body line 19.
|
||||||
|
Body line 20.
|
||||||
|
Body line 21.
|
||||||
|
Body line 22.
|
||||||
|
Body line 23.
|
||||||
|
Body line 24.
|
||||||
|
Body line 25.
|
||||||
|
Body line 26.
|
||||||
|
Body line 27.
|
||||||
|
Body line 28.
|
||||||
|
Body line 29.
|
||||||
|
Body line 30.
|
||||||
|
Body line 31.
|
||||||
|
Body line 32.
|
||||||
|
Body line 33.
|
||||||
|
Body line 34.
|
||||||
|
Body line 35.
|
||||||
|
Body line 36.
|
||||||
|
Body line 37.
|
||||||
|
Body line 38.
|
||||||
|
Body line 39.
|
||||||
|
Hooks resolve under ${CLAUDE_PLUGIN_ROOT}/hooks and project root is ${CLAUDE_PROJECT_DIR}.
|
||||||
|
Body line 41.
|
||||||
|
Body line 42.
|
||||||
|
Body line 43.
|
||||||
|
Body line 44.
|
||||||
|
Body line 45.
|
||||||
|
Body line 46.
|
||||||
6
tests/fixtures/deny-all-glob/.claude/settings.json
vendored
Normal file
6
tests/fixtures/deny-all-glob/.claude/settings.json
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": ["Bash"],
|
||||||
|
"deny": ["Bash(*)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
tests/fixtures/duplicate-command-name/one/.claude-plugin/plugin.json
vendored
Normal file
1
tests/fixtures/duplicate-command-name/one/.claude-plugin/plugin.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{ "name": "plugin-one", "description": "Declares namespace plugin-one", "version": "1.0.0" }
|
||||||
12
tests/fixtures/duplicate-command-name/one/CLAUDE.md
vendored
Normal file
12
tests/fixtures/duplicate-command-name/one/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Plugin
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/shared-cmd` | shared |
|
||||||
|
|
||||||
|
## Agents
|
||||||
|
(none)
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
(none)
|
||||||
7
tests/fixtures/duplicate-command-name/one/commands/shared.md
vendored
Normal file
7
tests/fixtures/duplicate-command-name/one/commands/shared.md
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
name: shared-cmd
|
||||||
|
description: A command whose name is shared across two differently-named plugins
|
||||||
|
model: sonnet
|
||||||
|
allowed-tools: Read
|
||||||
|
---
|
||||||
|
Do the thing.
|
||||||
1
tests/fixtures/duplicate-command-name/two/.claude-plugin/plugin.json
vendored
Normal file
1
tests/fixtures/duplicate-command-name/two/.claude-plugin/plugin.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{ "name": "plugin-two", "description": "Declares namespace plugin-two", "version": "1.0.0" }
|
||||||
12
tests/fixtures/duplicate-command-name/two/CLAUDE.md
vendored
Normal file
12
tests/fixtures/duplicate-command-name/two/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Plugin
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/shared-cmd` | shared |
|
||||||
|
|
||||||
|
## Agents
|
||||||
|
(none)
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
(none)
|
||||||
7
tests/fixtures/duplicate-command-name/two/commands/shared.md
vendored
Normal file
7
tests/fixtures/duplicate-command-name/two/commands/shared.md
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
name: shared-cmd
|
||||||
|
description: A command whose name is shared across two differently-named plugins
|
||||||
|
model: sonnet
|
||||||
|
allowed-tools: Read
|
||||||
|
---
|
||||||
|
Do the thing.
|
||||||
5
tests/fixtures/duplicate-plugin-name/alpha/.claude-plugin/plugin.json
vendored
Normal file
5
tests/fixtures/duplicate-plugin-name/alpha/.claude-plugin/plugin.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"name": "dup",
|
||||||
|
"description": "Alpha plugin that declares the namespace 'dup'",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
12
tests/fixtures/duplicate-plugin-name/alpha/CLAUDE.md
vendored
Normal file
12
tests/fixtures/duplicate-plugin-name/alpha/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Dup Plugin
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/dup:hello` | say hello |
|
||||||
|
|
||||||
|
## Agents
|
||||||
|
(none)
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
(none)
|
||||||
7
tests/fixtures/duplicate-plugin-name/alpha/commands/hello.md
vendored
Normal file
7
tests/fixtures/duplicate-plugin-name/alpha/commands/hello.md
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
name: hello
|
||||||
|
description: Shared command name within a colliding namespace — covered by the namespace-collision finding
|
||||||
|
model: sonnet
|
||||||
|
allowed-tools: Read
|
||||||
|
---
|
||||||
|
Say hello.
|
||||||
5
tests/fixtures/duplicate-plugin-name/beta/.claude-plugin/plugin.json
vendored
Normal file
5
tests/fixtures/duplicate-plugin-name/beta/.claude-plugin/plugin.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"name": "dup",
|
||||||
|
"description": "Beta plugin that declares the SAME namespace 'dup' — collides with alpha",
|
||||||
|
"version": "2.0.0"
|
||||||
|
}
|
||||||
12
tests/fixtures/duplicate-plugin-name/beta/CLAUDE.md
vendored
Normal file
12
tests/fixtures/duplicate-plugin-name/beta/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Dup Plugin
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/dup:hello` | say hello |
|
||||||
|
|
||||||
|
## Agents
|
||||||
|
(none)
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
(none)
|
||||||
7
tests/fixtures/duplicate-plugin-name/beta/commands/hello.md
vendored
Normal file
7
tests/fixtures/duplicate-plugin-name/beta/commands/hello.md
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
name: hello
|
||||||
|
description: Shared command name within a colliding namespace — covered by the namespace-collision finding
|
||||||
|
model: sonnet
|
||||||
|
allowed-tools: Read
|
||||||
|
---
|
||||||
|
Say hello.
|
||||||
4
tests/fixtures/duplicate-plugin-name/delta/.claude-plugin/plugin.json
vendored
Normal file
4
tests/fixtures/duplicate-plugin-name/delta/.claude-plugin/plugin.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"description": "Plugin with NO name field — must be excluded from the namespace-collision map",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
4
tests/fixtures/duplicate-plugin-name/gamma/.claude-plugin/plugin.json
vendored
Normal file
4
tests/fixtures/duplicate-plugin-name/gamma/.claude-plugin/plugin.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"description": "Plugin with NO name field — must be excluded from the namespace-collision map",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
17
tests/fixtures/forbidden-param-permissions/.claude/settings.json
vendored
Normal file
17
tests/fixtures/forbidden-param-permissions/.claude/settings.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Read(file_path:/etc/passwd)",
|
||||||
|
"Bash(npm:*)",
|
||||||
|
"WebFetch(domain:good.com)"
|
||||||
|
],
|
||||||
|
"deny": [
|
||||||
|
"Bash(command:rm *)",
|
||||||
|
"Grep(path:/secrets)",
|
||||||
|
"Agent(model:opus)"
|
||||||
|
],
|
||||||
|
"ask": [
|
||||||
|
"WebFetch(url:http://evil.com)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue