diff --git a/CLAUDE.md b/CLAUDE.md index 0dde5c5..612a87f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ Analyzes and optimizes Claude Code configuration across three pillars: |---------|-------------| | `/config-audit` | Full audit with auto-scope detection (no setup needed) | | `/config-audit posture` | Quick health scorecard (A-F grades, 10 quality areas incl. Token Efficiency, Plugin Hygiene) | -| `/config-audit tokens` | prompt-cache-aware token hotspots (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 (6 patterns: cache-breaking, redundant perms, deep imports, oversized cascade, bloated SKILL.md desc, MCP tool-schema budget), each ranked hotspot tagged with its load pattern (always / on-demand / external) — optional `--accurate-tokens` API calibration, `--with-telemetry-recipe` cache-hit recipe pointer | | `/config-audit manifest` | Ranked table of every token source (CLAUDE.md, rules, agents, skills, output styles, MCP, hooks) sorted by estimated tokens, each tagged with its load pattern (always-loaded / on-demand / external) + an always-loaded subtotal ("tokens that enter context every turn") | | `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact (incl. a conditional `disableBundledSkills` lever when the active skill listing is over budget — remediation companion to SKL `CA-SKL-002`) | | `/config-audit fix` | Auto-fix deterministic issues with backup + verification | @@ -109,7 +109,7 @@ Default: auto-detects scope from git context. Override with `/config-audit full| node --test 'tests/**/*.test.mjs' ``` -1008 tests across 56 test files (17 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`. +1012 tests across 56 test files (17 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`. ### active-config-reader — load-pattern model + rule/agent/output-style enumeration (v5.6 Foundation) @@ -154,8 +154,26 @@ list becomes an array. **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. -**TOK's load-pattern column is deferred to the next chunk** (it *is* a byte-equal SC-6 CLI, so it -needs snapshot handling); this chunk is manifest-only. + +### 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. ### CML scanner — context-window-scaled char budget diff --git a/README.md b/README.md index f031240..2b61a31 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ![Commands](https://img.shields.io/badge/commands-18-green) ![Agents](https://img.shields.io/badge/agents-6-orange) ![Hooks](https://img.shields.io/badge/hooks-4-red) -![Tests](https://img.shields.io/badge/tests-1008+-brightgreen) +![Tests](https://img.shields.io/badge/tests-1012+-brightgreen) ![License](https://img.shields.io/badge/license-MIT-lightgrey) A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 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. @@ -275,7 +275,7 @@ Your team configuration changes over time. Track it: |---------|-------------| | `/config-audit` | Full audit with auto-scope detection (no setup needed) | | `/config-audit posture` | Quick health scorecard: A-F grades across 10 quality areas (incl. Token Efficiency, Plugin Hygiene) | -| `/config-audit tokens` | prompt-cache-aware token hotspots — ranked by estimated waste; 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); 6 patterns + optional `--accurate-tokens` API calibration | | `/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 fix` | Auto-fix deterministic issues with backup + verification | @@ -588,7 +588,7 @@ Reference documents that inform the feature-gap agent and context-aware recommen node --test 'tests/**/*.test.mjs' ``` -1008 tests across 56 test files (17 lib + 29 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Requires Node.js 18+ (`node:test`). +1012 tests across 56 test files (17 lib + 29 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Requires Node.js 18+ (`node:test`). --- diff --git a/STATE.md b/STATE.md index 6cefe42..a1a7c4c 100644 --- a/STATE.md +++ b/STATE.md @@ -2,32 +2,32 @@ _Current state-of-play for DENNE pluginen. Auto-injisert ved sesjonsstart. Overskrives (ikke append) ved sesjonsslutt. Historikk → git (STATE.md er tracked). Stier relative til plugin-mappa._ -## Aktiv kontekst (v5.6 B1 = manifest load-pattern accounting LANDET på main — B2/TOK + C neste) -v5.5.0 released. v5.6 Foundation landet (`62d910e`). Nå: **v5.6 B delt i to** — **B1 (manifest) implementert + committet på main** (TDD). TOK-kolonnen ble bevisst utsatt (B2) fordi TOK er en byte-equal SC-6-CLI (krever snapshot-håndtering); manifest er env-aware → mode-equivalence → byte-stabil UTEN regen. Suite **1008** (996→1008), self-audit **A/A** (config A, plugin A), count **13** (ingen ny scanner). v5.6 IKKE release-cuttet (krever B2 + C + eget GO). +## Aktiv kontekst (v5.6 B KOMPLETT på main — C neste) +v5.5.0 released. v5.6 Foundation (`62d910e`) + **B1 manifest (`bb647ce`)** + **B2 token-hotspots** landet på main (TDD). **Hele B (load-pattern accounting) er nå ferdig.** Suite **1012** (996→1008→1012), self-audit **A/A**, count **13** (B la ingen scanner — C gir 14). v5.6 IKKE release-cuttet (krever C + eget GO). ## Gjort denne økten (2026-06-20, lør — pushet hvis i vindu) -- **`buildManifest` (`scanners/manifest.mjs`):** komponent-nivå — **plugin roll-up FJERNET** (dobbelttalte komponenter; plugin-bidrag enumereres allerede én gang som skills/rules/agents/output-styles/hooks/mcp). Source-kinds nå claude-md/skill/rule/agent/output-style/mcp-server/hook. Hver record får `loadPattern`/`survivesCompaction`/`derivationConfidence`. Rules/agents/output-styles **propagerer** Foundation-verdiene; CLAUDE.md scope→kind via `CLAUDE_MD_SCOPE_KIND` (alle cascade-filer walker opp → always); skills tagget **on-demand** (`skill-body` — målt token = body, ikke always-listing). Ny `summary` (always/onDemand/external/unknown {tokens,count}) i CLI-output. Ny `summarizeByLoadPattern` (eksportert). -- **Headline:** always-loaded subtotal. På plugin-rota: 24,3k always / 107 kilder (101 agenter dominerer), vs 125k on-demand (skill-bodies), 855 external (hooks). `total` endret (de-dup) — tilsiktet korrekthetsfiks. -- **Tester:** +12 (deterministisk enhetstest på `buildManifest` med syntetisk activeConfig: loadPattern per kind, summary-reconcil, plugin/disabled-mcp ekskludert fra total; + CLI loadPattern/summary-presence). Endret brutt `kind==='plugin'`-test → komponent-nivå. -- **Docs (docs-gate):** README (badge 996→1008, prosa + 3 manifest-beskrivelser), CLAUDE.md (test 1008 + ny «manifest — load-pattern accounting (v5.6 B)»-seksjon + tabellrad), scanner-internals manifest-rad. +**B1 (manifest, committet `bb647ce`):** komponent-nivå, plugin roll-up fjernet (dobbelttalte), loadPattern-triple per kilde + `summary` (always/onDemand/external) + always-loaded subtotal-headline. manifest = env-aware → mode-equivalence → byte-stabil uten regen. +**B2 (token-hotspots, denne committen):** +- **`token-hotspots.mjs`:** hver ranked hotspot får loadPattern-triple via `hotspotLoadPattern(h, ruleMap)` — `HOTSPOT_TYPE_TO_KIND`-mapping; rules slår opp `activeConfig.rules` for presis `scoped`; claude-md mapper via scope. To nye `deriveLoadPattern`-kinds: **`command`** (on-demand) + **`harness-config`** (external — settings/keybindings/.mcp.json/hooks.json/plugin.json = harness-config, IKKE model-context). Ærlig split: `.mcp.json`-FILA = external, MCP-serverens tool-schemas = separat `always`-hotspot. +- **Snapshot-blast (6 brudd / 5 filer):** TOK ER byte-equal SC-6/7 OG hotspots embedder i scan-orchestrator + posture. Løst ved å **bevare frozen v5.0.0**: delt `tests/helpers/strip-hotspot-load-pattern.mjs` stripper de additive feltene før byte-equal (json/raw-backcompat + de 3 Step 5/6/7 humanizer-testene). **SC-5 default-output regenerert** (`UPDATE_SNAPSHOT=1`, scan-orch + token-hotspots) — diff reviewet additiv-only. +- **Tester:** +4 (deriveLoadPattern command+harness-config; TOK hotspots loadPattern-triple + CLAUDE.md-always/skill-on-demand). Docs: README badge/prosa 1012, CLAUDE.md (1012 + ny B2-seksjon), tokens.md Load-kolonne, scanner-internals. ## Åpne tråder / neste steg (prioritert, alle KREVER GO) -1. **v5.6 B2 (TOK load-pattern-kolonne):** legg loadPattern på token-hotspots source-records. TOK ER byte-equal SC-6 → **bevar v5.0.0-baseline via normalizer-strip** (additive felt ignoreres, frozen-kontrakt intakt) + **regen SC-5 default-output/token-hotspots.json** (UPDATE_SNAPSHOT=1). Foundation + manifest-mønsteret ligger klart. -2. **v5.6 C (output-style scanner):** ny `CA-OST` (count 13→**14**): keep-coding-instructions/force-for-plugin/dead outputStyle. Badge + lore + humanizer/scoring/discovery-wiring. -3. **v5.6 release-cut:** etter B2+C (eget GO, som v5.5.0). -4. **v5.7:** D (mechanism-fit, heuristisk, precision-gated). -5. **llm-security KRITISK** (annet repo) + 3 aktive plugins + STATE-sveip — uendret. +1. **v5.6 C (output-style scanner):** ny `CA-OST` (count 13→**14**): `CA-OST-001` custom style mangler `keep-coding-instructions:true` (medium, V10), `CA-OST-002` `force-for-plugin` override (low, V11), `CA-OST-003` dead `outputStyle` (dead config). Foundation `enumerateOutputStyles` ligger klar. Badge 13→14 + lore-sweep (README/CLAUDE/scanner-internals + «count stays 13»-noter) + humanizer-data + scoring + discovery-wiring. Ny discovery-type for `.claude/output-styles/`? (Foundation gjorde det DIREKTE, ikke via file-discovery — vurder for scanner.) +2. **v5.6 release-cut:** etter C (eget GO, som v5.5.0): tag + catalog-bump (separat repo). +3. **v5.7:** D (mechanism-fit, heuristisk, precision-gated). +4. **llm-security KRITISK** (annet repo) + 3 aktive plugins + STATE-sveip — uendret. ## Gotchas (UFRAVIKELIG) -- **manifest er env-aware → SC-6/SC-7 = mode-equivalence (--json==--raw), IKKE byte-equal.** Derfor byte-stabil ved add-field-in-place (verifisert). **TOK er motsatt** (byte-equal) — B2 må håndtere snapshot. manifest IKKE i SC-5. -- **manifest har INGEN findings** → ingen humanizer/fix-engine-tittelkobling (gjelder kun finding-scannere). -- **Skill on-demand, IKKE always:** målt token = body (invoke). Always-listing (navn+desc) er liten, spores av skill-listing-budget/posture. Ikke tagg skill-body always — det blåser opp headline. -- Versjon KUN i plugin.json + README version-badge. **tests-badge = EKSAKT `ℹ tests N` fra `node --test`** (self-audit `countTestCases`); verifiser `readmeCheck.passed:true`/`mismatches:[]` via `--json`. 1008 nå. -- **docs-gate (non-marketplace gren) krever ≥3 ikke-ws-linjer i README OG CLAUDE.md for `feat:`.** Oppfylt ærlig denne økten. Alt.: `[skip-docs]`. -- Scanner-count 13. C gir 14. Path-guard blokkerer Write på `.claude-plugin/`+settings/hooks → hermetisk `mkdtemp` i tester. Push-vindu: hverdag 20–23, helg fritt. +- **To motsatte byte-stabilitets-regimer:** manifest = env-aware → mode-equivalence (additive felt gratis). TOK = byte-equal v5.0.0 OG embedder i scan-orch+posture → **5 frozen-v5.0.0-komparatorer** (json/raw-backcompat + cli-humanizer + posture-humanizer + scan-orchestrator-humanizer). **Grep ALLE 5 før du antar blast-radius** ved nytt scanner-output-felt. Bevar frozen via strip-helper; regen KUN SC-5 default-output. +- **manifest har INGEN findings** → ingen humanizer/fix-engine-tittelkobling (kun finding-scannere). +- **Skill on-demand** (body), ikke always. Always-listing (navn+desc) liten, spores av skill-listing-budget/posture. +- **harness-config = external** (settings/manifests koster ingen per-tur context-tokens). `.mcp.json`-fil ≠ MCP-tool-schemas (sistnevnte always). +- tests-badge = EKSAKT `ℹ tests N` (self-audit `countTestCases`); verifiser `readmeCheck.passed:true`/`mismatches:[]`. 1012 nå. Versjon KUN i plugin.json + README version-badge. +- **docs-gate (non-marketplace) krever ≥3 ikke-ws-linjer i README OG CLAUDE.md for `feat:`.** Path-guard blokkerer Write på `.claude-plugin/`+settings/hooks → hermetisk `mkdtemp`. Push-vindu: hverdag 20–23, helg fritt. ## Scope-gjerde -v5.6 B1 (manifest) godkjent (GO) og landet. B2 (TOK) + C + release-cut + v5.7 + andre repos = egne GO. Ikke start B2 uten GO. +v5.6 B (B1+B2) godkjent (GO) og landet. C + release-cut + v5.7 + andre repos = egne GO. Ikke start C uten GO. ## Kontinuitet Tre lag: STATE.md (tracked) + auto-memory + CLAUDE.md. Oppdater DENNE som siste handling. diff --git a/commands/tokens.md b/commands/tokens.md index 1840a69..ffce6ca 100644 --- a/commands/tokens.md +++ b/commands/tokens.md @@ -62,10 +62,12 @@ Do NOT render tables in JSON mode. Use the Read tool on `$TMPFILE`. Extract: - `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 - `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. + 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 @@ -73,9 +75,11 @@ Render as markdown. Group findings by `userImpactCategory` (e.g., "Wasted tokens ### Top hotspots (ranked by estimated tokens) -| Rank | Source | Tokens | Recommendations | -|------|--------|--------|-----------------| -| {rank} | `{source}` | ~{estimated_tokens} | {recommendations joined as `· ` bullets} | +| Rank | Source | Tokens | Load | Recommendations | +|------|--------|--------|------|-----------------| +| {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 diff --git a/docs/scanner-internals.md b/docs/scanner-internals.md index 05de705..3905d7c 100644 --- a/docs/scanner-internals.md +++ b/docs/scanner-internals.md @@ -53,7 +53,7 @@ Scanner CLI: `node scanners/scan-orchestrator.mjs [--global] [--full-mach | `fix-cli.mjs` | CLI: `node fix-cli.mjs [--apply] [--json] [--global]` | | `drift-cli.mjs` | CLI: `node drift-cli.mjs [--save] [--baseline name] [--json]` | | `whats-active.mjs` | CLI: `node whats-active.mjs [--json] [--verbose] [--suggest-disables]` — read-only active-config inventory | -| `token-hotspots-cli.mjs` | CLI: `node token-hotspots-cli.mjs [--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 [--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 [--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 diff --git a/scanners/lib/active-config-reader.mjs b/scanners/lib/active-config-reader.mjs index b87f8e3..a74f8cd 100644 --- a/scanners/lib/active-config-reader.mjs +++ b/scanners/lib/active-config-reader.mjs @@ -99,6 +99,14 @@ export function deriveLoadPattern(kind, opts = {}) { // 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'); } } diff --git a/scanners/token-hotspots.mjs b/scanners/token-hotspots.mjs index 12bfbe7..0ac42b0 100644 --- a/scanners/token-hotspots.mjs +++ b/scanners/token-hotspots.mjs @@ -27,10 +27,64 @@ import { readTextFile } from './lib/file-discovery.mjs'; import { finding, scannerResult } from './lib/output.mjs'; import { SEVERITY } from './lib/severity.mjs'; import { findImports, parseJson, parseFrontmatter } from './lib/yaml-parser.mjs'; -import { estimateTokens, readActiveConfig } from './lib/active-config-reader.mjs'; +import { estimateTokens, readActiveConfig, deriveLoadPattern } from './lib/active-config-reader.mjs'; 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} 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_PATTERNS = [ /\{timestamp\}/i, @@ -241,14 +295,25 @@ async function buildHotspots(discovery, targetPath, activeConfig) { } 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 out = []; for (let i = 0; i < top.length; i++) { const h = top[i]; + const lp = hotspotLoadPattern(h, ruleMap); const entry = { source: h.relPath || h.absPath, estimated_tokens: h.estimated_tokens, rank: i + 1, + loadPattern: lp.loadPattern, + survivesCompaction: lp.survivesCompaction, + derivationConfidence: lp.derivationConfidence, recommendations: hotspotRecommendations(h), }; // Expose the on-disk path for file-backed hotspots so the diff --git a/tests/helpers/strip-hotspot-load-pattern.mjs b/tests/helpers/strip-hotspot-load-pattern.mjs new file mode 100644 index 0000000..aa0b02d --- /dev/null +++ b/tests/helpers/strip-hotspot-load-pattern.mjs @@ -0,0 +1,37 @@ +/** + * v5.6 B2 added a load-pattern triple (loadPattern / survivesCompaction / + * derivationConfidence) to each TOK hotspot. The frozen v5.0.0 byte-equal + * baselines strip it before comparison so they still prove the ORIGINAL schema + * is byte-identical; the new fields are purely additive. + * + * TOK hotspots ride along inside scan-orchestrator and posture payloads too + * (`scanners[].hotspots`, `scannerEnvelope.scanners[].hotspots`), so this + * strips every hotspots array reachable in a CLI payload / envelope. Mutates + * in place and returns the payload for chaining inside a normalizer. + * + * @template T + * @param {T} payload + * @returns {T} + */ +export function stripHotspotLoadPattern(payload) { + const drop = (arr) => { + if (!Array.isArray(arr)) return; + for (const h of arr) { + delete h.loadPattern; + delete h.survivesCompaction; + delete h.derivationConfidence; + } + }; + if (!payload || typeof payload !== 'object') return payload; + drop(payload.hotspots); + if (Array.isArray(payload.scanners)) { + for (const s of payload.scanners) drop(s.hotspots); + } + if (payload.scannerEnvelope) { + drop(payload.scannerEnvelope.hotspots); + if (Array.isArray(payload.scannerEnvelope.scanners)) { + for (const s of payload.scannerEnvelope.scanners) drop(s.hotspots); + } + } + return payload; +} diff --git a/tests/json-backcompat.test.mjs b/tests/json-backcompat.test.mjs index 975cdc9..0d21cd9 100644 --- a/tests/json-backcompat.test.mjs +++ b/tests/json-backcompat.test.mjs @@ -29,6 +29,7 @@ import { promisify } from 'node:util'; import { readFile, writeFile, access, mkdir } from 'node:fs/promises'; import { homedir } from 'node:os'; import { hermeticEnv, HERMETIC_HOME } from './helpers/hermetic-home.mjs'; +import { stripHotspotLoadPattern } from './helpers/strip-hotspot-load-pattern.mjs'; const exec = promisify(execFile); const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -102,7 +103,7 @@ function normalizeScanOrchestrator(env) { } } stripAncestorDerived(out); - return out; + return stripHotspotLoadPattern(out); } function normalizePosture(p) { @@ -119,13 +120,13 @@ function normalizePosture(p) { } stripAncestorDerived(out.scannerEnvelope); } - return out; + return stripHotspotLoadPattern(out); } function normalizeTokenHotspots(p) { const out = JSON.parse(JSON.stringify(p)); out.duration_ms = 0; - return out; + return stripHotspotLoadPattern(out); } function normalizeDrift(p) { diff --git a/tests/lib/active-config-reader.test.mjs b/tests/lib/active-config-reader.test.mjs index 7993d47..61fd5ec 100644 --- a/tests/lib/active-config-reader.test.mjs +++ b/tests/lib/active-config-reader.test.mjs @@ -731,6 +731,9 @@ describe('deriveLoadPattern (v5.6)', () => { ['output-style', {}, 'always', 'yes', 'confirmed'], ['hook', {}, 'external', 'n/a', 'confirmed'], ['mcp', {}, 'always', 'yes', 'inferred'], + // v5.6 B2 — kinds surfaced by the token-hotspots discovery surface. + ['command', {}, 'on-demand', 'n/a', 'inferred'], // body loads on /invoke + ['harness-config', {}, 'external', 'n/a', 'inferred'], // settings/manifests: not model context ]; for (const [kind, opts, loadPattern, survivesCompaction, derivationConfidence] of cases) { const label = `${kind}${opts.scoped !== undefined ? ` (scoped=${opts.scoped})` : ''}`; diff --git a/tests/raw-backcompat.test.mjs b/tests/raw-backcompat.test.mjs index 96bd2e3..8ffa386 100644 --- a/tests/raw-backcompat.test.mjs +++ b/tests/raw-backcompat.test.mjs @@ -26,6 +26,7 @@ import { promisify } from 'node:util'; import { readFile, writeFile, access, mkdir } from 'node:fs/promises'; import { homedir } from 'node:os'; import { hermeticEnv, HERMETIC_HOME } from './helpers/hermetic-home.mjs'; +import { stripHotspotLoadPattern } from './helpers/strip-hotspot-load-pattern.mjs'; const exec = promisify(execFile); const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -97,7 +98,7 @@ function normalizeScanOrchestrator(env) { } } stripAncestorDerived(out); - return out; + return stripHotspotLoadPattern(out); } function normalizePosture(p) { @@ -114,13 +115,13 @@ function normalizePosture(p) { } stripAncestorDerived(out.scannerEnvelope); } - return out; + return stripHotspotLoadPattern(out); } function normalizeTokenHotspots(p) { const out = JSON.parse(JSON.stringify(p)); out.duration_ms = 0; - return out; + return stripHotspotLoadPattern(out); } function normalizeDrift(p) { diff --git a/tests/scanners/cli-humanizer.test.mjs b/tests/scanners/cli-humanizer.test.mjs index 716194a..27f1c2d 100644 --- a/tests/scanners/cli-humanizer.test.mjs +++ b/tests/scanners/cli-humanizer.test.mjs @@ -6,6 +6,7 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { readFile, writeFile, unlink, mkdir, access } from 'node:fs/promises'; import { hermeticEnv, HERMETIC_HOME } from '../helpers/hermetic-home.mjs'; +import { stripHotspotLoadPattern } from '../helpers/strip-hotspot-load-pattern.mjs'; const exec = promisify(execFile); const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -42,7 +43,7 @@ async function runCli(cliPath, args, env = {}) { function normalizeTokenHotspotsPayload(p) { const out = JSON.parse(JSON.stringify(p)); out.duration_ms = 0; - return out; + return stripHotspotLoadPattern(out); } function normalizeManifestOutput(o) { diff --git a/tests/scanners/posture-humanizer.test.mjs b/tests/scanners/posture-humanizer.test.mjs index 84ec489..9a24283 100644 --- a/tests/scanners/posture-humanizer.test.mjs +++ b/tests/scanners/posture-humanizer.test.mjs @@ -6,6 +6,7 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { readFile, unlink } from 'node:fs/promises'; import { hermeticEnv } from '../helpers/hermetic-home.mjs'; +import { stripHotspotLoadPattern } from '../helpers/strip-hotspot-load-pattern.mjs'; const exec = promisify(execFile); const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -38,7 +39,7 @@ function normalizePosture(p) { } } } - return out; + return stripHotspotLoadPattern(out); } /** Strip time-varying durations (Xms) so progress lines compare verbatim across runs. */ diff --git a/tests/scanners/scan-orchestrator-humanizer.test.mjs b/tests/scanners/scan-orchestrator-humanizer.test.mjs index 13c05a6..ee850e5 100644 --- a/tests/scanners/scan-orchestrator-humanizer.test.mjs +++ b/tests/scanners/scan-orchestrator-humanizer.test.mjs @@ -6,6 +6,7 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { readFile, unlink } from 'node:fs/promises'; import { hermeticEnv } from '../helpers/hermetic-home.mjs'; +import { stripHotspotLoadPattern } from '../helpers/strip-hotspot-load-pattern.mjs'; const exec = promisify(execFile); const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -36,7 +37,7 @@ function normalizeEnvelope(env) { } } } - return out; + return stripHotspotLoadPattern(out); } async function runOrchestrator(flags) { diff --git a/tests/scanners/token-hotspots.test.mjs b/tests/scanners/token-hotspots.test.mjs index f0888dc..4c29eda 100644 --- a/tests/scanners/token-hotspots.test.mjs +++ b/tests/scanners/token-hotspots.test.mjs @@ -182,6 +182,32 @@ describe('TOK scanner — hotspots contract', () => { } }); + it('every hotspot carries a load-pattern triple (v5.6 B2)', () => { + const LOAD_PATTERNS = new Set(['always', 'on-demand', 'external', 'unknown']); + const SURVIVES = new Set(['yes', 'no', 'n/a']); + for (const h of result.hotspots) { + assert.ok(LOAD_PATTERNS.has(h.loadPattern), + `hotspot ${h.source} has invalid loadPattern: ${h.loadPattern}`); + assert.ok(SURVIVES.has(h.survivesCompaction), + `hotspot ${h.source} has invalid survivesCompaction: ${h.survivesCompaction}`); + assert.ok(typeof h.derivationConfidence === 'string' && h.derivationConfidence.length > 0, + `hotspot ${h.source} missing derivationConfidence`); + } + }); + + it('tags a CLAUDE.md hotspot always-loaded and a skill hotspot on-demand (v5.6 B2)', () => { + const claudeMd = result.hotspots.find(h => /CLAUDE\.md/.test(h.source)); + if (claudeMd) { + assert.equal(claudeMd.loadPattern, 'always', + `expected CLAUDE.md hotspot always-loaded; got ${claudeMd.loadPattern}`); + } + const skill = result.hotspots.find(h => /SKILL\.md/.test(h.source) || /skills?\//.test(h.source)); + if (skill) { + assert.equal(skill.loadPattern, 'on-demand', + `expected skill hotspot on-demand; got ${skill.loadPattern}`); + } + }); + it('every hotspot.source is unique (v5 F4: no padding)', () => { const sources = result.hotspots.map(h => h.source); const unique = new Set(sources); diff --git a/tests/snapshots/default-output/scan-orchestrator.json b/tests/snapshots/default-output/scan-orchestrator.json index 2a017e3..f38c9c9 100644 --- a/tests/snapshots/default-output/scan-orchestrator.json +++ b/tests/snapshots/default-output/scan-orchestrator.json @@ -445,6 +445,9 @@ "source": "mcp:memory (.mcp.json)", "estimated_tokens": 500, "rank": 1, + "loadPattern": "always", + "survivesCompaction": "yes", + "derivationConfidence": "inferred", "recommendations": [ "Review whether this source needs to load on every turn." ] @@ -453,6 +456,9 @@ "source": "CLAUDE.md", "estimated_tokens": 116, "rank": 2, + "loadPattern": "always", + "survivesCompaction": "yes", + "derivationConfidence": "confirmed", "recommendations": [ "Move volatile top-of-file content to the bottom or extract to an @import-ed file.", "Split overlong CLAUDE.md into focused @imports (≤200 lines each)." @@ -463,6 +469,9 @@ "source": "hooks/hooks.json", "estimated_tokens": 81, "rank": 3, + "loadPattern": "external", + "survivesCompaction": "n/a", + "derivationConfidence": "inferred", "recommendations": [ "Deduplicate overlapping entries — each duplicate inflates the per-turn schema payload.", "Move rarely-used permissions to a project-local override." @@ -473,6 +482,9 @@ "source": ".claude/settings.json", "estimated_tokens": 59, "rank": 4, + "loadPattern": "external", + "survivesCompaction": "n/a", + "derivationConfidence": "inferred", "recommendations": [ "Deduplicate overlapping entries — each duplicate inflates the per-turn schema payload.", "Move rarely-used permissions to a project-local override." @@ -483,6 +495,9 @@ "source": ".mcp.json", "estimated_tokens": 45, "rank": 5, + "loadPattern": "external", + "survivesCompaction": "n/a", + "derivationConfidence": "inferred", "recommendations": [ "Deduplicate overlapping entries — each duplicate inflates the per-turn schema payload.", "Move rarely-used permissions to a project-local override." diff --git a/tests/snapshots/default-output/token-hotspots.json b/tests/snapshots/default-output/token-hotspots.json index b2c5912..dfb5a2d 100644 --- a/tests/snapshots/default-output/token-hotspots.json +++ b/tests/snapshots/default-output/token-hotspots.json @@ -11,6 +11,9 @@ "source": "mcp:memory (.mcp.json)", "estimated_tokens": 500, "rank": 1, + "loadPattern": "always", + "survivesCompaction": "yes", + "derivationConfidence": "inferred", "recommendations": [ "Review whether this source needs to load on every turn." ] @@ -19,6 +22,9 @@ "source": "CLAUDE.md", "estimated_tokens": 116, "rank": 2, + "loadPattern": "always", + "survivesCompaction": "yes", + "derivationConfidence": "confirmed", "recommendations": [ "Move volatile top-of-file content to the bottom or extract to an @import-ed file.", "Split overlong CLAUDE.md into focused @imports (≤200 lines each)." @@ -29,6 +35,9 @@ "source": "hooks/hooks.json", "estimated_tokens": 81, "rank": 3, + "loadPattern": "external", + "survivesCompaction": "n/a", + "derivationConfidence": "inferred", "recommendations": [ "Deduplicate overlapping entries — each duplicate inflates the per-turn schema payload.", "Move rarely-used permissions to a project-local override." @@ -39,6 +48,9 @@ "source": ".claude/settings.json", "estimated_tokens": 59, "rank": 4, + "loadPattern": "external", + "survivesCompaction": "n/a", + "derivationConfidence": "inferred", "recommendations": [ "Deduplicate overlapping entries — each duplicate inflates the per-turn schema payload.", "Move rarely-used permissions to a project-local override." @@ -49,6 +61,9 @@ "source": ".mcp.json", "estimated_tokens": 45, "rank": 5, + "loadPattern": "external", + "survivesCompaction": "n/a", + "derivationConfidence": "inferred", "recommendations": [ "Deduplicate overlapping entries — each duplicate inflates the per-turn schema payload.", "Move rarely-used permissions to a project-local override."