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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,24 @@ jobs:
BASE_REF: ${{ github.base_ref }}
run: scripts/sync-legacy-statusline-detect.sh --check-bump "origin/$BASE_REF"

unwrap-before-compose-sync:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Verify unwrap-before-compose cluster matches canonical
run: scripts/sync-unwrap-before-compose.sh --check
- name: Verify carrying plugins bumped when canonical changed
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/sync-unwrap-before-compose.sh --check-bump "origin/$BASE_REF"

resolve-convention-home-sync:
runs-on: ubuntu-24.04
timeout-minutes: 15
Expand Down Expand Up @@ -1819,6 +1837,7 @@ jobs:
- spawn-noise-sync
- check-retirements-sync
- legacy-statusline-detect-sync
- unwrap-before-compose-sync
- resolve-convention-home-sync
- resolve-convention-pattern-sync
- index-regen-sync
Expand Down
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.7.30",
"version": "0.7.31",
"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 \u2014 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",
Expand Down
9 changes: 9 additions & 0 deletions plugins/context-guard/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ 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.7.31]

### Changed

- **setup: extract unwrap-before-compose to a synced spoke.** Peel rules and the
shell-syntax guard move to `reference/unwrap-before-compose.md`, canonical here
and synced into rate-limit-guard so the twins cannot drift on quoting-as-trigger
again.

## [0.7.30]

