diff --git a/plugins/context-guard/.claude-plugin/plugin.json b/plugins/context-guard/.claude-plugin/plugin.json index 266f5ad25..8684cfb70 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.64", + "version": "0.7.65", "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 aa619369b..71fc21ebb 100644 --- a/plugins/context-guard/CHANGELOG.md +++ b/plugins/context-guard/CHANGELOG.md @@ -5,6 +5,13 @@ 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.65] + +### Changed + +- hooks: `zone-crossing-inject.sh` skips the zone resolver when nothing it reads has moved. A `$STATE_DIR/$SESSION.seen` mark, stamped with a redirection and compared with `-nt` (both builtins), records the inputs behind the last COMPLETED resolve; when the snapshot, `zones.json` and the compaction marker are all no newer than it, the fire exits before starting a process. The mark moves only after the markers persist, so a resolver failure, an `unknown` reading and a failed marker write are each retried. The envelope parse now uses `hook::jq_fields`' builtin parser on a payload within its proof ceiling and keeps the single here-string `jq` above it, because the helper's oversize fallback reads through a process substitution and costs four process creations against that `jq`'s two. Process creations under a Windows job object (5 reps, identical across reps; the subject's own floor is 3): small envelope, first fire 11 → 9, repeat with nothing moved 9 → **3**, snapshot rewritten 9 → 7; 150 KB batch payload, 11 → 11, 9 → **5**, 9 → 9. No cell is worse than before. Median wall for the small repeat fire, on a host whose timings are bimodal, 1,448 ms → 237 ms. The one failure mode: a snapshot written DURING a resolve is marked as seen, so its crossing waits for the next statusline render, since the window is the resolve rather than an mtime tick, and a missed crossing is late, never lost, because skipping only ever chooses silence. Crossing messages are byte-identical, asserted against a control session driven through the same zone sequence with no skipped fire. The per-batch budgets the contract test pins move with the paths: the steady fire now spawns nothing (0 commands, 0 process creations, 1 program launch) and a resolving fire spawns the resolver alone (1 command, 2 process creations, 3 program launches). +- hooks: the same skip also requires EXISTENCE parity, not mtimes alone. The mark carries one line recording whether `zones.json` and the compaction marker existed behind the last completed resolve, read back with the `read` builtin, and the skip is taken only when the three `-nt` tests are false and those flags still match; a mark with no readable line never takes it. `-nt` cannot see a removal, so deleting an override or the compaction marker previously read as nothing having moved and left the stale zone in place until an unrelated snapshot write. Both process budgets are unchanged. + ## [0.7.64] ### Changed diff --git a/plugins/context-guard/README.md b/plugins/context-guard/README.md index 6dc4faa11..181b18ca7 100644 --- a/plugins/context-guard/README.md +++ b/plugins/context-guard/README.md @@ -160,6 +160,44 @@ the older command-position budget, so a redirection moved back inside a substitu rather than quietly doubling a call site. Where `strace` is unavailable that assertion skips and the command-position budget still runs. +#### Skipping the resolve when nothing moved + +Three files outside the hook decide everything it does: the per-session snapshot, the optional +`zones.json`, and the compaction marker. When none is newer than the `.seen` mark the last +completed resolve left, and the two optional ones still exist or are still absent exactly as that +mark's own line records them, the fire cannot reach a different answer, and the hook exits through +builtins alone. The existence line is what an mtime comparison cannot supply: a removed file is +never newer than anything, so without it, deleting `zones.json` or the compaction marker read as +nothing having moved. A mark carrying no readable line never takes the skip. The envelope parse had +to become free for any of this to mean anything, so a payload +within `hook::jq_fields`' proof ceiling is parsed by the library's builtin JSON parser, and one +above it keeps the single here-string `jq` described below. + +Measured as process creations under a Windows job object, which counts every descendant; 5 reps per +cell, identical across reps. The subject is the hooks.json row run through `usr/bin/bash.exe -c`, +whose own floor is 3: the `-c` shell, `env`, and the shell the script's shebang starts. + +| Fire | Payload | Creations before | After | +|---|---|---|---| +| First, resolves | small envelope | 11 | 9 | +| Repeat, nothing moved | small envelope | 9 | **3** | +| Snapshot rewritten | small envelope | 9 | 7 | +| First, resolves | 150 KB batch | 11 | 11 | +| Repeat, nothing moved | 150 KB batch | 9 | **5** | +| Snapshot rewritten | 150 KB batch | 9 | 9 | + +No cell is worse than before, which is what the size test on the envelope parse buys: the helper's +fallback reads through a process substitution and costs four creations on an oversize payload +against two for the here-string `jq`, so only the small arm goes through the helper. Wall clock on +this host is bimodal and is reported only for the row it dominates: the small repeat fire's median +fell from 1,448 ms to 237 ms. + +The one failure mode is a snapshot written DURING a resolve. The mark is stamped after the resolve +completes, so that write counts as seen and its crossing waits for the next statusline render; the +window is the resolve, not an mtime tick. A missed crossing is therefore late, never lost, and the +converse cannot happen: skipping only ever chooses silence, so no arrangement of timestamps can +manufacture an injection the full path would not have made. + #### The cost this pass added: a temp file on payloads over 64KiB The saving is not free, and the charge is disk rather than CPU. Two of the five removed process diff --git a/plugins/context-guard/hooks/zone-crossing-inject.sh b/plugins/context-guard/hooks/zone-crossing-inject.sh index 8bea04acd..eac2913c8 100755 --- a/plugins/context-guard/hooks/zone-crossing-inject.sh +++ b/plugins/context-guard/hooks/zone-crossing-inject.sh @@ -92,6 +92,21 @@ # per-tool dedupe needed; UserPromptSubmit covers turns that begin without a # prior batch (fresh prompt after idle). # +# UNCHANGED INPUT, NO WORK. Three files outside this hook decide everything +# below: the per-session snapshot the statusline tee writes, the optional +# zones.json override, and the compaction marker. When none of them is newer +# than the mark left by the last completed resolve, this fire would repeat that +# resolve's decision exactly, and that decision is already persisted — so the +# hook exits before starting a single process. A third marker, +# `$STATE_DIR/$SESSION.seen`, carries the mark in its mtime alone, stamped with +# a redirection and compared with `-nt`, both builtins. The mark moves only +# after a resolve that persisted, so a resolver failure, an `unknown` reading +# and a failed marker write each leave it where it was and are retried. The +# residual is stated at the gate itself: a snapshot written DURING a resolve is +# marked as seen and its crossing waits for the next write, while a spurious +# injection is impossible in the other direction, because skipping only ever +# chooses silence. +# # State root: ${CLAUDE_PLUGIN_DATA} (plugin-private runtime state, NOT part # of the reader contract seam), falling back to ~/.claude/context-guard/state # when the harness doesn't export it. @@ -155,61 +170,76 @@ RESOLVER="$CG_DIR/../scripts/context-zone.sh" INPUT="" cg::read_payload_to INPUT || exit 0 -# ONE jq for the whole payload rather than one per field. hook::jq_field spawns -# a jq per call and this hook needs two fields; the payload is read once and -# both fields come back as two lines in a FIXED ORDER (event, then session). An -# absent field yields an empty line, which is what a per-field `// empty` plus -# non-empty test yields too. `gsub("\r";"")` is carried over from -# hook::jq_field for the Windows carriage-return case. +# ONE PASS over the whole envelope, and through the shared helper rather than a +# jq of this hook's own. hook::jq_fields answers `.hook_event_name` and +# `.session_id` — top-level keys carrying plain strings — from its BUILTIN JSON +# parser, so an ordinary envelope is parsed with no process at all. That is +# what lets the unchanged-snapshot skip below exit having started nothing: the +# skip still needs the session id, so a parse that cost a process would put a +# floor of one under every fire. # -# Not regex-extracted: a PostToolBatch payload carries every serialized tool -# result, so a pattern for these fields would be matching against tool output -# rather than against the envelope. post-compact-mark.sh's regex path is safe -# for its own payload shape; this one keeps jq as the parser. +# THE SIZE TEST IS NOT A STYLE CHOICE. The helper falls back to jq whenever it +# cannot PROVE the builtin answer is jq's, and one of those cases is a payload +# past the parser's ceiling — which a PostToolBatch payload carrying every +# serialized tool result clears routinely. That fallback reads through a +# process substitution, and measured on this repo's Windows host it costs FOUR +# process creations against TWO for the single here-string jq below. So the +# oversize arm keeps that jq, and only the small arm — where the helper is +# free — goes through the helper. The branch is what makes this change cost +# nothing on any payload instead of buying the small case at the large one's +# expense. +# +# 65536 mirrors hook::_json_split's own proof ceiling. Drift is benign in both +# directions: a payload the helper would have proven merely pays the here-string +# jq, and a payload past a ceiling this test missed reaches the helper, which +# refuses it and falls back to the same jq through a costlier route. Neither +# changes an answer. +# +# `<<<` IS NOT A PIPE, and that is the oversize arm's known cost. Bash 5.1+ +# delivers a here-string through a pipe only while it fits the pipe buffer; at +# or above 64KiB it spills to a temp file (`/tmp/sh-thd.*`) and hands jq that +# fd, which Defender then scans on the very hosts this hook is tuned for. The +# trade stands because a process creation on those hosts is the larger cost by +# an order of magnitude; the plugin README's hook-cost section carries the +# measured counts. # # REDIRECTIONS GO ON THE GROUP, NOT INSIDE THE SUBSTITUTION — see the -# REDIRECTION PLACEMENT note at the top of this file. `printf '%s' "$INPUT" | -# jq` cost three process creations to run one jq: the subshell the substitution -# opens, a child for the pipeline's left-hand side (a `printf` BUILTIN — a whole -# process to hand over a string this shell already holds), and the child that -# becomes jq. Hoisting `<<<` and `2>/dev/null` onto the enclosing group leaves -# jq a bare simple command inside the substitution, and the extraction costs -# one process instead of three. +# REDIRECTION PLACEMENT note at the top of this file. Inside, `<<<` and +# `2>/dev/null` each defeat the fork elision and bill a second process for one +# jq. What jq sees is unchanged either way: the group's stderr redirect +# suppresses exactly what jq's own did, stdout is still captured, a nonzero jq +# status still propagates out of the group, and jq parses JSON, so the newline +# `<<<` appends changes nothing. # -# What jq sees is unchanged: the group's stderr redirect suppresses exactly what -# jq's own did, the substitution still captures stdout, a nonzero jq status still -# propagates out of the group, and jq parses JSON, so the newline `<<<` appends -# changes nothing. +# Not regex-extracted: a PostToolBatch payload carries every serialized tool +# result, so a pattern for these fields would be matching against tool output +# rather than against the envelope. post-compact-mark.sh's regex path is safe +# for its own payload shape; this one keeps a real parser. # -# ONE THING DOES CHANGE, and it is disclosed rather than buried. `<<<` is not a -# pipe. Bash 5.1+ delivers a here-string through a pipe only while it fits in -# the pipe buffer; at or above 64KiB it spills the string to a temp file -# (`/tmp/sh-thd.*`, measured here: 60,000 bytes stays in the pipe, 65,536 opens -# the file) and hands jq that fd. The `printf | jq` form this replaced never -# touched disk at any size. Output is byte-identical either way, but a -# PostToolBatch payload carrying every serialized tool result routinely clears -# 64KiB, so a large fire now writes and reads a temp file it did not before. -# That is a real cost on the very hosts this change is for: #3508's Windows -# machines run Defender real-time protection, which scans temp-file writes, and -# the 0.4.8 measurement in the plugin README already attributes 22.0 s on that -# platform to it. The trade taken is one guaranteed process creation per fire -# against disk I/O on the fires that exceed the buffer; the README's hook-cost -# section states it. Feeding the hook's stdin straight to jq would avoid both, -# but that means giving up payload.sh's bounded drain loop — see the note there. -{ FIELDS=$(jq -r '(.hook_event_name // ""), (.session_id // "") | gsub("\r";"")'); } 2>/dev/null <<<"$INPUT" -# jq writes CRLF line endings on this host, and command substitution strips only -# the TRAILING one, so with two lines the separator's carriage return survives -# into the split and would ride along on the event name. The single-field helper -# never saw this because its one and only line ending was the trailing one. -# gsub above has already removed any CR belonging to a field's value, so nothing -# left here is anything but jq's own terminators. -FIELDS=${FIELDS//$'\r'/} -EVENT=${FIELDS%%$'\n'*} -SESSION=${FIELDS#*$'\n'} -# No newline in FIELDS means jq emitted at most one line, so there is no -# session field to take, and the expansion above would otherwise hand back the -# event name. -[[ "$SESSION" != "$FIELDS" ]] || SESSION="" +# The helper arm is guarded by `if` rather than `|| exit 0`: on rc 1 (no jq) and +# rc 2 (a payload jq rejects) it leaves HOOK_JQ_FIELDS EMPTY, and indexing that +# under `set -u` would abort where this hook must fail open. An absent field +# arrives as the empty string on both arms, which is what a per-field +# `// empty` plus a non-empty test yielded too. +EVENT="" +SESSION="" +if ((${#INPUT} > 65536)); then + { FIELDS=$(jq -r '(.hook_event_name // ""), (.session_id // "") | gsub("\r";"")'); } 2>/dev/null <<<"$INPUT" + # jq writes CRLF line endings on this host, and command substitution strips + # only the TRAILING one, so with two lines the separator's carriage return + # survives into the split and would ride along on the event name. gsub above + # has already removed any CR belonging to a field's value. + FIELDS=${FIELDS//$'\r'/} + EVENT=${FIELDS%%$'\n'*} + SESSION=${FIELDS#*$'\n'} + # No newline in FIELDS means jq emitted at most one line, so there is no + # session field to take, and the expansion above would otherwise hand back + # the event name. + [[ "$SESSION" != "$FIELDS" ]] || SESSION="" +elif hook::jq_fields "$INPUT" '.hook_event_name' '.session_id'; then + EVENT="${HOOK_JQ_FIELDS[0]}" + SESSION="${HOOK_JQ_FIELDS[1]}" +fi [[ -n "$EVENT" ]] || EVENT="PostToolBatch" hook::require_jq "$EVENT" "context-guard" "$INPUT" @@ -218,6 +248,90 @@ hook::require_jq "$EVENT" "context-guard" "$INPUT" # the state file below. [[ "$SESSION" =~ ^[A-Za-z0-9_-]+$ ]] || exit 0 +# silent-skip-ok: with neither CLAUDE_PLUGIN_DATA nor HOME there is no +# resolvable state root, and a `.`-relative fallback would key the last-seen +# zone to whatever directory the hook happened to start in — the once-per- +# transition contract cannot hold against state that moves with the working +# directory, so the hook would re-inject on every cd. Same doctrine +# post-compact-mark.sh applies to its marker path. +# +# Resolved AHEAD of the resolver because the skip below is keyed on a file in +# this directory. Nothing else moves with it: the resolver has no side effects, +# so a session with no state root now exits without starting it rather than +# after — same silence, one process less. +if [[ -n "${CLAUDE_PLUGIN_DATA:-}" ]]; then + STATE_DIR="$CLAUDE_PLUGIN_DATA/state" +elif [[ -n "${HOME:-}" ]]; then + STATE_DIR="$HOME/.claude/context-guard/state" +else + exit 0 +fi +STATE_FILE="$STATE_DIR/$SESSION.zone" +ARMED_FILE="$STATE_DIR/$SESSION.armed" +SEEN_FILE="$STATE_DIR/$SESSION.seen" +COMPACTED_FILE="" +[[ -n "${HOME:-}" ]] && COMPACTED_FILE="$HOME/.claude/context-guard/context/$SESSION.compacted" + +# THE UNCHANGED-INPUT SKIP. Everything below this line reads exactly three +# things the world outside this hook can move: the per-session snapshot the +# statusline tee writes, the optional zones.json override, and the compaction +# marker. (The zone words themselves also depend on wall-clock time, but only +# through the resolver's staleness window, whose one outcome is `unknown` — and +# `unknown` is silent and leaves state untouched, which is exactly what this +# skip does.) So when none of the three has moved since the last completed +# resolve, this fire cannot reach a different decision than the last one did, +# and the last one already persisted its markers. Exit before starting +# anything. +# +# `$STATE_DIR/$SESSION.seen` is the mark, stamped with a redirection rather +# than `touch(1)` so the steady path keeps costing zero processes. It records +# the inputs behind the last completed resolve twice over: in its own mtime, +# compared with `-nt`, which is a bash builtin and involves no stat(1) dialect; +# and in one line naming which of the two OPTIONAL inputs existed, read back +# with the `read` builtin, which starts nothing either. `-nt` is true when the +# left file exists and the right one does not, so a session with no mark yet, +# the first fire or one whose last fire failed to persist, never takes the skip. +# +# BOTH RECORDS ARE REQUIRED, because `-nt` only ever sees a file that is there +# getting newer. Deleting zones.json restores the shipped bands and deleting the +# compaction marker un-degrades the session, and neither move touches an mtime +# the mtime half can read: to it a file that is gone reads exactly like one that +# never changed. So the skip also demands that the existence flags still match, +# and a mark carrying no readable line, an older build's stamp or a write that +# failed after truncating, never takes the skip at all. +# +# A MISSING SNAPSHOT is not skippable: the `-e` test fails and the resolver +# runs and answers for it as it always has. +# +# THE ONE FAILURE MODE, stated. The mark is stamped AFTER the resolve +# completes, so a snapshot write that lands between the resolver's read and +# that stamp is recorded as already seen and its crossing is not reported. The +# window is the resolve itself, not an mtime tick. The statusline writes the +# snapshot again on its next render, so the next write catches the crossing up; +# the cost of a miss is a report one fire late, never a report that never +# comes. The converse cannot happen: skipping only ever chooses silence, so no +# arrangement of timestamps can manufacture an injection that the full path +# would not have made. +if [[ -n "${HOME:-}" && -e "$HOME/.claude/context-guard/context/$SESSION.json" ]] && + [[ ! "$HOME/.claude/context-guard/context/$SESSION.json" -nt "$SEEN_FILE" ]] && + [[ ! "$HOME/.claude/context-guard/zones.json" -nt "$SEEN_FILE" ]] && + [[ ! "$COMPACTED_FILE" -nt "$SEEN_FILE" ]]; then + seen_flags="" + IFS= read -r seen_flags <"$SEEN_FILE" 2>/dev/null || : + zones_now=0 + [[ -e "$HOME/.claude/context-guard/zones.json" ]] && zones_now=1 + compacted_now=0 + [[ -e "$COMPACTED_FILE" ]] && compacted_now=1 + [[ "$seen_flags" == "z=$zones_now c=$compacted_now" ]] && exit 0 +fi + +# Read BEFORE the resolver rather than at the stamp below, because what the mark +# records is what THIS resolve was decided on: an override created while the +# resolver runs lands older than the mark, so recording it as present would +# silence the first fire that could act on it. +zones_seen=0 +[[ -n "${HOME:-}" && -e "$HOME/.claude/context-guard/zones.json" ]] && zones_seen=1 + # Stderr redirected on the GROUP, not inside the substitution: the resolver is # one process, and `$(bash … 2>/dev/null)` billed two for it. Same suppression # (the resolver's zones.json notices stay hidden from this caller, as before), @@ -229,7 +343,7 @@ hook::require_jq "$EVENT" "context-guard" "$INPUT" # reading and including unknown, because the marker IS data even when the # snapshot has none. degraded="" -if [[ -n "${HOME:-}" && -e "$HOME/.claude/context-guard/context/$SESSION.compacted" ]]; then +if [[ -n "$COMPACTED_FILE" && -e "$COMPACTED_FILE" ]]; then degraded="yes" zone="dumb" fi @@ -238,21 +352,6 @@ fi # transition, and a later real reading must compare against the last REAL one. [[ "$zone" == "smart" || "$zone" == "acceptable" || "$zone" == "dumb" ]] || exit 0 -# silent-skip-ok: with neither CLAUDE_PLUGIN_DATA nor HOME there is no -# resolvable state root, and a `.`-relative fallback would key the last-seen -# zone to whatever directory the hook happened to start in — the once-per- -# transition contract cannot hold against state that moves with the working -# directory, so the hook would re-inject on every cd. Same doctrine -# post-compact-mark.sh applies to its marker path. -if [[ -n "${CLAUDE_PLUGIN_DATA:-}" ]]; then - STATE_DIR="$CLAUDE_PLUGIN_DATA/state" -elif [[ -n "${HOME:-}" ]]; then - STATE_DIR="$HOME/.claude/context-guard/state" -else - exit 0 -fi -STATE_FILE="$STATE_DIR/$SESSION.zone" -ARMED_FILE="$STATE_DIR/$SESSION.armed" # One reader for both markers. It sets REPLY (the raw bytes on disk) and # REPLY_NORM (the normalized zone word) rather than printing them, the same # reason rank/unrank below do: a command substitution forks a subshell, and @@ -391,6 +490,22 @@ if [[ -n "$persist_failed" ]]; then exit 0 fi +# The resolve completed and both markers hold it, so the inputs behind it may +# now be treated as seen. Stamped HERE and nowhere earlier: a resolver that +# failed, a reading of `unknown`, and a marker write that failed all exit above +# this line, and all three must be RETRIED on the next fire rather than skipped +# — the decision they were owed was never made. A redirection rather than +# `touch`, and one line rather than an empty file, because the mtime cannot +# carry the other half: the two optional inputs can be REMOVED, and a removal +# moves no mtime. The compacted flag is `degraded` rather than a fresh `-e`, for +# the same reason the zones flag was read before the resolver: it is what the +# decision used. A failure to stamp is ignored for the same reason a failure to +# skip is harmless, namely that it only costs the next fire a resolve it would +# have done anyway. +compacted_seen=0 +[[ -n "$degraded" ]] && compacted_seen=1 +printf 'z=%s c=%s\n' "$zones_seen" "$compacted_seen" >"$SEEN_FILE" 2>/dev/null || : + ((new_rank > armed_rank)) || { # Nothing worse than this session has already reported. Three shapes reach # here and only the first two are worth telemetry: a genuine recovery (rank diff --git a/plugins/context-guard/hooks/zone-crossing-inject.test.sh b/plugins/context-guard/hooks/zone-crossing-inject.test.sh index 1010d1ebf..592232095 100755 --- a/plugins/context-guard/hooks/zone-crossing-inject.test.sh +++ b/plugins/context-guard/hooks/zone-crossing-inject.test.sh @@ -549,6 +549,11 @@ fi SKIP_REF="$WORK/skip-ref" touch -t 200001010000 "$D/state/sskip.zone" "$D/state/sskip.armed" touch -t 200001020000 "$SKIP_REF" +# The `.seen` mark is cleared before each fire in this case, because 12c is +# about the MARKER writes on a fire that RESOLVES. Left in place, the +# unchanged-input skip would exit before the resolver and both assertions below +# would pass without the code they guard ever running. +rm -f "$D/state/sskip.seen" run "$H" "$D" sskip # same zone, same gate: nothing to persist if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "write skip: the steady fire is silent" @@ -569,6 +574,7 @@ fi # broken probe cannot pass the two assertions above by never detecting anything. printf 'acceptable\n' >"$D/state/sskip.zone" # legacy-looking mismatch: a rewrite is owed touch -t 200001010000 "$D/state/sskip.zone" +rm -f "$D/state/sskip.seen" run "$H" "$D" sskip if [[ $RC -eq 0 && "$D/state/sskip.zone" -nt "$SKIP_REF" && "$(cat "$D/state/sskip.zone" 2>/dev/null)" == "dumb" ]]; then ok "write skip: a marker that differs on disk is still rewritten (probe detects writes)" @@ -576,6 +582,183 @@ else fail "write skip probe: mismatched marker not rewritten: rc=$RC zone=$(cat "$D/state/sskip.zone" 2>/dev/null)" fi +# 13. THE UNCHANGED-INPUT SKIP decides only WHETHER the work runs, never what +# the work says. The xtrace budget below proves the skipped fire starts nothing; +# this pins the other half, which a process count cannot see: a session that +# skipped a fire must still produce the SAME crossing message, byte for byte, as +# one that never skipped. Compared against a control session driven through the +# identical zone sequence with no idle fire in it — the message carries only the +# two zone words, so two such sessions are byte-identical or the skip changed +# something it had no business touching. +write_snapshot "$H" sfpc 10 # control: smart, then a crossing to dumb +run "$H" "$D" sfpc +write_snapshot "$H" sfpc 90 +run "$H" "$D" sfpc +CTRL_OUT="$OUT" +if [[ $RC -eq 0 && "$CTRL_OUT" == *additionalContext* && "$CTRL_OUT" == *dumb* ]]; then + ok "skip control: the un-skipped session produced the crossing message" +else + fail "skip control did not cross: rc=$RC out=${CTRL_OUT:0:120}" +fi +write_snapshot "$H" sfps 10 # the same sequence with an idle fire in the middle +run "$H" "$D" sfps +run "$H" "$D" sfps # nothing moved since the resolve: the skip +if [[ $RC -eq 0 && -z "$OUT" ]]; then + ok "skip: a fire whose inputs have not moved is silent" +else + fail "skip: idle fire emitted: rc=$RC out=${OUT:0:120}" +fi +write_snapshot "$H" sfps 90 # rewritten: the skip must not survive it +run "$H" "$D" sfps +if [[ "$OUT" == "$CTRL_OUT" ]]; then + ok "skip: a rewritten snapshot resolves and its message is byte-identical" +else + fail "skip changed the crossing message: [${OUT:0:200}] != [${CTRL_OUT:0:200}]" +fi + +# 13a. zones.json is the second input the skip must watch: the bands can move +# under an unchanged snapshot, and the same percentage then resolves to a +# different word. A mark left by a resolve under the old bands may not silence +# the first fire under the new ones. +ZFH="$WORK/home-zfast" +ZFD="$WORK/data-zfast" +mkdir -p "$ZFD" +write_snapshot "$ZFH" szfast 60 # acceptable under the shipped bands +run "$ZFH" "$ZFD" szfast +if [[ $RC -eq 0 && "$OUT" == *acceptable* ]]; then + ok "zones.json skip: the baseline observation resolves acceptable" +else + fail "zones.json skip baseline: rc=$RC out=${OUT:0:120}" +fi +run "$ZFH" "$ZFD" szfast +if [[ $RC -eq 0 && -z "$OUT" ]]; then + ok "zones.json skip: the repeat fire is silent" +else + fail "zones.json skip: repeat fire emitted: rc=$RC out=${OUT:0:120}" +fi +sleep 0.05 # the override and the mark must land on distinguishable mtimes +mkdir -p "$ZFH/.claude/context-guard" +printf '{"smart_max_used_percentage":5,"acceptable_max_used_percentage":20}' \ + >"$ZFH/.claude/context-guard/zones.json" +run "$ZFH" "$ZFD" szfast +if [[ $RC -eq 0 && "$OUT" == *additionalContext* && "$OUT" == *dumb* ]]; then + ok "zones.json newer than the mark still resolves (same snapshot, new bands)" +else + fail "a newer zones.json was skipped: rc=$RC out=${OUT:0:120}" +fi + +# 13b. A RESOLVER FAILURE LEAVES THE MARK UNTOUCHED. The mark means "the inputs +# behind a completed decision", so a fire that reached no decision may not set +# it — otherwise one failed resolve would silence the session until its next +# snapshot write. Driven through a copy of the hook whose sibling resolver is a +# stub, which is a true nonzero exit from the process the hook actually starts +# rather than a simulated one: RESOLVER is derived from the hook's own path. +FAKE="$WORK/fake" +mkdir -p "$FAKE/hooks" "$FAKE/scripts" +cp "$SCRIPT_DIR"/*.sh "$FAKE/hooks/" +printf '#!/usr/bin/env bash\nexit 1\n' >"$FAKE/scripts/context-zone.sh" +FH="$WORK/home-fail" +FD="$WORK/data-fail" +mkdir -p "$FD" +write_snapshot "$FH" sfail 90 +F_OUT=$(printf '{"session_id":"sfail","hook_event_name":"PostToolBatch"}' | + HOME="$FH" CLAUDE_PLUGIN_DATA="$FD" HOOK_TELEMETRY_SINK="" bash "$FAKE/hooks/zone-crossing-inject.sh" 2>/dev/null) +F_RC=$? +if [[ $F_RC -eq 0 && -z "$F_OUT" ]]; then + ok "resolver failure: the hook is silent and exits 0" +else + fail "resolver failure: rc=$F_RC out=${F_OUT:0:120}" +fi +if [[ ! -e "$FD/state/sfail.seen" ]]; then + ok "resolver failure: the mark is not stamped" +else + fail "resolver failure stamped the mark, so the next fire would be skipped" +fi +# ...and the fire it was owed is still issued once the resolver works, with the +# snapshot untouched in between. +printf '#!/usr/bin/env bash\nexec bash %q/../scripts/context-zone.sh "$@"\n' "$SCRIPT_DIR" \ + >"$FAKE/scripts/context-zone.sh" +F_OUT=$(printf '{"session_id":"sfail","hook_event_name":"PostToolBatch"}' | + HOME="$FH" CLAUDE_PLUGIN_DATA="$FD" HOOK_TELEMETRY_SINK="" bash "$FAKE/hooks/zone-crossing-inject.sh" 2>/dev/null) +F_RC=$? +if [[ $F_RC -eq 0 && "$F_OUT" == *additionalContext* && "$F_OUT" == *dumb* ]]; then + ok "resolver failure: the retried fire resolves and injects" +else + fail "retry after a resolver failure was skipped: rc=$F_RC out=${F_OUT:0:120}" +fi + +# 13c. REMOVING zones.json moves the same input, and `-nt` cannot see it: a file +# that is gone is never newer than anything. The override above is what makes +# 60% resolve dumb, so deleting it restores the shipped bands and the session is +# acceptable again. The mark therefore records whether each OPTIONAL input +# existed, and the skip requires that record to still hold. +run "$ZFH" "$ZFD" szfast +if [[ $RC -eq 0 && -z "$OUT" ]]; then + ok "zones.json skip: the repeat fire under the override is silent" +else + fail "zones.json skip: the fire under the override emitted: rc=$RC out=${OUT:0:120}" +fi +rm -f "$ZFH/.claude/context-guard/zones.json" +run "$ZFH" "$ZFD" szfast +# An improvement is silent by contract, so the persisted zone is the observable. +if [[ "$(cat "$ZFD/state/szfast.zone" 2>/dev/null)" == "acceptable" ]]; then + ok "zones.json removed: the next fire re-resolves under the shipped bands" +else + fail "a removed zones.json was skipped: zone=$(cat "$ZFD/state/szfast.zone" 2>/dev/null)" +fi + +# 13d. The compaction marker has the same hole, and the degraded reading lasts +# only as long as the marker does. A session that resolved dumb under one must +# resolve again once it is gone, although nothing left carries an mtime newer +# than the mark. +CMH="$WORK/home-cmark" +CMD="$WORK/data-cmark" +mkdir -p "$CMD" +write_snapshot "$CMH" scmark 10 # smart +run "$CMH" "$CMD" scmark +run "$CMH" "$CMD" scmark +if [[ $RC -eq 0 && -z "$OUT" ]]; then + ok "compaction marker: the repeat fire before any marker is silent" +else + fail "compaction marker: repeat fire emitted: rc=$RC out=${OUT:0:120}" +fi +# No sleep: creating the marker flips the recorded flag, so this half is settled +# by existence parity and needs no distinguishable mtime. The removal below is +# the same, which is the point, because an mtime race cannot decide either way. +: >"$CMH/$CTX_REL/scmark.compacted" +run "$CMH" "$CMD" scmark +if [[ $RC -eq 0 && "$OUT" == *additionalContext* && "$OUT" == *dumb* ]]; then + ok "compaction marker: a new marker resolves and reports the degraded zone" +else + fail "compaction marker: a new marker was skipped: rc=$RC out=${OUT:0:120}" +fi +rm -f "$CMH/$CTX_REL/scmark.compacted" +run "$CMH" "$CMD" scmark +if [[ "$(cat "$CMD/state/scmark.zone" 2>/dev/null)" == "smart" ]]; then + ok "compaction marker removed: the next fire re-resolves undegraded" +else + fail "a removed compaction marker was skipped: zone=$(cat "$CMD/state/scmark.zone" 2>/dev/null)" +fi + +# 13e. A MARK WITH NO READABLE LINE IS NOT A SKIP. The line is what makes the +# two removals above visible, so a mark left by an older build, or by a write +# that failed after truncating, carries none and must fall through to a resolve +# rather than inherit a skip it never earned. Truncating the mark also makes it +# the newest of the four files, so every `-nt` test passes and the line is the +# only thing left to refuse on. +: >"$D/state/sfps.seen" +run "$H" "$D" sfps +if [[ -s "$D/state/sfps.seen" ]]; then + ok "mark with no line: the fire re-resolves and re-stamps the mark" +else + fail "a mark with no line took the skip, so it was never re-stamped" +fi +if [[ "$(head -1 "$D/state/sfps.seen" 2>/dev/null)" == "z=0 c=0" ]]; then + ok "mark with no line: the new mark records both optional inputs as absent" +else + fail "mark line is not the existence record: [$(head -1 "$D/state/sfps.seen" 2>/dev/null)]" +fi + # No resolvable state root → stay silent rather than key the last-seen zone to # the working directory, which would re-inject on every cd. write_snapshot "$WORK/nohome" snr 90 @@ -601,10 +784,20 @@ fi # EXACT COUNTS, not on absence, so a regression back to a second `jq` fails here # rather than showing up as a slow session. # -# Budget on the steady non-crossing path, which is the common case: -# 1 jq : one pass over the payload for both envelope fields -# 1 bash : scripts/context-zone.sh, the single band authority this hook must -# not re-implement; its own execs are in that process, not this trace +# TWO paths are budgeted, because the steady fire no longer does the work. +# +# A. THE STEADY NON-CROSSING PATH — the common case, and now the unchanged-input +# skip: the snapshot, zones.json and the compaction marker are all older than +# the `.seen` mark the last resolve left, and the mark's existence line still +# matches, so the hook exits before the resolver. Budget: ZERO. The envelope +# parse is answered by hook::jq_fields' builtin parser, the mark is compared +# with `-nt`, its line is read with `read`, and it is stamped with a +# redirection: every one of those is a shell builtin. +# B. THE RESOLVING PATH — a fire whose snapshot has been rewritten since. Budget: +# 1 bash : scripts/context-zone.sh, the single band authority this hook must +# not re-implement; its own execs are in that process, not this trace +# and NO jq, for the same reason A is free: the builtin parser answers the two +# envelope fields of an ordinary-sized payload. # Anything else is a regression. The count is of commands in COMMAND POSITION # (anchored on the xtrace depth prefix), so `command -v jq` in hook::require_jq # is correctly not counted: it is a shell builtin and spawns nothing. @@ -616,10 +809,15 @@ TH="$WORK/home-trace" TD="$WORK/data-trace" mkdir -p "$TD" write_snapshot "$TH" strace 10 -# Prime: the first fire creates the state directory and the markers, so the -# traced fire is the steady path a running session actually pays. +# Prime: the first fire creates the state directory, the markers and the `.seen` +# mark, so the traced fire is the steady path a running session actually pays. printf '{"session_id":"strace","hook_event_name":"PostToolBatch"}' | HOME="$TH" CLAUDE_PLUGIN_DATA="$TD" HOOK_TELEMETRY_SINK="" bash "$HOOK" >/dev/null 2>&1 +if [[ -f "$TD/state/strace.seen" ]]; then + ok "trace: a completed resolve leaves the .seen mark" +else + fail "trace: no .seen mark after a completed resolve" +fi TRACE_LOG="$WORK/inject-xtrace.log" printf '{"session_id":"strace","hook_event_name":"PostToolBatch"}' | HOME="$TH" CLAUDE_PLUGIN_DATA="$TD" HOOK_TELEMETRY_SINK="" \ @@ -633,30 +831,56 @@ else fi TRACE_SPAWNS=$(grep -cE "$TRACE_PAT" "$TRACE_LOG" 2>/dev/null | tr -cd '0-9') TRACE_DETAIL=$(grep -oE "$TRACE_PAT" "$TRACE_LOG" 2>/dev/null | sed -E 's/^\++ //; s/ $//' | sort | uniq -c | tr -d '\n') -if [[ "$TRACE_SPAWNS" == "2" ]]; then - ok "trace: the steady path spawns exactly 2 processes" +if [[ "$TRACE_SPAWNS" == "0" ]]; then + ok "trace: the steady path spawns nothing at all" else - fail "trace: steady path spawns $TRACE_SPAWNS processes, budget is 2: $TRACE_DETAIL" + fail "trace: steady path spawns $TRACE_SPAWNS processes, budget is 0: $TRACE_DETAIL" fi +# Named separately from the total, so a revert to always-resolving is legible in +# the failure message rather than only in the count. +TRACE_BASH=$(grep -cE '^\++ bash ' "$TRACE_LOG" 2>/dev/null | tr -cd '0-9') TRACE_JQ=$(grep -cE '^\++ jq ' "$TRACE_LOG" 2>/dev/null | tr -cd '0-9') -if [[ "$TRACE_JQ" == "1" ]]; then - ok "trace: exactly one jq pass over the payload" +if [[ "$TRACE_BASH" == "0" && "$TRACE_JQ" == "0" ]]; then + ok "trace: the steady path invokes neither the resolver nor jq" else - fail "trace: $TRACE_JQ jq processes on the steady path, budget is 1" + fail "trace: steady path ran $TRACE_BASH bash and $TRACE_JQ jq, both budgets are 0" fi -TRACE_BASH=$(grep -cE '^\++ bash ' "$TRACE_LOG" 2>/dev/null | tr -cd '0-9') -if [[ "$TRACE_BASH" == "1" ]]; then + +# B. The resolving path, on the same session: rewrite the snapshot so it is +# newer than the mark, and the hook must do the work it skipped above — the +# skip may only ever suppress a REPEAT. +sleep 0.05 # the mark and the rewrite must land on distinguishable mtimes +write_snapshot "$TH" strace 10 +TRACE_LOG2="$WORK/inject-xtrace-resolve.log" +printf '{"session_id":"strace","hook_event_name":"PostToolBatch"}' | + HOME="$TH" CLAUDE_PLUGIN_DATA="$TD" HOOK_TELEMETRY_SINK="" \ + BASH_XTRACEFD=9 bash -x "$HOOK" >/dev/null 2>/dev/null 9>"$TRACE_LOG2" +TRACE2_SPAWNS=$(grep -cE "$TRACE_PAT" "$TRACE_LOG2" 2>/dev/null | tr -cd '0-9') +TRACE2_DETAIL=$(grep -oE "$TRACE_PAT" "$TRACE_LOG2" 2>/dev/null | sed -E 's/^\++ //; s/ $//' | sort | uniq -c | tr -d '\n') +if [[ "$TRACE2_SPAWNS" == "1" ]]; then + ok "trace: a rewritten snapshot resolves, and spawns exactly 1 process" +else + fail "trace: resolving path spawns $TRACE2_SPAWNS processes, budget is 1: $TRACE2_DETAIL" +fi +TRACE2_BASH=$(grep -cE '^\++ bash ' "$TRACE_LOG2" 2>/dev/null | tr -cd '0-9') +if [[ "$TRACE2_BASH" == "1" ]]; then ok "trace: exactly one resolver process (the band authority)" else - fail "trace: $TRACE_BASH bash processes on the steady path, budget is 1" + fail "trace: $TRACE2_BASH bash processes on the resolving path, budget is 1" +fi +TRACE2_JQ=$(grep -cE '^\++ jq ' "$TRACE_LOG2" 2>/dev/null | tr -cd '0-9') +if [[ "$TRACE2_JQ" == "0" ]]; then + ok "trace: the envelope parse costs no jq (builtin parser)" +else + fail "trace: $TRACE2_JQ jq processes on the resolving path, budget is 0" fi # The specific pipelines this budget replaced, named so a revert is legible in # the failure message rather than only in the total. -TRACE_GONE=$(grep -cE '^\++ (dirname|tr|head) ' "$TRACE_LOG" 2>/dev/null | tr -cd '0-9') +TRACE_GONE=$(grep -cE '^\++ (dirname|tr|head|touch) ' "$TRACE_LOG2" 2>/dev/null | tr -cd '0-9') if [[ "$TRACE_GONE" == "0" ]]; then - ok "trace: no dirname, tr or head on the steady path" + ok "trace: no dirname, tr, head or touch on the resolving path" else - fail "trace: $TRACE_GONE dirname/tr/head process(es) returned: $TRACE_DETAIL" + fail "trace: $TRACE_GONE dirname/tr/head/touch process(es) returned: $TRACE2_DETAIL" fi # --- The per-batch PROCESS-CREATION budget, proven by strace ------------------- @@ -676,16 +900,25 @@ fi # Asserted as an EXACT count, not a ceiling, so a regression back to an inner # redirect fails here rather than showing up as a timed-out session. # -# Budget on the steady non-crossing path, one process creation each: -# 1 jq : the payload pass, both envelope fields +# Budget on the STEADY NON-CROSSING path: ZERO process creations, and one +# execve — the hook's own shell, which strace itself launches rather than the +# hook forking it. The unchanged-input skip reaches its exit through builtins +# only. +# +# Budget on the RESOLVING path, one process creation each: # 1 bash : scripts/context-zone.sh, the band authority # 1 jq : the resolver's snapshot pass, inside that bash -# The hook's own shell is execve'd by the harness, not forked by the hook, so it -# is not in this count. Skipped where strace is unavailable (it needs ptrace, -# which containers and macOS commonly withhold) — the xtrace budget above still -# runs there, and CI keeps a Linux lane that does not skip. +# plus the hook's own shell for a program-launch count of 3. The payload jq the +# earlier budget carried is gone: hook::jq_fields answers this envelope from its +# builtin parser. +# +# Skipped where strace is unavailable (it needs ptrace, which containers and +# macOS commonly withhold) — the xtrace budgets above still run there, and CI +# keeps a Linux lane that does not skip. if command -v strace >/dev/null 2>&1; then STRACE_LOG="$WORK/inject-strace.log" + # The snapshot was rewritten for trace B above and the fire that followed it + # re-stamped the mark, so this fire is the steady one again. printf '{"session_id":"strace","hook_event_name":"PostToolBatch"}' | HOME="$TH" CLAUDE_PLUGIN_DATA="$TD" HOOK_TELEMETRY_SINK="" \ strace -f -qq -e trace=clone,clone3,fork,vfork,execve -o "$STRACE_LOG" \ @@ -693,25 +926,51 @@ if command -v strace >/dev/null 2>&1; then if [[ -s "$STRACE_LOG" ]]; then ok "strace: the steady non-crossing path was traced" S_FORKS=$(grep -cE '(clone|clone3|fork|vfork)\(' "$STRACE_LOG" 2>/dev/null | tr -cd '0-9') - if [[ "$S_FORKS" == "3" ]]; then - ok "strace: the steady path creates exactly 3 processes" + if [[ "$S_FORKS" == "0" ]]; then + ok "strace: the steady path creates no processes at all" else S_DETAIL=$(grep -oE 'execve\("[^"]+"' "$STRACE_LOG" 2>/dev/null | sed 's/execve("//' | sort | uniq -c | tr -d '\n') - fail "strace: steady path creates $S_FORKS processes, budget is 3 (execs: $S_DETAIL)" + fail "strace: steady path creates $S_FORKS processes, budget is 0 (execs: $S_DETAIL)" fi - # The programs actually launched must not change with the fork count: this - # is a latency fix, so the same work must still run. One jq for the payload, - # one bash for the resolver, one jq inside it, plus the hook's own shell. S_EXECS=$(grep -cE 'execve\(' "$STRACE_LOG" 2>/dev/null | tr -cd '0-9') - if [[ "$S_EXECS" == "4" ]]; then - ok "strace: the same 4 program launches as before the fork reduction" + if [[ "$S_EXECS" == "1" ]]; then + ok "strace: the steady path launches only the hook's own shell" else - fail "strace: $S_EXECS program launches on the steady path, expected 4" + fail "strace: $S_EXECS program launches on the steady path, expected 1" fi else fail "strace: no usable trace captured" fi + STRACE_LOG2="$WORK/inject-strace-resolve.log" + sleep 0.05 + write_snapshot "$TH" strace 10 + printf '{"session_id":"strace","hook_event_name":"PostToolBatch"}' | + HOME="$TH" CLAUDE_PLUGIN_DATA="$TD" HOOK_TELEMETRY_SINK="" \ + strace -f -qq -e trace=clone,clone3,fork,vfork,execve -o "$STRACE_LOG2" \ + bash "$HOOK" >/dev/null 2>&1 + if [[ -s "$STRACE_LOG2" ]]; then + ok "strace: the resolving path was traced" + S2_FORKS=$(grep -cE '(clone|clone3|fork|vfork)\(' "$STRACE_LOG2" 2>/dev/null | tr -cd '0-9') + if [[ "$S2_FORKS" == "2" ]]; then + ok "strace: the resolving path creates exactly 2 processes" + else + S2_DETAIL=$(grep -oE 'execve\("[^"]+"' "$STRACE_LOG2" 2>/dev/null | + sed 's/execve("//' | sort | uniq -c | tr -d '\n') + fail "strace: resolving path creates $S2_FORKS processes, budget is 2 (execs: $S2_DETAIL)" + fi + # The programs actually launched must not change with the fork count: this + # is a latency fix, so the same work must still run. One bash for the + # resolver, one jq inside it, plus the hook's own shell. + S2_EXECS=$(grep -cE 'execve\(' "$STRACE_LOG2" 2>/dev/null | tr -cd '0-9') + if [[ "$S2_EXECS" == "3" ]]; then + ok "strace: the resolving path launches exactly 3 programs" + else + fail "strace: $S2_EXECS program launches on the resolving path, expected 3" + fi + else + fail "strace: no usable resolving-path trace captured" + fi else ok "SKIP: strace unavailable — process-creation budget not asserted here" fi