Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/context-guard/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "context-guard",
"version": "0.4.3",
"version": "0.4.4",
"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 inject continuation guidance once per transition into a worse zone (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",
Expand Down
26 changes: 26 additions & 0 deletions plugins/context-guard/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@ 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.4.4]

### Fixed

- **The shim no longer runs an uninstalled plugin's tee (#1787).** `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.
- **`setup` no longer adds an `sh -c` layer per run (#1787).** "Unwrap before you compose" stripped
guard-shim prefixes but not the `sh -c '<escaped …>'` adapter the skill's own shell-syntax guard
prints, so a rerun read that adapter as the renderer, found shell syntax in it, and wrapped it
again — one layer per run. Unwrapping is now two rules applied until a pass strips nothing, so
several layers from earlier reruns collapse rather than only the outermost, and a rerun over
already-correct wiring prints byte-identical wiring. The adapter rule establishes provenance
before it peels: because the skill emits an adapter only for a renderer carrying shell syntax, an
`sh -c` over a string carrying none is the operator's own and is preserved — peeling
`sh -c 'ulimit -n'` would leave the shim `exec`-ing a shell builtin with no shell, exiting 127.

## [0.4.3]

### Fixed
Expand Down
48 changes: 39 additions & 9 deletions plugins/context-guard/scripts/statusline-shim.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -46,12 +46,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
# <plugin>@<marketplace> 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 `<version-dir>/.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 <plugin>@<marketplace> 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.
Expand All @@ -60,7 +87,7 @@ set -uo pipefail

PLUGIN_NAME="context-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 /context-guard:setup check compares the
# two directly; the marker is for humans reading the installed copy.
Expand All @@ -85,6 +112,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
Expand Down
43 changes: 41 additions & 2 deletions plugins/context-guard/scripts/statusline-shim.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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" "context-guard" "0.4.2" "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" "context-guard" "0.4.2" "installed")"
DEAD="$(plant_tee "$H9" "mkt" "context-guard" "0.4.1" "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))
37 changes: 29 additions & 8 deletions plugins/context-guard/skills/setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,34 @@ zone bands, zones.json shape) are owned by
and belongs in no operator file:

**Unwrap before you compose.** `<current statusline command>` below means the operator's OWN
renderer, never the raw effective `command` string. Before substituting, strip every leading
guard-shim invocation from that string — `bash <path>/context-guard/bin/statusline-shim.sh` and
`bash <path>/rate-limit-guard/bin/statusline-shim.sh`, in whatever order they appear — plus any
legacy `bash <plugin-cache>/…/statusline-tee.sh` prefix, and treat what remains as the renderer.
renderer, never the raw effective `command` string. Recover it by peeling off the wrapping this
skill itself prints, applying BOTH rules repeatedly until a pass strips nothing:

1. **Guard-shim prefixes** — every leading `bash <path>/context-guard/bin/statusline-shim.sh` and
`bash <path>/rate-limit-guard/bin/statusline-shim.sh`, in whatever order they appear, plus any
legacy `bash <plugin-cache>/…/statusline-tee.sh` prefix.
2. **A generated `sh -c` adapter** — when what remains is EXACTLY `sh -c '<single-quoted string>'`
with nothing after the closing quote, AND the string it carries itself contains shell syntax,
that is the shell-syntax adapter a previous run printed, not the renderer. Unescape it back:
drop the leading `sh -c` and the outer quotes, then replace every `'\''` with `'`.

Both conditions establish provenance, and the second is load-bearing. This skill emits the
adapter ONLY for a renderer that carries shell syntax (the guard below), so an `sh -c` over a
string carrying NONE was written by the operator and must be preserved: peeling
`sh -c 'ulimit -n'` to `ulimit -n` would leave the shim `exec`-ing a shell builtin that no
longer has a shell, and the statusline would exit 127 instead of rendering. A trailing word
(`sh -c '…' extra`) makes it a real command, not an adapter — leave that alone too.

One pass is not enough: an operator may already carry several layers from earlier reruns, and a
single peel over three layers leaves three.