### Changed
Expand Down
9 changes: 5 additions & 4 deletions plugins/context-guard/skills/setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,11 @@ zone bands, zones.json shape) are owned by
settings. Those branches already forbade printing wiring the operator cannot make run. When
this step does print, the wiring target is the SHIM's fixed path, never
`${CLAUDE_PLUGIN_ROOT}`. Read
[`reference/statusline-edit.md`](reference/statusline-edit.md) now, before composing: it owns
the peel rules, the shell-syntax guard, both JSON edit blocks, and the Windows note, and
composing without it is what produced `context -> rate -> rate -> renderer` and the
compounding `sh -c` wrap.
[`reference/unwrap-before-compose.md`](reference/unwrap-before-compose.md) for the peel
rules and the shell-syntax guard (shared with rate-limit-guard), then
[`reference/statusline-edit.md`](reference/statusline-edit.md) for this plugin's JSON
edit blocks and the Windows note. Composing without those is what produced
`context -> rate -> rate -> renderer` and the compounding `sh -c` wrap.
8. **Dotfiles tracking proposal**, the printed edit changes a durable user-scope file the operator
maintains. When the operator's home directory is managed by a dotfiles system (chezmoi, yadm, a
bare-repo setup, ...), surface the reminder to capture the `settings.json` change through that
Expand Down
2 changes: 1 addition & 1 deletion plugins/context-guard/skills/setup/evals/evals.json
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@
"id": 12,
"name": "bare-builtin-renderer-gets-a-shell",
"prompt": "/context-guard:setup check\n\nI have never run this skill. My statusLine command is: ulimit '-n' — I wrote it that way myself; the quotes are just habit. The plugin is installed.",
"expected_output": "Recognizes that the command word `ulimit` resolves as a shell builtin and not as an executable, so the plain wrapped form would reach `exec ulimit -n` and die with exit 127. Prints the shell-wrapped variant: bash ~/.claude/context-guard/bin/statusline-shim.sh sh -c 'ulimit '''-n''''.",
"expected_output": "Recognizes that the command word `ulimit` resolves as a shell builtin and not as an executable, so the plain wrapped form would reach `exec ulimit -n` and die with exit 127. Prints the shell-wrapped variant: bash ~/.claude/context-guard/bin/statusline-shim.sh sh -c 'ulimit '\\''-n'\\'''.",
"files": [],
"expectations": [
"Emits the SHELL-WRAPPED variant, not the plain wrapped one — `exec` cannot run a builtin, so the plain form would exit 127 on every refresh",
Expand Down
112 changes: 12 additions & 100 deletions plugins/context-guard/skills/setup/reference/statusline-edit.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,59 +10,17 @@ ineffective remediation those branches exist to withhold.

## Contents

- [Unwrap before you compose](#unwrap-before-you-compose)
- [Unwrap and wrap rules](#unwrap-and-wrap-rules)
- [The edit blocks](#the-edit-blocks)
- [The shell-syntax guard](#the-shell-syntax-guard)
- [Sibling shims compose by nesting](#sibling-shims-compose-by-nesting)
- [Windows note](#windows-note)

## Unwrap before you compose

`<current statusline command>` below means the operator's OWN
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, once that string is unescaped, ANY of the following
holds, that is an adapter a previous run printed, not the renderer. Unescape it back: drop the
leading `sh -c` and the outer quotes, then replace every `'\''` with `'`.

- **A. It is itself EXACTLY `sh -c '<single-quoted string>'`, nothing after the closing
quote.** A nested `sh -c` is always a layer some run added: an operator's own renderer is at
most one `sh -c` deep. Apply the same strictness here as to the outer shape, so two readers
peel the same number of layers.
- **B. It begins with a guard-shim prefix from rule 1.** This skill never puts a shim inside
an adapter, and an operator would not write one inside their own `sh -c`. Leaving it sealed
there hides it from rule 1, which strips only LEADING prefixes, and the composed wiring then
names that shim a second time.
- **C. It is a command the guard below would send for wrapping.** That is the only shape this
skill's own adapter ever carries.

Branches A and B must NOT inherit the guard's top-level scoping. Their evidence is the shape
of the carried string, not the syntax in it. Absent all three, the `sh -c` 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 shape stays ambiguous on purpose: a single `sh -c` over a merely-quoted command, which a
version of this skill that wrongly counted quoting as a trigger also emitted. Nothing in it
distinguishes that from an operator's own, so it is preserved. The cost is one spurious shell
per refresh; peeling on a guess costs a broken statusline.

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

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). 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.
## Unwrap and wrap rules

Read [`unwrap-before-compose.md`](unwrap-before-compose.md) now, before composing. It owns
the peel rules and the shell-syntax guard, shared byte-identical with rate-limit-guard.
Composing without it is what produced `context -> rate -> rate -> renderer` and the
compounding `sh -c` wrap. The JSON blocks below are this plugin's printed paths only.

## The edit blocks

Expand All @@ -89,27 +47,7 @@ No statusline configured (standalone minimal statusline):
}
```

## The shell-syntax guard

The wrapped form passes the user's command as ARGV, the shell that runs
the `statusLine` command splits the whole line into words and consumes its quotes, and the shim
`exec`s those words unchanged. It therefore only works for plain `executable arg…` commands. Test
the UNWRAPPED renderer, never the raw effective `command` string, the rules above run first.
Print the shell-wrapped variant instead when EITHER of these holds:

- It carries, UNQUOTED, at the top level, shell syntax no ARGV word can express: an inline env
assignment like `THEME=dark my-statusline`, a redirection, or any control operator (`|`, `|&`,
`&&`, `||`, `;`, `&`, a newline).
- **Its command word is not an executable**, a shell builtin, function, or alias, which `exec`
cannot run because there is no file to exec. `ulimit '-n'` is the standing example: `ulimit`
exists only as a builtin, so the plain wrapped form reaches `exec ulimit -n` and the statusline
dies with exit 127 on every refresh. Resolve it the way the shim will: `type -P <word>` finding nothing while `type -t <word>` reports `builtin`, `function`, or
`alias` is the test, not by matching a hardcoded list of builtin names.

This trigger is load-bearing precisely because it is *not* about syntax. Such a renderer often
carries none at all, and before the guard was scoped to real syntax the bare presence of quotes
wrapped it by accident. That accident was doing real work, and dropping it without this
replacement is what turns a working statusline into exit 127.
When the shared guard selects the shell-wrapped form:

```json
{
Expand All @@ -120,33 +58,6 @@ Print the shell-wrapped variant instead when EITHER of these holds:
}
```

`<escaped renderer>` is that same unwrapped renderer, POSIX-escaped for single-quote
embedding: 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: run
`printf '%s\n' '<escaped renderer>'` and confirm the output matches the renderer
byte-for-byte. The single-quoted argument reproduces exactly the quoting context the emitted
`sh -c '<escaped renderer>'` uses; a double-quoted wrapper would instead let the outer shell
expand any `$(...)` or backticks in the operator's own renderer before the check ever ran.

**Syntax inside a quoted argument does not count, and bare quoting is never itself a trigger.**
The quotes make it one ordinary ARGV word that reaches the renderer intact through the plain
wrapped form. So an operator's own `sh -c '<string>'`, the one shape rule 2 preserves, is
ALREADY a plain `executable arg…` command: `sh` is the executable, `-c` and the carried string
are two ordinary ARGV words. Substitute it VERBATIM.

For an input that is ITSELF `sh -c '<string>'`, rule 2 and this guard therefore never both wrap
it, and leave exactly one layer: rule 2 peels every generated layer before the guard runs, and
what rule 2 preserves is a renderer this guard declines. Do not generalize that to a layer count
for every input, the guard adds whatever the renderer genuinely needs, which is NONE for a plain
command and ONE for top-level syntax, and that one is a layer more when the operator's own
`sh -c` sits inside it. `sh -c 'ulimit -n' && echo ok` correctly prints TWO: the `&&` cannot be an
ARGV word, so the adapter is mandatory, and peeling the inner `sh -c` would strand the builtin.
What is invariant is that peel and wrap are inverses, which is what makes a re-run byte-identical
at whatever count the renderer needs. Firing on the quotes instead is what turned an operator's
`sh -c 'ulimit -n'` into `sh -c 'sh -c '\''ulimit -n'\'''`, one more shell on every refresh and
the same compounding rule 2 exists to prevent.

## Sibling shims compose by nesting

Sibling tees compose by nesting, each through its OWN shim, the tees are transparent wrappers,
Expand All @@ -168,9 +79,10 @@ installed but its shim is absent, print the single-shim form above and say that
}
```

The shell-syntax guard applies UNCHANGED to this form: `<current statusline command>` is the
innermost ARGV here too, so run the same test above on the same unwrapped renderer and substitute
whichever of the two forms it selects, never the raw string. Substituting `THEME=dark my-statusline` raw
The shell-syntax guard in [`unwrap-before-compose.md`](unwrap-before-compose.md) applies
UNCHANGED to this form: `<current statusline command>` is the innermost ARGV here too, so run
that test on the same unwrapped renderer and substitute whichever of the two forms it selects,
never the raw string. Substituting `THEME=dark my-statusline` raw
makes `THEME=dark` the executable, which fails `command not found` (127) instead of setting the
variable. The shim paths are the only part that nests; the innermost substitution rule never
changes:
Expand Down
Loading