diff --git a/plugins/context-guard/.claude-plugin/plugin.json b/plugins/context-guard/.claude-plugin/plugin.json index f02c6d8bbf..d14d82d748 100644 --- a/plugins/context-guard/.claude-plugin/plugin.json +++ b/plugins/context-guard/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "context-guard", - "version": "0.5.2", + "version": "0.5.3", "description": "Per-session context-window observability plus the first shipped consumer: a statusline wrapper tees each session's context_window fields to a per-session snapshot file, a zone resolver classifies usage into smart/acceptable/dumb bands (percentage bands plus window-class token bands, conservative-min combination, zones.json SSOT with shipped defaults), a reader contract fixes how consuming sessions interpret the snapshots, and zone-crossing hooks report once per transition into a worse zone across two channels — the continuation menu to the operator, who owns that choice, and to the model only the zone determination plus the counter-steer that a zone word is not a decay signal (advisory by default; an optional blocking mode gates new mutating work on a fresh dumb-zone snapshot with handoff-writing exempt), with a PostCompact hook persisting an evidence-degraded marker.", "author": { "name": "Melodic Software", diff --git a/plugins/context-guard/CHANGELOG.md b/plugins/context-guard/CHANGELOG.md index 325f55f7a3..ac017b336a 100644 --- a/plugins/context-guard/CHANGELOG.md +++ b/plugins/context-guard/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to the `context-guard` plugin. 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). +## [0.5.3] + +### Fixed + +- **`setup check` FAILs a pre-revision-3 installed shim instead of reporting INFO.** The rule + already described the defect accurately — a copy predating `# shim-revision: 3` picks the newest + tee by mtime alone and keeps teeing from an UNINSTALLED plugin for the whole orphan window — but + still classified it INFO because the statusline keeps rendering. Rendering is not the property + that matters; what the operator is running has a behavior defect, and INFO files it under a + heading operators are told they can defer. Classification now turns on the installed revision: + `>= 3` stays INFO, below 3 or unmarked is FAIL. + + **Existing installs need one `apply`.** The statusline runs the durable copy at + `~/.claude/context-guard/bin/statusline-shim.sh`, which a plugin update never overwrites, so an + operator who ran `apply` before revision 3 shipped keeps running the old shim until they re-run + it. Uninstalling first is the trap worth naming: the setup skill goes with the plugin while the + stale shim stays behind, leaving no in-product path to the remediation. Kept in step with the + identical `rate-limit-guard` change (#1866) — the two shims are a deliberate byte-identical + cluster, so their setup contracts must not drift apart. + ## [0.5.2] ### Fixed diff --git a/plugins/context-guard/skills/setup/SKILL.md b/plugins/context-guard/skills/setup/SKILL.md index 1af713ac8f..11a9e29663 100644 --- a/plugins/context-guard/skills/setup/SKILL.md +++ b/plugins/context-guard/skills/setup/SKILL.md @@ -48,12 +48,23 @@ zone bands, zones.json shape) are owned by Remediation: `apply`. - **Present and identical** — PASS. Nothing about it needs revisiting on a plugin update; that is the whole point of the shim. - - **Present but differing** — INFO, not FAIL: the installed copy is an older (or hand-edited) - revision, and it still resolves a tee, so the statusline keeps rendering. Do NOT report the - difference as harmless: a copy predating `# shim-revision: 3` picks the newest tee by mtime - alone, so it also resolves one left behind by an UNINSTALLED plugin and keeps teeing for the - whole orphan grace window. Report the shipped `# shim-revision:` marker against the installed - one, say which of the two behaviors the installed copy has, and offer `apply` as the refresh. + - **Present but differing** — classify by what the installed revision can still do, not by the + fact that it differs. Report the shipped `# shim-revision:` marker against the installed one + either way, say which of the two behaviors the installed copy has, and offer `apply` as the + refresh. + - Installed revision **>= 3** — INFO: an older-but-adequate or hand-edited copy that still + resolves the newest tee correctly. A refresh is housekeeping. + - Installed revision **< 3, or unmarked** — FAIL. Such a copy picks the newest tee by mtime + alone, so it also resolves one left behind by an UNINSTALLED plugin and keeps teeing for + the whole orphan grace window. The statusline keeps rendering, which is why this reads as + harmless and is not: it is a behavior defect in what the operator is running, and INFO + files it under a heading operators are told they can defer. + - **The migration matters more than the classification.** The durable copy at + `~/.claude/context-guard/bin/statusline-shim.sh` is what the statusline actually runs; a + plugin update never overwrites it. An operator who ran `apply` before revision 3 shipped + therefore keeps running the old shim until they re-run `apply` — and if they uninstall the + plugin first, this skill is gone and the stale shim keeps teeing with no remaining way to + reach the remediation. Say that in the finding, so the reason to act now is on screen. - **The SHIPPED source is absent** (no `${CLAUDE_PLUGIN_ROOT}/scripts/statusline-shim.sh`) — INFO, and skip the comparison entirely: this installed plugin version predates the shim (< 0.2.0). Never report the operator's installed copy as drifted on this branch. Remediation: diff --git a/plugins/rate-limit-guard/.claude-plugin/plugin.json b/plugins/rate-limit-guard/.claude-plugin/plugin.json index e9709d10b2..264e7a3de6 100644 --- a/plugins/rate-limit-guard/.claude-plugin/plugin.json +++ b/plugins/rate-limit-guard/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "rate-limit-guard", - "version": "0.4.2", + "version": "0.4.3", "description": "Shared rate-limit guard for loop lanes: a statusline wrapper tees the subscription rate-limit windows to a fixed machine-scope file, a StopFailure hook records rate-limit stops reactively, and a reader contract fixes how consuming sessions pause and resume.", "author": { "name": "Melodic Software", diff --git a/plugins/rate-limit-guard/CHANGELOG.md b/plugins/rate-limit-guard/CHANGELOG.md index 2dda2d98ca..8020e2e41f 100644 --- a/plugins/rate-limit-guard/CHANGELOG.md +++ b/plugins/rate-limit-guard/CHANGELOG.md @@ -3,6 +3,33 @@ All notable changes to the `rate-limit-guard` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.4.3] + +### Fixed + +- **The shim no longer runs an uninstalled plugin's tee (#1849).** `claude plugin uninstall` does + not delete the version directory: the plugins reference documents that updating or uninstalling + marks the previous version directory orphaned and removes it automatically 14 days later, so the + files — `scripts/statusline-tee.sh` included — stay on disk for that whole window. `resolve_tee()` + matched on the glob and mtime alone, so a removed plugin kept teeing and kept writing snapshots + with no signal to the operator. A candidate whose version directory carries the orphan marker is + now skipped, so uninstalling stops the tee at the next statusline refresh. The marking is + documented; the marker's on-disk spelling was measured (Claude Code 2.1.220, against a relocated + `CLAUDE_CONFIG_DIR`) and the shim's header records both, along with the fallback: should upstream + rename or drop the marker, resolution degrades to exactly what it does today — a stale tee, never + a broken statusline. The undocumented `installed_plugins.json` the header previously rejected + stays rejected. Port of the context-guard fix from #1787 / PR #1844; the two shims remain + deliberately unregistered as a byte-identical cluster (plugin name and header prose differ). + + **Existing installs need one `apply`.** The statusline runs the durable copy at + `~/.claude/rate-limit-guard/bin/statusline-shim.sh`, which a plugin update never overwrites, so + an operator who ran `apply` before this release keeps running the old shim — and keeps selecting + orphaned tees — until they re-run it. `setup check` previously reported any installed-vs-shipped + difference as INFO on the premise that an older revision "still resolves the newest tee"; that + premise is what this fix falsifies, so a copy below revision 3 is now a FAIL with the migration + stated in the finding. Uninstalling first is the trap worth naming: the setup skill goes with the + plugin while the stale shim stays behind, leaving no in-product path to the remediation. + ## [0.4.2] ### Changed diff --git a/plugins/rate-limit-guard/scripts/statusline-shim.sh b/plugins/rate-limit-guard/scripts/statusline-shim.sh index b326a05ecf..9e380b8a9d 100755 --- a/plugins/rate-limit-guard/scripts/statusline-shim.sh +++ b/plugins/rate-limit-guard/scripts/statusline-shim.sh @@ -35,8 +35,8 @@ # dir (the documented multi-account alias), so the shim would silently take the # no-tee path forever after they wired it. # -# RESOLUTION: the newest installed tee by MTIME across marketplaces, which is -# the most recently installed one — deliberately not a version sort, since +# RESOLUTION: the newest NON-ORPHANED tee by MTIME across marketplaces, which +# is the most recently installed one — deliberately not a version sort, since # version directory names sort lexically ("0.9.0" > "0.10.0") and carry no # guarantee of being semver at all. Marketplace directories named temp_* are # skipped: the cache holds transient temp_git_*/temp_local_* clones during @@ -47,12 +47,39 @@ # Verified empirically 2026-07-24: the cache copy does NOT preserve the source # file's timestamps — an installed tee carries its INSTALL time (measured: a # source committed at 03:08 installed at 12:38 carried 12:38), so "newest -# mtime" is "most recently installed" even against an orphaned older version -# directory lingering from a previous install. An alternative authoritative -# source exists — ~/.claude/plugins/installed_plugins.json maps -# @ to the current installPath — but it is an UNDOCUMENTED -# internal file carrying its own schema version, and reading it would put a jq -# spawn on every statusline refresh. Revisit only if upstream documents it. +# mtime" is "most recently installed". +# +# ORPHAN SKIP — why mtime alone is not enough: "When you update or uninstall a +# plugin, the previous version directory is marked as orphaned and removed +# automatically 14 days later. The grace period lets concurrent Claude Code +# sessions that already loaded the old version keep running without errors" +# (plugins reference, "Plugin caching and file resolution", +# https://code.claude.com/docs/en/plugins-reference, fetched 2026-07-30). +# UNINSTALL therefore leaves the tee on disk for ~14 days, and an mtime-only +# shim keeps executing it for that whole window: the operator removes the +# plugin and it keeps writing snapshots, with no signal that it is still +# running. A candidate whose version directory carries the orphan marker is +# skipped, so uninstalling stops the tee at the next statusline refresh. +# +# The MARKING is documented; the marker's on-disk spelling is not. Measured on +# Claude Code 2.1.220, 2026-07-30, against a relocated CLAUDE_CONFIG_DIR: an +# uninstall writes `/.orphaned_at` (epoch-ms) and leaves +# scripts/statusline-tee.sh in place. Reproducible on any live cache: every +# superseded version directory of a plugin carries the marker and the currently +# installed one does not. A directory can also be marker-less while merely +# STAGED — a newer version fetched for a pending update — so the marker's +# absence is not itself a claim of installation; mtime still picks the winner +# among unmarked candidates, as it did before. If upstream renames or drops the +# marker, the test finds nothing and resolution falls back to today's +# mtime-only behavior — a stale tee, never a broken statusline. +# +# An alternative authoritative source exists — ~/.claude/plugins/ +# installed_plugins.json maps @ to the current installPath +# — but it is an UNDOCUMENTED internal file carrying its own schema version, +# and reading it would put a jq spawn on every statusline refresh. The orphan +# marker is preferred over it on both counts: the behavior it reports is +# documented, and the test is a builtin. Revisit only if upstream documents the +# file. # # Pure builtins — glob + `-nt` tests, no subprocesses — because the statusline # command runs on every session event and on the refresh interval. @@ -61,7 +88,7 @@ set -uo pipefail PLUGIN_NAME="rate-limit-guard" -# shim-revision: 2 +# shim-revision: 3 # Bumped whenever this file's content changes. The installed copy is a # BYTE-IDENTICAL copy of this file, so /rate-limit-guard:setup check compares # the two directly; the marker is for humans reading the installed copy. @@ -86,6 +113,9 @@ resolve_tee() { rest="${cand#"$cache"/}" mkt="${rest%%/*}" [[ "$mkt" == temp_* ]] && continue + # Uninstalled or superseded: the version directory is marked orphaned and + # lingers ~14 days. Running it would keep an uninstalled plugin writing. + [[ -e "${cand%/scripts/statusline-tee.sh}/.orphaned_at" ]] && continue if [[ -z "$RESOLVED" || "$cand" -nt "$RESOLVED" ]]; then RESOLVED="$cand" fi diff --git a/plugins/rate-limit-guard/scripts/statusline-shim.test.sh b/plugins/rate-limit-guard/scripts/statusline-shim.test.sh index 4abf76f68f..cecf496dcc 100755 --- a/plugins/rate-limit-guard/scripts/statusline-shim.test.sh +++ b/plugins/rate-limit-guard/scripts/statusline-shim.test.sh @@ -4,8 +4,10 @@ # # Proves: (a) RESOLUTION — the newest installed tee by mtime wins across # version directories whose names do NOT sort lexically (0.10.0 vs 0.9.0), -# transient temp_* marketplace clones are skipped, the marketplace directory -# name is never assumed, and other plugins' trees are ignored; (b) +# transient temp_* marketplace clones are skipped, version directories marked +# orphaned by an update or an uninstall are skipped even when they are the +# newest, the marketplace directory name is never assumed, and other plugins' +# trees are ignored; (b) # TRANSPARENCY — the wrapped statusline command receives the stdin bytes and # its stdout and exit code pass through, whether the tee was found or not; # (c) the NO-TEE paths — a missing tee degrades to running the wrapped command @@ -80,6 +82,16 @@ EOF printf '%s' "$dir/statusline-tee.sh" } +# Mark a planted tee's VERSION directory the way Claude Code marks one on an +# update or an uninstall: `.orphaned_at` holding an epoch-ms timestamp +# (measured on Claude Code 2.1.220). The directory keeps its files for ~14 days +# afterwards, which is the window this marker exists to close. +# $1 = the tee path returned by plant_tee +orphan_tee() { + local tee="$1" + printf '1785448003467' >"${tee%/scripts/statusline-tee.sh}/.orphaned_at" +} + # Wrapped statusline stand-in: echoes the stdin it received, prints a fixed # line, and exits with a chosen code. make_wrapped() { @@ -240,6 +252,33 @@ ERR="$(cat "$errfile")" rm -f "$errfile" assert_contains "$ERR" "TEE:home" "an empty CLAUDE_CONFIG_DIR falls back to \$HOME/.claude" +# --- 15. UNINSTALLED plugin: the orphaned tee is not executed --------------- +# `claude plugin uninstall` writes .orphaned_at into the version directory and +# leaves the files there for ~14 days. Without the marker check the shim keeps +# finding and running the removed plugin's tee for that whole window. +H8="$WORK/h8" +GONE="$(plant_tee "$H8" "melodic-software" "rate-limit-guard" "0.4.0" "uninstalled")" +orphan_tee "$GONE" +make_wrapped "$H8/render.sh" 0 +run "$H8" bash "$H8/render.sh" +assert_eq "" "$ERR" "an uninstalled plugin's orphaned tee is not executed" +assert_contains "$OUT" "RENDER" "uninstalled plugin still leaves the statusline running" +assert_eq "0" "$RC" "uninstalled plugin preserves the wrapped exit code" + +# --- 16. orphaned loses to an installed sibling even when it is newer ------- +# The update path: the superseded directory is marked orphaned. Give it the +# newer mtime so only the marker can decide. +H9="$WORK/h9" +KEEP="$(plant_tee "$H9" "mkt" "rate-limit-guard" "0.4.0" "installed")" +DEAD="$(plant_tee "$H9" "mkt" "rate-limit-guard" "0.3.9" "orphaned")" +orphan_tee "$DEAD" +touch -t 202001010000 "$KEEP" +touch -t 203001010000 "$DEAD" +make_wrapped "$H9/render.sh" 0 +run "$H9" bash "$H9/render.sh" +assert_contains "$ERR" "TEE:installed" "orphaned version directory skipped even when newest by mtime" +assert_not_contains "$ERR" "TEE:orphaned" "the orphaned tee does not also run" + echo echo "passed: $PASS failed: $FAIL" ((FAIL == 0)) diff --git a/plugins/rate-limit-guard/skills/setup/SKILL.md b/plugins/rate-limit-guard/skills/setup/SKILL.md index 359ec0b3b0..3e02fc006a 100644 --- a/plugins/rate-limit-guard/skills/setup/SKILL.md +++ b/plugins/rate-limit-guard/skills/setup/SKILL.md @@ -68,9 +68,23 @@ owned by `${CLAUDE_PLUGIN_ROOT}/reference/reader-contract.md`. Remediation: `apply`. - **Present and identical** — PASS. Nothing about it needs revisiting on a plugin update; that is the whole point of the shim. - - **Present but differing** — INFO, not FAIL: the installed copy is an older (or hand-edited) - revision that still resolves the newest tee. Report the shipped `# shim-revision:` marker - against the installed one and offer `apply` as the refresh. + - **Present but differing** — classify by what the installed revision can still do, not by the + fact that it differs. Report the shipped `# shim-revision:` marker against the installed one + either way, and offer `apply` as the refresh. + - Installed revision **>= 3** — INFO: an older-but-adequate or hand-edited copy that still + resolves the newest tee correctly. A refresh is housekeeping. + - Installed revision **< 3, or unmarked** — FAIL. Revision 3 is the first that skips a + candidate whose version directory carries the orphan marker; every earlier revision keeps + teeing from an UNINSTALLED plugin's directory for the ~14 days before Claude Code reaps it, + writing snapshots the operator has no reason to expect. That is a behavior defect in the + running statusline, not drift, and INFO would leave it sitting under a heading operators + are told they can defer. + - **The migration matters more than the classification.** The durable copy at + `~/.claude/rate-limit-guard/bin/statusline-shim.sh` is what the statusline actually runs; + a plugin update never overwrites it. An operator who ran `apply` before 0.4.3 therefore + keeps running the old shim until they re-run `apply` — and if they uninstall the plugin + first, this skill is gone and the stale shim keeps teeing with no remaining way to reach + the remediation. Say that in the finding, so the reason to act now is on screen. - **The SHIPPED source is absent** (no `${CLAUDE_PLUGIN_ROOT}/scripts/statusline-shim.sh`) — INFO, and skip the comparison entirely: this installed plugin version predates the shim (< 0.2.0). Never report the operator's installed copy as drifted on this branch. Remediation: