diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a174ed4f..dc88ccc5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/plugins/context-guard/.claude-plugin/plugin.json b/plugins/context-guard/.claude-plugin/plugin.json index 46692570b..fdb3fc4a0 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.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", diff --git a/plugins/context-guard/CHANGELOG.md b/plugins/context-guard/CHANGELOG.md index 77bf98840..bf0771431 100644 --- a/plugins/context-guard/CHANGELOG.md +++ b/plugins/context-guard/CHANGELOG.md @@ -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 diff --git a/plugins/context-guard/skills/setup/SKILL.md b/plugins/context-guard/skills/setup/SKILL.md index 580643481..f131a63b1 100644 --- a/plugins/context-guard/skills/setup/SKILL.md +++ b/plugins/context-guard/skills/setup/SKILL.md @@ -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 diff --git a/plugins/context-guard/skills/setup/evals/evals.json b/plugins/context-guard/skills/setup/evals/evals.json index 4027b3f70..44e16d881 100644 --- a/plugins/context-guard/skills/setup/evals/evals.json +++ b/plugins/context-guard/skills/setup/evals/evals.json @@ -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", diff --git a/plugins/context-guard/skills/setup/reference/statusline-edit.md b/plugins/context-guard/skills/setup/reference/statusline-edit.md index 66194114c..be0a1b831 100644 --- a/plugins/context-guard/skills/setup/reference/statusline-edit.md +++ b/plugins/context-guard/skills/setup/reference/statusline-edit.md @@ -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 - -`` 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 /context-guard/bin/statusline-shim.sh` and - `bash /rate-limit-guard/bin/statusline-shim.sh`, in whatever order they appear, plus any - legacy `bash /…/statusline-tee.sh` prefix. -2. **A generated `sh -c` adapter**, when what remains is EXACTLY `sh -c ''` - 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 ''`, 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 @@ -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 ` finding nothing while `type -t ` 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 { @@ -120,33 +58,6 @@ Print the shell-wrapped variant instead when EITHER of these holds: } ``` -`` 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' ''` and confirm the output matches the renderer -byte-for-byte. The single-quoted argument reproduces exactly the quoting context the emitted -`sh -c ''` 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 ''`, 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 ''`, 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, @@ -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: `` 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: `` 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: diff --git a/plugins/context-guard/skills/setup/reference/unwrap-before-compose.md b/plugins/context-guard/skills/setup/reference/unwrap-before-compose.md new file mode 100644 index 000000000..5fdab03af --- /dev/null +++ b/plugins/context-guard/skills/setup/reference/unwrap-before-compose.md @@ -0,0 +1,109 @@ +# Unwrap before you compose — shared peel and wrap rules + +The shared, plugin-name-free half of the two statusline guard plugins' compose +rules, synced byte-identical between them by `scripts/sync-unwrap-before-compose.sh` +and registered in `scripts/cross-plugin-source-registry.txt`. The hub setup skill +supplies every concrete shim path for the printed edit. These rules target +machine-scope surfaces under `~/.claude/`, outside the repo-scope +retirement-manifest schema (ADR 0018, decision 6), so they stay prose and are +deduplicated here instead. + +## Unwrap before you compose + +`` 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 /context-guard/bin/statusline-shim.sh` and + `bash /rate-limit-guard/bin/statusline-shim.sh`, in whatever order they appear, plus any + legacy `bash /…/statusline-tee.sh` prefix. +2. **A generated `sh -c` adapter**, when what remains is EXACTLY `sh -c ''` + 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 ''`, 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. 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. + +## 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 ` finding nothing while `type -t ` 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. + +The hub prints this plugin's own shim path around `sh -c ''` when the guard +fires, and around the renderer verbatim when it does not. + +`` 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' ''` and confirm the output matches the renderer +byte-for-byte. The single-quoted argument reproduces exactly the quoting context the emitted +`sh -c ''` 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 ''`, 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 ''`, 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. diff --git a/plugins/rate-limit-guard/.claude-plugin/plugin.json b/plugins/rate-limit-guard/.claude-plugin/plugin.json index cc3115916..7f35bc111 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.7.24", + "version": "0.7.25", "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 e40bd44bc..6becca603 100644 --- a/plugins/rate-limit-guard/CHANGELOG.md +++ b/plugins/rate-limit-guard/CHANGELOG.md @@ -3,6 +3,15 @@ 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.7.25] + +### Changed + +- **setup: unwrap-before-compose matches context-guard, then shares the spoke.** The inline + shell-syntax guard treated bare quoting as a wrap trigger, the bug context-guard already + fixed with `type -P` / `type -t`. Peel and wrap rules now live in + `reference/unwrap-before-compose.md`, synced byte-identical from context-guard. + ## [0.7.24] ### Changed diff --git a/plugins/rate-limit-guard/skills/setup/SKILL.md b/plugins/rate-limit-guard/skills/setup/SKILL.md index 59bba7fc0..1bd9fd297 100644 --- a/plugins/rate-limit-guard/skills/setup/SKILL.md +++ b/plugins/rate-limit-guard/skills/setup/SKILL.md @@ -110,16 +110,11 @@ owned by `${CLAUDE_PLUGIN_ROOT}/reference/reader-contract.md`. marked clearly as the operator's to apply. The wiring target is the SHIM's fixed path, never `${CLAUDE_PLUGIN_ROOT}`, which is version-pinned and belongs in no operator file: - **Unwrap before you compose.** `` 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 /rate-limit-guard/bin/statusline-shim.sh` - and `bash /context-guard/bin/statusline-shim.sh`, in whatever order they appear, plus any - legacy `bash /…/statusline-tee.sh` prefix, and treat what remains as the renderer. - 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. + Read [`reference/unwrap-before-compose.md`](reference/unwrap-before-compose.md) now, before + composing: it owns the peel rules and the shell-syntax guard, shared byte-identical with + context-guard. Bare quoting is never a wrap trigger; `type -P` / `type -t` is how a builtin + renderer is detected. Composing without it is what produced `context -> rate -> rate -> + renderer` and the compounding `sh -c` wrap. Wrapping an existing statusline command (preserve the user's unwrapped command verbatim as the trailing arguments): @@ -144,10 +139,7 @@ owned by `${CLAUDE_PLUGIN_ROOT}/reference/reader-contract.md`. } ``` - Shell-syntax guard: the wrapped form passes the user's command as ARGV. It only works for - plain `executable arg…` commands. If the current command contains shell syntax (an inline env - assignment like `THEME=dark my-statusline`, a pipe, `&&`, `;`, or quoting), print the - shell-wrapped variant instead: + When the shared guard selects the shell-wrapped form: ```json { @@ -184,9 +176,10 @@ owned by `${CLAUDE_PLUGIN_ROOT}/reference/reader-contract.md`. } ``` - The shell-syntax guard applies UNCHANGED to this form: `` is the - innermost ARGV here too, so a command carrying shell syntax must be substituted as - `sh -c ''`, never raw. Substituting `THEME=dark my-statusline` raw + The shell-syntax guard in [`reference/unwrap-before-compose.md`](reference/unwrap-before-compose.md) + applies UNCHANGED to this form: `` 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 raw. 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: diff --git a/plugins/rate-limit-guard/skills/setup/evals/evals.json b/plugins/rate-limit-guard/skills/setup/evals/evals.json index c2c58d71c..06f431628 100644 --- a/plugins/rate-limit-guard/skills/setup/evals/evals.json +++ b/plugins/rate-limit-guard/skills/setup/evals/evals.json @@ -87,6 +87,58 @@ "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": "/rate-limit-guard:setup check\n\nMy statusLine command is already: bash ~/.claude/rate-limit-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 rate-limit-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": "/rate-limit-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/rate-limit-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" + ] + }, + { + "id": 10, + "name": "bare-quoting-is-not-a-wrap-trigger", + "prompt": "/rate-limit-guard:setup check\n\nI have never run this skill. My statusLine command is: my-statusline --format 'a | b' — the pipe is part of the format string, not a pipeline. The plugin is installed.", + "expected_output": "Prints the PLAIN wrapped form: bash ~/.claude/rate-limit-guard/bin/statusline-shim.sh my-statusline --format 'a | b'. The pipe is sealed inside a quoted argument, so it is one ordinary ARGV word and needs no adapter.", + "files": [], + "expectations": [ + "Emits the PLAIN wrapped form with no `sh -c` adapter", + "Does NOT treat the `|` as a control operator — the guard tests for syntax that is UNQUOTED and at the TOP LEVEL, and this pipe is neither", + "Does not justify a wrap by the presence of quotes — bare quoting is explicitly not a trigger", + "Reads `my-statusline` as an ordinary executable, so the not-an-executable trigger does not fire either" + ] + }, + { + "id": 11, + "name": "bare-builtin-renderer-gets-a-shell", + "prompt": "/rate-limit-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/rate-limit-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", + "Reaches that decision through the not-an-executable trigger, NOT through the shell-syntax trigger — the renderer carries no unquoted top-level shell syntax at all", + "Does not justify the wrap by the presence of quotes — bare quoting is explicitly not a trigger", + "Resolves builtin-ness the way the shim will (`type -P` finding nothing while `type -t` reports `builtin`), never by matching a hardcoded list of builtin names" + ] } ] } diff --git a/plugins/rate-limit-guard/skills/setup/reference/unwrap-before-compose.md b/plugins/rate-limit-guard/skills/setup/reference/unwrap-before-compose.md new file mode 100644 index 000000000..5fdab03af --- /dev/null +++ b/plugins/rate-limit-guard/skills/setup/reference/unwrap-before-compose.md @@ -0,0 +1,109 @@ +# Unwrap before you compose — shared peel and wrap rules + +The shared, plugin-name-free half of the two statusline guard plugins' compose +rules, synced byte-identical between them by `scripts/sync-unwrap-before-compose.sh` +and registered in `scripts/cross-plugin-source-registry.txt`. The hub setup skill +supplies every concrete shim path for the printed edit. These rules target +machine-scope surfaces under `~/.claude/`, outside the repo-scope +retirement-manifest schema (ADR 0018, decision 6), so they stay prose and are +deduplicated here instead. + +## Unwrap before you compose + +`` 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 /context-guard/bin/statusline-shim.sh` and + `bash /rate-limit-guard/bin/statusline-shim.sh`, in whatever order they appear, plus any + legacy `bash /…/statusline-tee.sh` prefix. +2. **A generated `sh -c` adapter**, when what remains is EXACTLY `sh -c ''` + 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 ''`, 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. 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. + +## 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 ` finding nothing while `type -t ` 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. + +The hub prints this plugin's own shim path around `sh -c ''` when the guard +fires, and around the renderer verbatim when it does not. + +`` 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' ''` and confirm the output matches the renderer +byte-for-byte. The single-quoted argument reproduces exactly the quoting context the emitted +`sh -c ''` 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 ''`, 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 ''`, 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. diff --git a/scripts/cross-plugin-source-registry.txt b/scripts/cross-plugin-source-registry.txt index 2431466ca..38e6e56e0 100644 --- a/scripts/cross-plugin-source-registry.txt +++ b/scripts/cross-plugin-source-registry.txt @@ -45,5 +45,8 @@ lib/check-retirements.sh # Dedicated check: scripts/sync-legacy-statusline-detect.sh --check (CI: legacy-statusline-detect-sync) skills/setup/reference/legacy-statusline-detect.md +# Dedicated check: scripts/sync-unwrap-before-compose.sh --check (CI: unwrap-before-compose-sync) +skills/setup/reference/unwrap-before-compose.md + # Dedicated check: scripts/sync-resolve-convention-home.sh --check (CI: resolve-convention-home-sync) lib/resolve-convention-home.sh diff --git a/scripts/sync-unwrap-before-compose.sh b/scripts/sync-unwrap-before-compose.sh new file mode 100755 index 000000000..57d50d701 --- /dev/null +++ b/scripts/sync-unwrap-before-compose.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Sync or verify the cross-plugin skills/setup/reference/unwrap-before-compose.md cluster. +# +# scripts/sync-unwrap-before-compose.sh copy the canonical file into each carrier +# scripts/sync-unwrap-before-compose.sh --check fail if any carrier differs from canonical +# scripts/sync-unwrap-before-compose.sh --check-bump fail if the canonical changed vs but a +# carrying plugin's manifest version did not +# scripts/sync-unwrap-before-compose.sh --print-manifest emit src and copies as data (for affected-tests) +# +# Canonical copy: plugins/context-guard/skills/setup/reference/unwrap-before-compose.md (see +# scripts/cross-plugin-source-registry.txt). The twin statusline guard plugins carry the shared, +# plugin-name-free half of their unwrap-before-compose and shell-syntax-guard rules here; the +# surfaces they classify are machine-scope (~/.claude/), so ADR 0018 decision 6 fixes their twin +# drift with this sync gate rather than the repo-scope retirement schema. +# +# The three modes live in scripts/lib/sync-cluster.sh, shared with the sibling +# sync-*.sh gates; this file supplies the unwrap-before-compose cluster's parameters. +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$script_dir/.." +# shellcheck source=lib/sync-cluster.sh +. "$script_dir/lib/sync-cluster.sh" + +sync_cluster_script="sync-unwrap-before-compose.sh" +src="plugins/context-guard/skills/setup/reference/unwrap-before-compose.md" +copies=(plugins/rate-limit-guard/skills/setup/reference/unwrap-before-compose.md) +sync_cluster_manifest_strip='/skills/*' +sync_cluster_noun="Canonical" +sync_cluster_carrier="carrying" +sync_cluster_sync_summary=0 + +mode="${1:-sync}" +base="" +# Raised here, not in the shared engine: bash prefixes a ${var:?} diagnostic with +# the path and line of the expansion, so the message has to come from the script +# the user actually ran. +[[ "$mode" == "--check-bump" ]] && base="${2:?usage: sync-unwrap-before-compose.sh --check-bump }" + +sync_cluster::run "$mode" "$base" diff --git a/scripts/sync-unwrap-before-compose.test.sh b/scripts/sync-unwrap-before-compose.test.sh new file mode 100755 index 000000000..42a95eed1 --- /dev/null +++ b/scripts/sync-unwrap-before-compose.test.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# Unit tests for sync-unwrap-before-compose.sh. Builds a tiny synthetic repo +# tree per scenario in a temp dir and invokes the script against it directly. +# The load-bearing case is "--check discriminates": a drift gate that reports +# clean whether or not the copies match is worse than no gate. +set -uo pipefail + +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SELF_DIR/sync-unwrap-before-compose.sh" +. "$SELF_DIR/test-git-helpers.sh" + +# shellcheck source=lib/test-harness.sh +. "$SELF_DIR/lib/test-harness.sh" + +CANONICAL="plugins/context-guard/skills/setup/reference/unwrap-before-compose.md" +COPY="plugins/rate-limit-guard/skills/setup/reference/unwrap-before-compose.md" + +canonical_v1() { + printf '# Unwrap before you compose\n\nshared spoke v1\n' +} +canonical_v2() { + printf '# Unwrap before you compose\n\nshared spoke v2\n' +} + +new_fixture() { + local dir + dir="$(mktemp -d)" + mkdir -p "$dir/scripts/lib" \ + "$dir/plugins/context-guard/skills/setup/reference" \ + "$dir/plugins/context-guard/.claude-plugin" \ + "$dir/plugins/rate-limit-guard/skills/setup/reference" \ + "$dir/plugins/rate-limit-guard/.claude-plugin" + cp "$SCRIPT" "$dir/scripts/sync-unwrap-before-compose.sh" + cp "$SELF_DIR/lib/sync-cluster.sh" "$dir/scripts/lib/sync-cluster.sh" + chmod +x "$dir/scripts/sync-unwrap-before-compose.sh" + printf '%s' "$dir" +} + +manifest() { + printf '{"name":"%s","version":"%s"}\n' "$2" "$3" >"$1/plugins/$2/.claude-plugin/plugin.json" +} + +base_fixture() { + local dir + dir="$(new_fixture)" + canonical_v1 >"$dir/$CANONICAL" + canonical_v1 >"$dir/$COPY" + manifest "$dir" context-guard 0.1.0 + manifest "$dir" rate-limit-guard 0.1.0 + printf '%s' "$dir" +} + +git_fixture() { + local fixture="$1" + git_init_test_repo "$fixture" || return 1 + git -C "$fixture" add -A + git -C "$fixture" commit -qm base + git -C "$fixture" rev-parse HEAD +} + +run_mode() ( + local fixture="$1" + shift + cd "$fixture" && bash scripts/sync-unwrap-before-compose.sh "$@" +) + +f="$(new_fixture)" +canonical_v1 >"$f/$CANONICAL" +printf '# drifted\n' >"$f/$COPY" +manifest "$f" context-guard 0.1.0 +manifest "$f" rate-limit-guard 0.1.0 +if out="$(run_mode "$f" 2>&1)" && cmp -s "$f/$CANONICAL" "$f/$COPY"; then + ok "sync makes the carrying copy byte-identical to the canonical" +else + fail "sync should copy the canonical into rate-limit-guard, got: $out" +fi +rm -rf "$f" + +f="$(base_fixture)" +if run_mode "$f" --check >/dev/null 2>&1; then + clean_verdict=pass +else + clean_verdict=fail +fi +printf '# drifted\n' >"$f/$COPY" +if run_mode "$f" --check >/dev/null 2>&1; then + drifted_verdict=pass +else + drifted_verdict=fail +fi +if [[ "$clean_verdict" != "$drifted_verdict" ]]; then + ok "--check discriminates: matching copies '$clean_verdict', drifted copies '$drifted_verdict'" +else + fail "--check returned '$clean_verdict' for BOTH matching and drifted copies" +fi +if [[ "$clean_verdict" == pass && "$drifted_verdict" == fail ]]; then + ok "--check passes on a matching cluster and fails on a drifted one" +else + fail "expected clean=pass drifted=fail, got clean=$clean_verdict drifted=$drifted_verdict" +fi +rm -rf "$f" + +f="$(base_fixture)" +printf '# drifted\n' >"$f/$COPY" +out="$(run_mode "$f" --check 2>&1)" || true +if [[ "$out" == *"$COPY"* && "$out" == *"$CANONICAL"* ]]; then + ok "the drift message names both the drifted copy and the canonical" +else + fail "drift message should name both paths, got: $out" +fi +rm -rf "$f" + +f="$(base_fixture)" +out="$(run_mode "$f" --print-manifest 2>&1)" +if [[ "$out" == *"src"*"$CANONICAL"* && "$out" == *"copy"*"$COPY"* ]]; then + ok "--print-manifest publishes src and copy, so affected-tests can derive the fan-out" +else + fail "--print-manifest should publish src and copy, got: $out" +fi +rm -rf "$f" + +f="$(base_fixture)" +if base="$(git_fixture "$f")"; then + canonical_v2 >"$f/$CANONICAL" + canonical_v2 >"$f/$COPY" + if run_mode "$f" --check-bump "$base" >/dev/null 2>&1; then + fail "--check-bump should fail when the canonical changed but no carrier version moved" + else + ok "--check-bump fails when the canonical changed but no carrier version moved" + fi + manifest "$f" rate-limit-guard 0.2.0 + manifest "$f" context-guard 0.2.0 + if run_mode "$f" --check-bump "$base" >/dev/null 2>&1; then + ok "--check-bump passes once the carrying plugins bumped" + else + fail "--check-bump should pass after both carriers bumped" + fi +else + fail "could not init a git fixture; --check-bump arms did not run" +fi +rm -rf "$f" + +test_harness::report