Substituting the raw string instead is what produces `context → rate → rate → renderer` when the
sibling plugin was configured first, or a doubled self-wrap on a re-run: each duplicated tee runs
and writes on EVERY refresh and costs another 0.6–0.9 s (below). Unwrapping also makes the
printed edit idempotent — re-running `check` on already-correct wiring prints the wiring it
already has.
and writes on EVERY refresh and costs another 0.6–0.9 s (below). Skipping rule 2 compounds the
shell-syntax guard instead — the leftover adapter still contains shell syntax, so it is wrapped in
ANOTHER `sh -c` layer, one more on every run. Unwrapping both makes the printed edit idempotent:
re-running `check` on already-correct wiring prints byte-identical wiring, with exactly one shim
invocation per plugin and at most one `sh -c` layer.

Wrapping an existing statusline command (preserve the user's unwrapped command verbatim as the
trailing arguments):
Expand Down Expand Up @@ -182,7 +201,9 @@ zone bands, zones.json shape) are owned by
replace every `'` in it with `'\''` before substituting (then JSON-escape the whole `command`
string as usual). Show the final, fully escaped line — never hand the operator a template with
raw quotes left to fix. Verify your printed edit round-trips: mentally unquote it back and
confirm it reproduces the original command byte-for-byte.
confirm it reproduces the original command byte-for-byte. Print this variant only when the
UNWRAPPED renderer still carries shell syntax — an already-adapted command reaching this step
with its `sh -c` layer intact is rule 2 above having been skipped, and wrapping it adds a layer.

Sibling tees compose by nesting, each through its OWN shim — the tees are transparent wrappers,
so the innermost command still owns stdout and the exit code. Print this form only when
Expand Down
26 changes: 26 additions & 0 deletions plugins/context-guard/skills/setup/evals/evals.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,32 @@
"Does not present the two steps as order-independent",
"Notes that uninstalling the plugin itself is safe on its own — the shim degrades to running the wrapped command"
]
},
{
"id": 8,
"name": "rerun-does-not-compound-the-sh-c-wrap",
"prompt": "/context-guard:setup check\n\nMy statusLine command is already: bash ~/.claude/context-guard/bin/statusline-shim.sh sh -c 'THEME=dark my-statusline --flag' — I ran this skill once before and applied what it printed. Both the shim and the plugin are installed.",
"expected_output": "Unwraps the shim prefix AND the previously-generated sh -c adapter back to THEME=dark my-statusline --flag, then re-applies the shell-syntax guard to that renderer, printing wiring that carries exactly one sh -c layer — byte-identical to the wiring already in settings.json, so the operator has nothing to apply.",
"files": [],
"expectations": [
"Treats the existing `sh -c '...'` as a previously-generated adapter to unwrap, never as the operator's own renderer",
"Prints exactly ONE `sh -c` layer and one context-guard shim invocation — never a nested `sh -c 'sh -c '\\''...'\\'''`",
"Recovers `THEME=dark my-statusline --flag` byte-for-byte, unescaping `'\\''` back to `'`",
"Reports the wiring as already correct rather than presenting an identical line as a change to apply"
]
},
{
"id": 9,
"name": "genuine-sh-c-renderer-is-not-peeled",
"prompt": "/context-guard:setup check\n\nI have never run this skill. My statusLine command is: sh -c 'ulimit -n' — I wrote that myself because ulimit is a shell builtin and will not run any other way. The plugin is installed.",
"expected_output": "Treats sh -c 'ulimit -n' as the operator's OWN renderer, not a generated adapter, because the string it carries has no shell syntax and this skill only ever emits the adapter for a renderer that has some. Wraps it intact: bash ~/.claude/context-guard/bin/statusline-shim.sh sh -c 'ulimit -n'.",
"files": [],
"expectations": [
"Does NOT peel the `sh -c` layer — the carried string `ulimit -n` contains no shell syntax, so the adapter cannot have been generated by this skill",
"Preserves `sh -c 'ulimit -n'` verbatim as the wrapped command, so the shim never `exec`s a shell builtin with no shell",
"Names the provenance test (adapter is emitted only for a renderer carrying shell syntax) rather than peeling any `sh -c` on sight",
"Prints exactly one shim invocation and exactly one `sh -c` layer"
]
}
]
}