diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0c1195e..cdf6eb5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "context-police", "source": "./plugins/context-police", "description": "Patrol the context budget: measure the skills/agents catalog cost (now surfaced natively by /doctor), trim it per-project (skillOverrides) or globally (disable-model-invocation), gate per-skill description length at publish time so truncation stops silently killing trigger phrases, emit an interactive HTML recap, and curate episodic lessons out of the always-on catalog by description intent.", - "version": "2.3.0" + "version": "2.4.0" } ] } diff --git a/README.md b/README.md index e959ecc..62856c0 100644 --- a/README.md +++ b/README.md @@ -277,14 +277,27 @@ Exit `0` clean · `1` over cap · `2` bad path (so a typo fails loudly instead o After you apply a treatment, render a clickable HTML report of exactly what happened: ```bash -python3 ~/.claude/skills/context-police/scripts/render_treatment_report.py \ - --settings .claude/settings.json \ - --skills-dir ~/.claude/skills \ - --decisions panel-decisions.json \ - --title "My Project" \ - --out skill-treatment.html +# Resolve across all three install roots. A plugin install creates neither of the first two. +S="${CLAUDE_PLUGIN_ROOT:+${CLAUDE_PLUGIN_ROOT}/skills/context-police/scripts/render_treatment_report.py}" +[ -f "$S" ] || S="$HOME/.claude/skills/context-police/scripts/render_treatment_report.py" +[ -f "$S" ] || S="$(find -L "$HOME/.claude/plugins/cache" -mindepth 7 -maxdepth 7 \ + -path '*/context-police/*/skills/context-police/scripts/render_treatment_report.py' 2>/dev/null \ + | awk -F/ '{print $(NF-4)"\t"$0}' | sort -V -k1,1 | tail -1 | cut -f2-)" + +if [ -f "$S" ]; then + python3 "$S" \ + --settings .claude/settings.json \ + --skills-dir ~/.claude/skills \ + --decisions panel-decisions.json \ + --title "My Project" \ + --out skill-treatment.html +else + echo "render_treatment_report.py: not found - tried \$CLAUDE_PLUGIN_ROOT/skills/context-police/scripts/, ~/.claude/skills/context-police/scripts/, and the plugin cache" +fi ``` +> **Why the resolver?** A `/plugin install` puts the script under `~/.claude/plugins/cache//context-police//skills/context-police/scripts/` — `~/.claude/skills/context-police/` never exists, so a single hardcoded root silently misses and the recap step does nothing while the summary still reads clean. The version is ranked on its own path segment (`$(NF-4)`), not the whole path, because the marketplace name comes first and would otherwise decide the ordering. + - Data-driven & honest-by-construction: it reads the OFF set straight from your `settings.json`, enumerates the skills universe, and computes the bare-name token estimate (`Σ(len(name)+3)/4`, paid every turn + per subagent). - The `--decisions` file is optional (`{"pulls":[…],"adds":[…],"override":[…]}`); omit it for a plain off/on drill-down, pass it to surface the review-panel reasons. - All data is inlined — no server, no build. On macOS, `open skill-treatment.html`. @@ -370,6 +383,7 @@ CI (`npm test`, zero-dependency `node --test`) fails if the copies drift or the ## 📜 Version history +- **v2.4.0** — **The recap step was unreachable on a plugin install.** `SKILL.md` (and this README) told the agent to run `python3 ~/.claude/skills/context-police/scripts/render_treatment_report.py`. A `/plugin install` never creates that path — the script lands under `~/.claude/plugins/cache//context-police//skills/context-police/scripts/` — so on the plugin install path the command just failed, and the usual "log it and continue" response means the recap silently does nothing while the run still reads clean. Both call sites now resolve across all three install roots (`$CLAUDE_PLUGIN_ROOT` → `~/.claude/skills/` → a version-ranked `find` over the plugin cache) and print `not found - tried ` instead of a bare "not installed", which has already been misread by a human as proof a skill was absent. `CLAUDE_PLUGIN_ROOT` alone does not fix this: it is frequently unset in the shell a step runs in, and it points at the *calling* plugin's own root so it can never reach a sibling. The version is ranked on its **own path segment** (`awk '{print $(NF-4)}'`), not the whole path — the marketplace segment precedes the version, so a plain `sort -V` over full paths would let `aaa-mkt/2.5.0` lose to `zzz-mkt/1.0.0`. New `tests/plugin-path-resolution.test.mjs` extracts the resolver straight out of `SKILL.md` and runs it against a fixture cache, so a regression fails CI rather than being caught by eye. - **v2.3.0** — **The gate's own guarantees were narrower than the sentences describing them.** Four fixes, each with a negative control — see [The gap this gate had](#-the-gap-this-gate-had-and-how-it-was-closed-v230). **(1) Wrap corruption is now scored over EVERY skill**, disabled included, and fails the build; it was scoped to the model-invocable subset, so in a repo where 74 of 94 skills are disabled the check was silently blind to most of it — which is why four real corruptions there had to be found via `--json` instead of CI. Disabled hits print in their own group. **(2) New `NO HEADROOM` tier** (`MIN_HEADROOM = 40`): `WARN_FRACTION = 0.75` lumped a description with 23 chars of slack in with one that had 340 — 29 skills, one bucket, one colour — and this skill sat at cap−3 inside it. Sorted tightest-first, remaining slack on every row. **(3)** SKILL.md now prescribes a **pinned sha256** for vendored copies rather than a feature grep: a "not a stale fork" test built from three substring assertions stayed green on a copy that genuinely *was* a stale fork, because the drift was inside a function whose name never changed. **(4)** SKILL.md now documents that **`--compare` only sees double-quoted spans** — backticked literals are invisible, and one repo's trim dropped three of them while `--compare` reported 0 DROPPED — and ships a description-scoped hand-diff recipe. Also corrects the `agent-review-panel` separation figure quoted in the procedure (`+26.0 → +32.0 pts` → **`+0.2605 → +0.3183`**) and the second-pass split (`13 / 25 / 1` → **`12 / 27 / 0`**); those came from an uncommitted one-off and do not reproduce against the harness that now ships in that repo. `--json` gains `min_headroom`, `counts.critical_headroom`, and per-skill `critical` / `headroom`. - **v2.2.2** — **This skill was three characters from breaking its own rule.** The description was **1,533** chars against the 1,536 cap: under it, so nothing was truncated and no trigger was lost — but with **3 chars of headroom**, while step 8 of this skill's own trimming procedure says *"Leave headroom (~30–50 chars). A trim landing at cap−2 is one edit from breaking again."* Retrimmed to **1,483** (**53** headroom) by compressing prose only: `the listing of skill names+descriptions` → `the name+description listing`, `native harness features` → `native`, `one skill's description` → `a description`, `only the levers differ` → `only levers differ`. Verified the way this skill tells you to: `--compare` reports **0 dropped, 0 narrowed** with both quoted trigger phrases (`"Prompt is too long"`, `"name-invoked → restore"`) intact; the content-word set loses only `descriptions`, `features`, `from`, `names`, `one` — plurals and filler from those four edits — and gains nothing; no backticked literal dropped; re-wrapped with `break_on_hyphens=False` and the gate reports no `BROKEN BY LINE-WRAP`. Both copies of `SKILL.md` stay byte-identical (`plugin-copy-sync` test green). - **Note on the numbering below.** PR [#6](https://github.com/wan-huiyan/context-police/pull/6) was squash-merged as `eedad0f` with the subject *"(v2.1.0)"*, but the branch had already gone 2.1.0 → 2.2.0 → 2.2.1 internally, so `main` jumped **2.0.0 → 2.2.1** in a single commit and the v2.1.0 entry below is the only record of all three. `v2.2.0` added wrap-corruption detection and `--compare`; `v2.2.1` (`6f854ea`) fixed `find_wrap_corruption()` false-positiving on `description: >-`. Neither is a git tag — this repo's newest tag is `v2.0.0`, so every "v2.2.x" reference anywhere in this ecosystem means a `plugin.json`/`marketplace.json` version, never a release. diff --git a/SKILL.md b/SKILL.md index 1a40ce3..b94088d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -17,7 +17,7 @@ description: | `disable-model-invocation` DUAL-ROLE footgun — also the correct setting for a user slash-command, so a "name-invoked → restore" audit is a false-positive machine. author: Claude Code -version: 2.3.0 +version: 2.4.0 date: 2026-06-17 --- @@ -338,10 +338,32 @@ After a treatment, render a self-contained, interactive HTML recap (reads `.clau computes counts + the bare-name token estimate; clickable tiles → a searchable explorer of every skill by decision). Honest-by-construction; opens from `file://`. ```bash -python3 ~/.claude/skills/context-police/scripts/render_treatment_report.py \ - --settings .claude/settings.json [--skills-dir ~/.claude/skills] \ - [--decisions panel-decisions.json] [--title "My Project"] [--out skill-treatment.html] +# Resolve across all three install roots. A plugin install creates neither of the first two. +S="${CLAUDE_PLUGIN_ROOT:+${CLAUDE_PLUGIN_ROOT}/skills/context-police/scripts/render_treatment_report.py}" +[ -f "$S" ] || S="$HOME/.claude/skills/context-police/scripts/render_treatment_report.py" +[ -f "$S" ] || S="$(find -L "$HOME/.claude/plugins/cache" -mindepth 7 -maxdepth 7 \ + -path '*/context-police/*/skills/context-police/scripts/render_treatment_report.py' 2>/dev/null \ + | awk -F/ '{print $(NF-4)"\t"$0}' | sort -V -k1,1 | tail -1 | cut -f2-)" + +if [ -f "$S" ]; then + python3 "$S" \ + --settings .claude/settings.json [--skills-dir ~/.claude/skills] \ + [--decisions panel-decisions.json] [--title "My Project"] [--out skill-treatment.html] +else + echo "render_treatment_report.py: not found - tried \$CLAUDE_PLUGIN_ROOT/skills/context-police/scripts/, ~/.claude/skills/context-police/scripts/, and the plugin cache" +fi ``` +**Why three roots.** A plugin install lands under `~/.claude/plugins/cache//context-police//`, +so `~/.claude/skills/context-police/` does not exist and a single hardcoded root silently misses. `CLAUDE_PLUGIN_ROOT` +does not rescue it on its own — it is often unset in the shell a step runs in, and it points at the *calling* plugin's +root, so it can never reach a sibling. Four details in the snippet are load-bearing: rank on the **version segment +alone** (`$(NF-4)`) because the marketplace segment precedes the version and a plain `sort -V` over whole paths would +let `aaa-mkt/2.5.0` lose to `zzz-mkt/1.0.0`; use `find`, not a glob, because zsh's `nomatch` fails a non-matching glob +*before* `2>/dev/null` can apply; guard **before** any `> "$OUT"` redirect, since the shell creates and truncates the +file before the command runs and leaves a 0-byte file that reads as a real record; and say *"not found - tried +<paths>"* rather than *"not installed"* — a failed lookup is not evidence about install state, and a bare "not +installed" has already been misread by a human as proof a skill was absent. + Verify the render with `browser_evaluate` over a served port (`file://` is blocked in MCP browser; the screenshot subsystem wedges) or `open` it on macOS. diff --git a/plugins/context-police/.claude-plugin/plugin.json b/plugins/context-police/.claude-plugin/plugin.json index 36ce203..f21d542 100644 --- a/plugins/context-police/.claude-plugin/plugin.json +++ b/plugins/context-police/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "context-police", "description": "Measure, trim, and report the token cost of a runaway Claude Code skills/agents catalog (injected every turn + into every subagent) — verified per-project skillOverrides + global disable-model-invocation levers, a publish-time per-skill description-cap gate that reports which trigger phrases truncation has silently killed, an interactive HTML recap, and the durable root-cause fix (curate episodic lessons out of the always-on catalog by description intent; a retrieval-hook replacement was tested to ground and killed by a base-rate wall).", - "version": "2.3.0", + "version": "2.4.0", "author": { "name": "Huiyan Wan" }, diff --git a/plugins/context-police/skills/context-police/SKILL.md b/plugins/context-police/skills/context-police/SKILL.md index 1a40ce3..b94088d 100644 --- a/plugins/context-police/skills/context-police/SKILL.md +++ b/plugins/context-police/skills/context-police/SKILL.md @@ -17,7 +17,7 @@ description: | `disable-model-invocation` DUAL-ROLE footgun — also the correct setting for a user slash-command, so a "name-invoked → restore" audit is a false-positive machine. author: Claude Code -version: 2.3.0 +version: 2.4.0 date: 2026-06-17 --- @@ -338,10 +338,32 @@ After a treatment, render a self-contained, interactive HTML recap (reads `.clau computes counts + the bare-name token estimate; clickable tiles → a searchable explorer of every skill by decision). Honest-by-construction; opens from `file://`. ```bash -python3 ~/.claude/skills/context-police/scripts/render_treatment_report.py \ - --settings .claude/settings.json [--skills-dir ~/.claude/skills] \ - [--decisions panel-decisions.json] [--title "My Project"] [--out skill-treatment.html] +# Resolve across all three install roots. A plugin install creates neither of the first two. +S="${CLAUDE_PLUGIN_ROOT:+${CLAUDE_PLUGIN_ROOT}/skills/context-police/scripts/render_treatment_report.py}" +[ -f "$S" ] || S="$HOME/.claude/skills/context-police/scripts/render_treatment_report.py" +[ -f "$S" ] || S="$(find -L "$HOME/.claude/plugins/cache" -mindepth 7 -maxdepth 7 \ + -path '*/context-police/*/skills/context-police/scripts/render_treatment_report.py' 2>/dev/null \ + | awk -F/ '{print $(NF-4)"\t"$0}' | sort -V -k1,1 | tail -1 | cut -f2-)" + +if [ -f "$S" ]; then + python3 "$S" \ + --settings .claude/settings.json [--skills-dir ~/.claude/skills] \ + [--decisions panel-decisions.json] [--title "My Project"] [--out skill-treatment.html] +else + echo "render_treatment_report.py: not found - tried \$CLAUDE_PLUGIN_ROOT/skills/context-police/scripts/, ~/.claude/skills/context-police/scripts/, and the plugin cache" +fi ``` +**Why three roots.** A plugin install lands under `~/.claude/plugins/cache//context-police//`, +so `~/.claude/skills/context-police/` does not exist and a single hardcoded root silently misses. `CLAUDE_PLUGIN_ROOT` +does not rescue it on its own — it is often unset in the shell a step runs in, and it points at the *calling* plugin's +root, so it can never reach a sibling. Four details in the snippet are load-bearing: rank on the **version segment +alone** (`$(NF-4)`) because the marketplace segment precedes the version and a plain `sort -V` over whole paths would +let `aaa-mkt/2.5.0` lose to `zzz-mkt/1.0.0`; use `find`, not a glob, because zsh's `nomatch` fails a non-matching glob +*before* `2>/dev/null` can apply; guard **before** any `> "$OUT"` redirect, since the shell creates and truncates the +file before the command runs and leaves a 0-byte file that reads as a real record; and say *"not found - tried +<paths>"* rather than *"not installed"* — a failed lookup is not evidence about install state, and a bare "not +installed" has already been misread by a human as proof a skill was absent. + Verify the render with `browser_evaluate` over a served port (`file://` is blocked in MCP browser; the screenshot subsystem wedges) or `open` it on macOS. diff --git a/tests/plugin-path-resolution.test.mjs b/tests/plugin-path-resolution.test.mjs new file mode 100644 index 0000000..6e7ff05 --- /dev/null +++ b/tests/plugin-path-resolution.test.mjs @@ -0,0 +1,147 @@ +// Plugin-install path resolution for render_treatment_report.py. +// +// A skill installed as a PLUGIN lives at +// ~/.claude/plugins/cache//context-police//skills/context-police/... +// and NOT at ~/.claude/skills/context-police/. An instruction that reaches the +// script through the ~/.claude/skills/ root alone misses on every plugin +// install, and the usual "log it and continue" response makes the recap step do +// nothing while the run still reads clean. +// +// These tests extract the resolver verbatim out of SKILL.md / README.md and +// execute it, so the documented snippet is the thing under test — not a copy of +// it that could drift. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const SCRIPT = 'render_treatment_report.py'; + +/** Pull the three-root resolver out of a doc: the `S=` chain, nothing else. */ +function extractResolver(file) { + const lines = readFileSync(join(root, file), 'utf8').split('\n'); + const start = lines.findIndex((l) => l.startsWith('S="${CLAUDE_PLUGIN_ROOT')); + assert.notEqual(start, -1, `${file}: no resolver found (expected an S="\${CLAUDE_PLUGIN_ROOT...} line)`); + const end = lines.findIndex((l, i) => i >= start && l.includes('cut -f2-)"')); + assert.notEqual(end, -1, `${file}: resolver has no plugin-cache fallback (expected a cut -f2-)" line)`); + return lines.slice(start, end + 1).join('\n'); +} + +/** Run the resolver with a fake HOME; return { path, stderr }. */ +function resolve(snippet, home, env = {}) { + const r = spawnSync('bash', ['-c', `${snippet}\nprintf '%s' "$S"`], { + encoding: 'utf8', + env: { PATH: process.env.PATH, HOME: home, ...env }, + }); + assert.equal(r.status, 0, `resolver exited ${r.status}: ${r.stderr}`); + return { path: r.stdout, stderr: r.stderr }; +} + +/** Build a fake plugin cache: [marketplace, version] pairs, all carrying the script. */ +function fakeHome(entries) { + const home = mkdtempSync(join(tmpdir(), 'cp-home-')); + for (const [marketplace, version] of entries) { + const dir = join(home, '.claude', 'plugins', 'cache', marketplace, + 'context-police', version, 'skills', 'context-police', 'scripts'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, SCRIPT), '#!/usr/bin/env python3\n'); + } + return home; +} + +const resolvers = ['SKILL.md', 'README.md'].map((f) => [f, extractResolver(f)]); + +test('SKILL.md and README.md ship the identical resolver', () => { + assert.equal(resolvers[1][1], resolvers[0][1], + 'the resolver drifted between SKILL.md and README.md — both are rendered surfaces, fix both'); +}); + +for (const [file, snippet] of resolvers) { + test(`${file}: resolves a plugin-cache install (no ~/.claude/skills/ dir at all)`, () => { + const home = fakeHome([['wan-huiyan-context-police', '2.3.0']]); + try { + const { path } = resolve(snippet, home); + assert.equal(path, join(home, '.claude/plugins/cache/wan-huiyan-context-police', + 'context-police/2.3.0/skills/context-police/scripts', SCRIPT)); + } finally { rmSync(home, { recursive: true, force: true }); } + }); + + test(`${file}: ranks on the VERSION segment, not the marketplace name`, () => { + // The marketplace segment PRECEDES the version in the path, so a plain + // `sort -V` over whole paths ranks by marketplace name and lets + // aaa-mkt/2.5.0 lose to zzz-mkt/1.0.0. + const home = fakeHome([['aaa-marketplace', '2.5.0'], ['zzz-marketplace', '1.0.0']]); + try { + const { path } = resolve(snippet, home); + assert.match(path, /aaa-marketplace\/context-police\/2\.5\.0\//, + 'picked the alphabetically-last marketplace instead of the highest version'); + } finally { rmSync(home, { recursive: true, force: true }); } + }); + + test(`${file}: orders versions numerically (2.10.0 beats 2.9.0)`, () => { + const home = fakeHome([['mkt', '2.9.0'], ['mkt', '2.10.0']]); + try { + assert.match(resolve(snippet, home).path, /\/2\.10\.0\//); + } finally { rmSync(home, { recursive: true, force: true }); } + }); + + test(`${file}: prefers ~/.claude/skills/ over the plugin cache`, () => { + const home = fakeHome([['mkt', '9.9.9']]); + const dir = join(home, '.claude/skills/context-police/scripts'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, SCRIPT), '#!/usr/bin/env python3\n'); + try { + assert.equal(resolve(snippet, home).path, join(dir, SCRIPT)); + } finally { rmSync(home, { recursive: true, force: true }); } + }); + + test(`${file}: CLAUDE_PLUGIN_ROOT wins when it actually holds the script`, () => { + const home = fakeHome([['mkt', '9.9.9']]); + const pluginRoot = join(home, 'somewhere', 'context-police'); + const dir = join(pluginRoot, 'skills/context-police/scripts'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, SCRIPT), '#!/usr/bin/env python3\n'); + try { + const { path } = resolve(snippet, home, { CLAUDE_PLUGIN_ROOT: pluginRoot }); + assert.equal(path, join(dir, SCRIPT)); + } finally { rmSync(home, { recursive: true, force: true }); } + }); + + test(`${file}: an unset CLAUDE_PLUGIN_ROOT is not treated as a path`, () => { + // "${VAR:+...}" must collapse to the empty string, not to "/skills/...". + const home = fakeHome([['mkt', '1.0.0']]); + try { + assert.match(resolve(snippet, home, { CLAUDE_PLUGIN_ROOT: '' }).path, /\/1\.0\.0\//); + } finally { rmSync(home, { recursive: true, force: true }); } + }); + + test(`${file}: a missing plugin cache fails quietly, with nothing on stderr`, () => { + // A shell glob would blow up here before 2>/dev/null could apply; find does not. + const home = mkdtempSync(join(tmpdir(), 'cp-home-')); + try { + const { path, stderr } = resolve(snippet, home); + assert.equal(path, '', 'resolved to something in a home with no install at all'); + assert.equal(stderr, '', `resolver leaked to stderr: ${stderr}`); + } finally { rmSync(home, { recursive: true, force: true }); } + }); +} + +for (const file of ['SKILL.md', 'README.md']) { + test(`${file}: never invokes a script through the ~/.claude/skills/ root alone`, () => { + const offenders = readFileSync(join(root, file), 'utf8') + .split('\n') + .filter((l) => /^\s*(python3?|bash|sh)\s+["']?~\/\.claude\/skills\//.test(l)); + assert.deepEqual(offenders, [], + `${file} invokes a script via a single hardcoded root — a plugin install misses it entirely`); + }); + + test(`${file}: the not-found message names the paths it tried`, () => { + const text = readFileSync(join(root, file), 'utf8'); + assert.match(text, /not found - tried .*CLAUDE_PLUGIN_ROOT.*claude\/skills.*plugin cache/, + `${file} must say "not found - tried " — a failed lookup is not evidence about install state`); + }); +}