Skip to content

perf(autonomy): cut lane-stop-gate process creations on every Stop - #3870

Merged
kyle-sexton merged 6 commits into
mainfrom
claude/3515-lane-stop-gate-perf
Sep 7, 2026
Merged

perf(autonomy): cut lane-stop-gate process creations on every Stop#3870
kyle-sexton merged 6 commits into
mainfrom
claude/3515-lane-stop-gate-perf

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Closes #3515

Important

Which of #3515's acceptance criteria this PR meets, and which it does not.

Criterion Status
No more than 2 external process spawns on the common path (own shell + at most one jq) Met for the common path, which is every interactive Stop: the hook's shell plus one uname -s. The one spawn is uname, not jq; it is the managed-settings platform primitive the trust design rests on ($OSTYPE is a variable a repo env block can set) and was left alone on purpose. Not met for an enabled lane's stop: that path launches 5 programs (4 jq, 1 uname), down from 18. One of those jq is in the fenced shared library's hook::buffer_stdin (its payload validation pass); the other three read three different inputs (payload, settings file, block decision).
grep/sed/cut/tr/basename/dirname replaced with builtins on the hot path Met. None launches on either traced path; the suite pins their absence. cksum remains on the marker-consumption path only (it keys the ledger, and changing the key would orphan existing ledger entries).
An early guard exits before any spawn for non-matching invocations Partially met. The payload-free pre-filter exits before stdin and before jq, but it spawns uname to know which fixed managed-settings path to test. Removing it would mean testing all three platform paths, one of which is cwd-relative on Linux, so it stays.
Existing behavioural tests pass; the guard blocks what it blocked before Met, with one disclosed divergence in the stricter direction (below).
Measured on a Windows host, a single run completes in under 2 s Not measured. This runner is Linux; wall-clock is not the right proxy here and no figure is invented. The hook-budget convention's per-turn 500 ms bar (the corrected criterion from the issue comments) also still needs the Windows measurement.

Closes #3515 is kept because the pr-issue-linkage gate wants a native closing keyword. A reviewer may prefer to reopen #3515 on merge for the Windows measurement and the enabled-lane count, or split those into a follow-up.

Summary

hooks/lane-stop-gate.sh is the autonomy plugin's Stop hook. It fires on every turn of every session, gated or not, and #3515 recorded it at a 27.6 s average with 73 timeouts against its 15 s budget on the #3508 hosts. It carried the largest in-file fork signature of the campaign: about 12 redirections and 11 pipes written inside command substitutions, plus a $( ) capture around every lib helper that is nothing but parameter expansion.

Following #3779's diagnosis rather than #3508's original framing: the cost was redirection placement and per-helper subshells, not per-field jq needing a shared helper. lib/hook-utils.sh and all 17 plugins/*/hooks/hook-utils.sh copies are untouched (git diff origin/main --name-only shows only the six autonomy files). The one shared-library facility used, hook::jq_fields, already existed on main.

Fix

All in plugins/autonomy/hooks/lane-stop-gate.sh and lane-stop-gate-lib.sh:

Site Before After
Lib path helpers (gate_data_dir, gate_trusted_data_dir, gate_arm_record_path, gate_arm_claim_path, gate_user_settings_file) v=$(gate_x): one fork each, for a function that only expands parameters gate_x_to <var> forms via printf -v; print forms delegate, so lane-stop-gate-arm.sh and the lib-level tests read exactly what they read before
Pre-filter settings scan grep -q lane_stop_gate "$f" 2>/dev/null per file builtin NUL-chunk read + substring test (gate_file_mentions), with 2>/dev/null written before the input redirection so an existing-but-unreadable settings file stays as silent as grep was (bash applies redirections left to right; see case 51)
Managed-files list done < <(gate_managed_settings_files) with $(uname -s 2>/dev/null) inside: 3 creations per call, called up to 4 times per stop gate_managed_settings_files_load fills an array in-process; { platform=$(uname -s); } 2>/dev/null is 1 creation; loaded once per stop and reused by option resolution
Payload fields (EVENT, SESSION_ID, CWD, STOP_ACTIVE, LAST) five printf | jq | tr pipelines, 3-4 creations each one hook::jq_fields pass (3 creations: process substitution, printf writer, jq); values chomped of trailing newlines so each reads byte-for-byte as the old $( ) capture did
Settings options (3 keys x managed files + user file) one $(jq … <file 2>/dev/null) per key per file, each behind two more captures (8 creations per key) gate_settings_options_to <file> <key>...: one jq per file for every key, NUL-separated through a process substitution with the redirections on the enclosing group (1 creation per file); resolved once, answered from memory (gate_option_to)
Arm record jq -ec . validation + printf | jq per field + printf | jq per option: 8 creations plus 6 on lookup one jq pass over the record file yielding armed_at, session_id, sentinel, marker, compact json (1 creation); EPOCHSECONDS for the TTL clock with date as the pre-5.0 fallback
Sentinel escape and match $(printf | sed …) then grep -qE … <<<"$LAST" shell loop over the same fifteen metacharacters; [[ $LAST =~ (^|\n)[[:space:]]*TOKEN[[:space:]]*(\n|$) ]], which agrees with grep's per-line verdict on every message (argument in the code comment)
Telemetry data object $(jq -nc --arg … 2>/dev/null) on every evaluated stop assembled in the shell from the closed vocabulary: identical bytes
Marker ledger $(printf | cksum | tr -cd '0-9'), $(stat … || stat … || printf ''), $(dirname …) { key=$(cksum); } < <(printf '%s' "$path") with a parameter-expansion digit filter (same key as before, verified), per-rung { …; } 2>/dev/null stat groups, ${ledger%/*}
Post-nudge branch read $(git … 2>/dev/null | tr -d '\000-\037') { BRANCH=$(git …); } 2>/dev/null + ${BRANCH//[[:cntrl:]]/} (a superset strip; git refuses every such byte in a ref name, and lane::notify strips C0 again)
gate_resolve_plugin_name (unanchored installs) $(jq … <"$manifest" 2>/dev/null) group-hoisted redirections

Left alone on purpose: uname -s as the platform primitive; hook::buffer_stdin (shared library; its $( ) capture, read-slice probe and printf | jq -e validation are 4 of the enabled path's remaining 10 creations); the gate_arm_owned subshell (one fork, and the cleanest scope for umask/noclobber); the final jq -nc block decision.

Disclosed divergence (one degenerate config): a configured sentinel that itself holds a newline. grep -E read that newline as a pattern separator and authorized a stop on any line matching either half of the token. The shell match treats the token as one pattern, so only the whole token standing alone authorizes, which is what the block reason instructs the agent to emit. No shipped launcher writes such a token. Pinned by new case 50 and named in the 0.22.30 changelog entry.

Group redirections were checked for over-suppression: every { …; } 2>/dev/null here wraps exactly one command, and the two process-substitution loops wrap a read builtin (no stderr) plus the one jq whose stderr the old code already suppressed.

Verification

Before/after, measured here (strace -f -e trace=clone,clone3,fork,vfork,execve, staged plugins/cache/<m>/<n>/<v>/hooks install, hook launched by the harness so its own shell is not in the count):

Path Creations before After Launches (execve) before After
Default: no gate footprint anywhere (every interactive Stop) 4 1 2 (grep, uname) 1 (uname)
Enabled by user settings, first stop, no signal (block) 48 10 18 5 (jq, jq, uname, jq, jq)
Enabled, sentinel on its own line (allow) 44 9 16 4
Enabled, second stop after the nudge (allow, notify muted) 54 10 21 5
Enabled, marker present (allow, consume) 51 12 19 6
Env-only enable claim (once-per-session notice) 31 22 11 8 (remainder is hook::notice_once in the shared library)
Armed by the launcher, first stop (block) 60 11 20 5

Launches did not stay flat, and that is expected here: unlike #3779, this issue explicitly asks for the grep/sed/tr/dirname helpers to become builtins, so those launches are gone; every jq that reads a distinct input is still launched, and the payload/settings/arm-record jq calls that were batched read the same inputs once instead of several times.

Wall-clock is not measured and no figure is invented. A spawn is about 1 ms on this Linux runner; #3508 measures 180 to 2,841 ms per spawn (median 1,108 ms at 501 concurrent processes) on the affected Windows hosts. Process creations by trace are the drift-immune proxy #3508's corrected criteria ask for. The plugin README now carries the table above under a "Hook cost" heading, per hook-budget Rule 1, and says the Windows wall-clock share still needs measuring.

Proof the verdicts did not change.

  • All 89 pre-existing assertions in lane-stop-gate.test.sh pass unmodified; the suite is now 103 with the additions below (102 where chmod 000 denies nothing and case 51 skips visibly).
  • A black-box differential harness (scratch, not committed) ran the origin/main hook and this branch's hook over 95 scenarios from identical fresh staged installs, comparing rc, stdout, and marker/ledger side effects: every settings shape the reader distinguishes (boolean/string/number/null values, options as string/array, pluginConfigs as array/string, null entry, other marketplace, malformed, two documents), sentinel edge cases (CRLF, tabs, blank lines, inline mention, substring, trailing text, 120 KB messages with early and no sentinel, invalid UTF-8 around the token, metacharacter sentinels, empty sentinel), marker absolute/relative/consumed/stale-ledger/undeletable, env-only claims, and the arm record's every parse and claim path (null/false/number/string/empty/malformed, missing or non-numeric or float armed_at, expired, legacy session_id match/mismatch/numeric, taken/empty/directory claim files, missing session id). 93 identical; the 2 differences are the two halves of the disclosed newline-sentinel case.
  • The ledger key derivation and the sentinel escape were also compared directly against the old pipelines on sample inputs: identical.

New regression tests, verified non-vacuous.

  • Case 49: gate_settings_options_to answers three keys from one pass with the single-key verdicts (including the trailing-newline chomp and the all-or-nothing options-not-an-object case).
  • Case 50: a newline-bearing sentinel authorizes only as a whole block (the disclosed divergence, pinned).
  • Case 51: an existing-but-unreadable settings file produces no stderr and does not change the verdict. Pins the redirection order in gate_file_mentions (commit fd0e7578): the first draft wrote done <"$1" 2>/dev/null, which attempts the open before stderr is silenced and prints Permission denied per turn where grep -q … 2>/dev/null was silent. Run as an unprivileged user the case passes on the fixed tree and fails with that exact line on the old ordering; where chmod 000 denies nothing (root, or a filesystem without POSIX modes) it skips visibly rather than passing vacuously.
  • Trace budget: exactly 1 creation and 1 launch (uname) on the default path; ceilings of 10 creations and 5 launches on the enabled block path (a ceiling so a shared-library saving in fix(hook-utils): a hook payload cut short at EOF is a loud allow; a stall stays a block #3740/perf(hooks): fuse stdin jq completeness with field extract #3838 lowers the count without failing here, while a regression in this plugin's files raises it and does); and no dirname, tr, sed, grep, cksum or date on either path. Skips where strace is unavailable; the CI Linux lane does not skip. Re-measured rather than assumed after the fd0e7578 redirection swap: both pins hold, and the enabled path's four jq are still jq -e . (hook::buffer_stdin), the jq -j payload pass, the jq -j settings pass and the jq -nc block decision, in that order with uname third.
  • Mutation 1 (move the uname redirection back inside its substitution): FAIL: default path creates 2 processes, budget is 1 and FAIL: enabled block path creates 11 processes, ceiling is 10. Mutation 2 (put the sed escape pipeline back): 13 creations, 6 launches, and the named-helper check all fail. Both on scratch copies; the committed tree passes.

Commands run in the foreground on the pushed head fd0e7578, with actual results (the lane-notify / lane-launcher / check-shell-portability lines are from the 1ca98cbf run; the second commit touches neither those files nor their inputs, and the full affected-tests.sh --run below was re-run on fd0e7578):

bash plugins/autonomy/hooks/lane-stop-gate.test.sh              -> PASS=103 FAIL=0 (unprivileged)
                                                                -> PASS=102 FAIL=0 as root, case 51 skips visibly
bash plugins/autonomy/hooks/lane-notify.test.sh                 -> rc=0
bash plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh -> rc=0  (calls lane-stop-gate-arm.sh)
bash plugins/claude-ops/skills/lanes/scripts/restart-consumer.test.sh -> rc=0

bash scripts/affected-tests.sh --explain
  -> the R4 transitive walk from lane-stop-gate.sh fans out to the whole shell corpus (170 suites);
     plugin.json, CHANGELOG.md, README.md recorded as no-suite (non-shell lanes)
bash scripts/affected-tests.sh --run   (foreground)
  -> rc=1: one failing suite, plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh
     ("process budget: the trace probe actually counted something ... measured -1, the pid-stamped PS4
     did not reach the traced shell"). Reproduced identically on a pristine `git archive origin/main`
     export: pre-existing and environmental, not from this change. Every other selected shell suite passed.

shellcheck <3 changed .sh files>                          -> clean
shfmt -d   <3 changed .sh files>                          -> clean
bash scripts/check-shell-portability.sh origin/main       -> No unexcused GNU-only constructs in 3 shell file(s).
node_modules/.bin/markdownlint-cli2 README.md CHANGELOG.md -> 0 issues
typos <5 changed files>                                   -> clean
scripts/check-hook-exec-form.sh, check-killswitch-hoist.sh, check-silent-skips.sh,
  check-discriminating-test-skips.sh, check-hook-wiring-liveness.sh, check-purged-em-dashes.sh -> all rc=0

bash scripts/check-changelog-parity.sh --check                       -> rc=0
bash scripts/check-changelog-parity.sh --check-order                 -> rc=0
bash scripts/check-changelog-parity.sh --check-bump origin/main      -> rc=0
bash scripts/check-changelog-parity.sh --check-preserved origin/main -> rc=0 (78 headings compared)

Manifest bumped 0.22.29 to 0.22.30 with a matching changelog entry.

Not mine and pre-existing: test_save_point.py::test_new_origin_falls_back_to_directory_name (Python, in the NOT RUN ecosystem list) and the cache-content-check.test.sh trace-probe failure above.

Related

🤖 Generated with Claude Code

https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob


Generated by Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-06T20:29:20.142095Z fd0e757 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copy link
Copy Markdown
Contributor Author

Independent review and the fixes it produced — flipped to ready

Posted by the autonomous worker lane (vm-worker-202609050257). Commit fd0e7578. This flip is the lane's handoff; the merge lane owns it from here. This lane does not merge.

The behaviour change is a genuine fix, and the old code was the bug

The review built the case from staged old and new installs. With sentinel DONE\nOK, the old code allowed a stop on "DONE with step 1, continuing" and on "All tests pass.\nOK"grep took the embedded newline as a pattern separator, so ^[[:space:]]*DONE OR OK[[:space:]]*$ matched, and any line starting with half the token or ending with the other half opened the gate. The new code blocks both and allows only the whole token on its own lines.

The old behaviour could not have been load-bearing: it authorized on half-tokens the block reason never asked for, a newline in a sentinel is malformed config, and no shipped launcher writes one. Every other class tried — default token, CR, tabs, blank lines, inline and substring and trailing forms, a metacharacter sentinel a.b*(c), invalid UTF-8 under C and C.UTF-8 — gave identical verdicts.

Fail-safe direction: the new code errs toward "fails to stop" (it blocks once, then the existing one-nudge-then-allow path releases it). The old code erred toward "stops when it should not", which is the direction the gate exists to prevent.

execve fell, and every launch is accounted for

18 → 5 on the enabled block path, each vanished launch named with the builtin that replaced it: grepread -d '' substring scan; tr ×2 → ${v//$'\r'/}; four per-field jq → one jq -j pass; uname ×3 → 1 loaded and reused; three settings jq → one multi-key jq -j; sed → a shell case loop; grep -qE[[ =~ ]]; jq -nc → a shell string. Armed path 20 → 5 likewise. Nothing vanished without a builtin doing the same job — which is what makes this a conversion rather than removed work, and the issue explicitly asked for it.

The interactive default path, which runs on every Stop, is 4 creations / 2 launches → 1 / 1.

Two fixes from review

A prose miscount: the body said two jq were inside hook::buffer_stdin; argv verification shows one (jq -e .), the other three being the hook's own. The sentence summed to five against a real four.

A new stderr leak, found and closed. done <"$1" 2>/dev/null applies redirections left to right, so an existing-but-unreadable settings file printed Permission denied before stderr was silenced — noise from a hook that runs on every Stop. Now done 2>/dev/null <"$1".

The test for it is worth noting: run as an unprivileged user via setpriv --reuid=1000 so chmod 000 actually denies. As root it would pass vacuously, so it reports a visible SKIP instead. Reverting the single line reproduces the failure.

Strace pins re-measured rather than assumed: default still exactly 1 creation / 1 launch, enabled block path still 10 / 5.

Left for the merge lane

#3740 also bumps plugins/autonomy/.claude-plugin/plugin.json (0.22.27 → 0.22.28) against a stale base; whichever lands second must rebump. Not this PR's defect. Also recorded, no action: [[ =~ ]] is linear, so 120 KB matches old timing and only a 1 MB message diverges, far outside realistic sizes.

Gates: autonomy suite 103/0 unprivileged and 102/0 as root, silent-skips, discriminating-test-skips, shellcheck, shfmt, markdownlint, typos, and all four parity modes including --check-order. Sole affected-tests.sh failure is the confirmed pre-existing cache-content-check.test.sh.


Generated by Claude Code

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 3m 28s —— View job


Security review (/review:security-review)

  • Load /review:security-review instructions
  • Check skip gate (PR state, security-relevant surface)
  • Fetch PR diff (origin/main...HEAD) and read changed files
  • Hunt for logic/trust-boundary/injection issues in the shell changes
  • Adversarially validate any candidate findings
  • Post final results

Scope reviewed: plugins/autonomy/hooks/lane-stop-gate.sh and lane-stop-gate-lib.sh (the only behavior-carrying files in this diff; plugin.json, CHANGELOG.md, README.md, and lane-stop-gate.test.sh are non-executable-surface or test-only). No GitHub Actions files changed, so there is nothing in zizmor's advisory lane here.

This PR is a performance-only refactor of the Stop hook's config-resolution trust boundary (managed settings ▷ arm record ▷ user settings ▷ default), converting several $( ) subshell captures and external-process pipelines (grep/sed/tr/dirname/multiple jq) into builtins and batched single-pass jq calls. I traced the diff line by line against the trust-boundary invariants the header comments assert, specifically:

  • Precedence ordering (gate_option_to in lane-stop-gate.sh): the new memoized, batched resolution (gate_managed_options_to, arm-record fields, gate_settings_options_to) preserves the same "a file/scope only overrides a key it explicitly answers for" semantics as the old per-key gate_option/gate_managed_option. A managed drop-in that doesn't mention a key no longer clobbers a primary file's verdict for that key — matches the original behavior. No downgrade path found.
  • Untrusted-input handling: EVENT/SESSION_ID/CWD/STOP_ACTIVE/LAST now come from one hook::jq_fields pass instead of five separate jq | tr -d '\r' pipelines. The shared helper still strips all CR bytes (not just trailing), and the new chomp_nl only trims trailing LF — together reproducing the old byte-for-byte value. No untrusted field reaches a shell metacharacter context unquoted.
  • Sentinel matching: replacing grep -qE ... <<<"$LAST" with a bash [[ =~ ]] match against a hand-escaped SENTINEL_RE preserves the same 15-character escape set as the old sed expression, and the anchor rewrite ((^|\n)...( \n|$)) is a faithful per-line-anchor translation. The one behavioral divergence (a sentinel value that itself contains a newline) is explicitly disclosed in the PR body/changelog and moves in the stricter direction (harder to satisfy, not easier) — not a new bypass.
  • emit_tel's telemetry JSON is now built by string interpolation ('{"outcome":"'"$2"'","signal":"'"$3"'"}') instead of jq -nc --arg. I checked every call site (lane-stop-gate.sh:639,665,675): all three pass only fixed literal strings from a closed vocabulary (completion-signaled/stopped-after-nudge/nudged, sentinel/marker/none), never a variable derived from the payload, sentinel, marker path, or cwd. There is no exploitable injection path in this diff. (Flagging for awareness only, not as a finding: this pattern is fragile if a future edit ever threads untrusted data through emit_tel's $2/$3, since it would then skip JSON escaping entirely.)
  • Path/id validation (GATE_ARM_ID_RE, the managed-path absolute-path guard, the lane_stop_gate_* key regex in gate_settings_options_to) is unchanged and still gates every value before it reaches a filesystem path or a jq --argjson array literal.
  • Arm-record parsing: the new single jq -j multi-field extraction (gate_load_arm_record) preserves the same fail-closed behavior as the old jq -ec . validation + per-field captures — a malformed/non-object record still yields fewer than 5 NUL-separated fields and is refused before any ownership/TTL check runs.

I did not find a security vulnerability (injection, authz/trust-boundary bypass, or unsafe secret handling) introduced by this diff. The PR body's own differential harness (95 scenarios, 93 identical / 2 disclosed-and-intentional) and the new regression tests (cases 49-51) corroborate this from the behavioral side.

No security issues found in this pull request.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Last security-reviewed head: fd0e7578defdde61f21627e65250789192aa5d38. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fd0e7578de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/autonomy/hooks/lane-stop-gate.sh Outdated
Comment thread plugins/autonomy/hooks/lane-stop-gate.sh Outdated
@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 7m 2s —— View job


Code review complete

  • Load /review:code-review instructions
  • Gather context (diff, changed files, CLAUDE.md/AGENTS.md conventions)
  • Review lane-stop-gate-lib.sh changes
  • Review lane-stop-gate.sh changes
  • Review test additions
  • Check docs (README/CHANGELOG/plugin.json)
  • Post findings

Summary

Reviewed the process-creation reduction in lane-stop-gate.sh / lane-stop-gate-lib.sh against origin/main. The refactor (in-process _to helpers, batched jq passes, builtin replacements for grep/sed/tr/dirname/date) is sound and the logic changes I traced by hand (sentinel escaping via the shell's own ERE engine, the NUL-separated multi-key jq batching, the arm-record single-pass read) preserve the documented pre-PR verdicts.

1 finding, posted as 3 inline comments (same root cause, three sites):

A redirection-order bug — <file 2>/dev/null instead of 2>/dev/null <file — leaks "Permission denied" to real stderr when the target file exists but is unreadable, because bash applies redirections left to right and the stderr silencing only takes effect after the failed open. This PR already knows about and fixes exactly this bug in one place (gate_file_mentions, via commit fd0e7578, pinned by test case 51), but three new call sites introduced by this same PR have the reversed, still-buggy order:

  • lane-stop-gate-lib.sh:78gate_resolve_plugin_name, run on every hook invocation for unanchored --plugin-dir installs, before any early exit.
  • lane-stop-gate-lib.sh:313gate_settings_options_to, read for every managed/user settings file during option resolution. This is the most reachable of the three: case 51 only unreadable-tests the pre-filter path (gate_file_mentions), which exits before option resolution ever runs; a session that reaches gate_option_to by another route (e.g. an arm id) with a locked-down managed-settings.json or user settings.json would leak per Stop, uncaught by the new suite.
  • lane-stop-gate.sh:334gate_load_arm_record. Lower likelihood (the arm record lives under this plugin's own data directory) but the same bug class.

Functionally these still fail safe (no verdict change — I traced that a failed group redirection skips the enclosed jq entirely, leaving the result arrays at their zero-initialized "no verdict" state), so this is a stderr-noise regression rather than a gate-bypass, but it's the exact per-turn noise class this PR's own second commit was written to eliminate. Suggested fixes (swap the redirection order to match gate_file_mentions) are attached inline.

Minor, not filed as a finding: the README's new "Hook cost" table has 6 rows; the PR description's verification table has 7 (missing the "Env-only enable claim" row). Doc-only, no action needed unless you want the table to match.

Verification claims in the PR body (differential harness, strace ceilings, affected-tests.sh run) are author-reported and were not independently re-run here, per the review lane's scope.

Comment thread plugins/autonomy/hooks/lane-stop-gate-lib.sh Outdated
Comment thread plugins/autonomy/hooks/lane-stop-gate-lib.sh Outdated
Comment thread plugins/autonomy/hooks/lane-stop-gate.sh Outdated
Comment thread plugins/autonomy/hooks/lane-stop-gate-lib.sh Outdated
Comment thread plugins/autonomy/hooks/lane-stop-gate-lib.sh Outdated
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@claude claude Bot mentioned this pull request Sep 6, 2026
kyle-sexton added a commit that referenced this pull request Sep 7, 2026
…ners (#3878)

<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
No linked issue

## Summary

Drop leftover process creations on the hottest Bash paths in this
marketplace: the shared hook library every always-on hook sources, the
always-on formatter Write/Edit paths (typos, ruff, biome, bash,
powershell, go, actionlint, eol, markdown), the always-on
desktop-notification Notification path, always-on guardrails verifiers,
and CI scanners that used to spawn once per file, per plugin, or per
allowlist entry.

## Fix

GNU Bash runs command substitution in a subshell even for builtins
(Command Substitution, [Bash Reference
Manual](https://www.gnu.org/software/bash/manual/html_node/Command-Execution-Environment.html);
[Greg's Wiki](https://mywiki.wooledge.org/CommandSubstitution)).
Cygwin's `fork` is a non-copy-on-write Win32 `CreateProcess` ([Cygwin
User's Guide, Process
Creation](https://ftp.cygwin.com/cygwin-ug-net/highlights.html)): "fork
will almost certainly always be inefficient under Win32."

### Shared hook library (`lib/hook-utils.sh`, synced to 17 carriers,
patch bump)

Same `_to` / in-process pattern as #3838, #3732, and #3678:

- `hook::json_escape_to` deletes residual C0 bytes with parameter
expansion instead of `printf | tr -d`
- `hook::emit_channels` writes through `_to` instead of
`$(hook::json_escape …)`
- Fractional `read -t` landed in bash-4.0-alpha (CHANGES).
`hook::read_supports_fractional_timeout` is `BASH_VERSINFO`; no TMPDIR
probe file
- `hook::notice_once` reads the marker with `read`, creates the
directory only when missing, and prunes stale markers once per process
- `hook::bash_parse_segments` walks `${cmd:i:1}` instead of `read -N1`
from a process substitution, and `$'…'` bodies decode through
`ansi_c_decode_to` (`printf -v`)
- `hook::repo_root_to` / `hook::repo_relative_path_to` write in this
shell so callers skip a leftover capture around git or builtins-only
work

Isolation `$(source …)` forks are unchanged (#3685).

### typos-format (always-on Write|Edit|NotebookEdit)

- Basename via `${FILE##*/}` (plus a backslash trim), not `basename(1)`
- `repo_root_to` / `repo_relative_path_to` instead of capture subshells
- Directory existence check instead of `$(cd && pwd)`
- `command -v typos` is no longer captured; the later exec looks the
name up on PATH

### Remaining always-on formatters (ruff, biome, bash, powershell, go,
actionlint, eol, markdown)

Same leftover class as typos-format, now applied to every always-on
formatter that still captured `_to` helpers or spawned `basename` /
leftover `cd && pwd`:

- `FILE_BASE` is `${FILE##*/}` (and a backslash trim)
- `repo_root_to` / `repo_relative_path_to` write in-process
- `$(cd && pwd)` canonicalize is an existence check on the path git
already answered (ruff, biome, bash-format EditorConfig walk)
- Nested `$(normalize_path "$(physical_path …)")` in powershell-format
uses the `_to` forms
- `command -v ruff|biome|goimports` is no longer captured
- markdown-format keeps physical `pwd -P` containment and config
discovery; leftover helper-capture and membership dirname on the
root-resolution path are gone

### desktop-notification (always-on Notification)

- Field extract fuses into `hook::buffer_stdin_to` so completeness and
`.notification_type` / `.message` share one jq process
- C0 stripping is parameter expansion, not `printf | tr`
- `repo_root_to` writes in-process; OSC 9 / BEL use `printf -v`;
`terminalSequence` uses `json_escape_jq_to`
- `uname` stays so tests can PATH-stub Darwin; git for `repo_root` stays

### guardrails verifiers (always-on PostToolUse / PreToolUse)

- `skill-reference-verify`, `stale-path-verify`, and `cli-flag-verify`
call `repo_root_to` / `repo_relative_path_to` in-process
- `hardcoded-path-check` and `secret-pattern-detection` use
`normalize_path_to` instead of leftover `$(hook::normalize_path)`
captures
- Isolation `$(source …)` forks are unchanged (#3685)

### CI scanners

- Orphaned-fixture scan: one `*.test.*` index, cached `evals.json`
`files[]`, in-shell ERE escape. Unquoted `\\` matches one backslash (a
quoted `'\\'` arm is two chars and leaves `\b` as a word boundary)
- Purged-em-dash scan: one `git ls-files -z` with every `:(glob)`
pathspec; in-process component-wise attribution so `*` cannot cross `/`.
`--list` stdout is byte-identical to origin/main
- Cross-plugin source drift: one `find plugins` plus one `sha256sum` of
2+ cluster paths. Discover stdout is byte-identical to origin/main
- Discriminating-test-skips / silent-skips: one awk per corpus (`FNR` +
`FILENAME`; mawk has no `ENDFILE`)
- Hook-exec-form: one jq over every `hooks.json` and one over every
`plugin.json` (`input_filename` attributes rows). Unreadable
`hooks.json` still fails closed via per-file fallback; unreadable
manifests are still skipped

Hook-specific leftover-fork work already in flight (#3873, #3872, #3871,
#3870, #3869, #3851, #3849, #3779, #3880, #3886) is out of scope here.

## Verification

Independent census re-derived spawn counts from `84adf87b` vs `cdb93f61`
without inheriting implementer figures. Kernel census `strace -f -e
trace=clone,clone3,fork,vfork,execve`; counter over duration; 3
identical trials.

**always-on formatters** (this revision vs `84adf87b`):

| Hook | clones before | clones after | execve before | execve after |
|---|---|---|---|---|
| ruff-format no-config skip | 14 | 10 | 4 | 4 |
| powershell-format no-settings skip | 23 | 16 | 7 | 7 |
| bash-format no-EditorConfig (ShellCheck finding) | 17 | 13 | 6 (1
`basename`) | 5 (0 `basename`) |

**guardrails** (this revision):

| Hook | clones before | clones after | execve |
|---|---|---|---|
| skill-reference-verify Write, no skill refs | 18 | 17 | 8 unchanged |
| secret-pattern-detection clean Write | 10 | 8 | 4 unchanged |

Secret-pattern absolute counts are with `CLAUDE_PLUGIN_ROOT` set (Claude
Code always sets it). Without that env the leftover `PLUGIN_ROOT=$(cd …
&& pwd)` fallback adds one clone on both sides (11→9); the drop of 2 is
the same.

**CI scanners** (successful execve, exclude ENOENT; earlier commits on
this PR):

| Gate | origin/main or prior HEAD | HEAD |
|---|---|---|
| purged-em-dashes `--list` | 478 | 9 |
| cross-plugin-source-drift `--check` | 181 | 4 |
| discriminating-test-skips | 316 (awk 312) | 5 (awk 1) |
| silent-skips | 120 (awk 118) | 4 (awk 2) |
| hook-exec-form `--check` | 196 execve, jq 96, tr 96, clones 292 | 7
execve, jq 2, tr 0, clones 9 |

`--list` / discover stdout for the two listing gates is byte-identical
to origin/main.

**Local `scripts/affected-tests.sh --run`:** 153 shell suites passed or
were skipped; 14 NOT RUN python/mjs ecosystems (exit 3, expected on this
runner). No `FAIL`. Including: `lib/hook-utils.test.sh` PASS=323;
bash-format PASS=54; eol-normalizer PASS=54; markdown-format PASS=174;
powershell-format PASS=17; cli-flag-verify PASS=92; hardcoded-path-check
PASS=118; secret-pattern-detection PASS=86; skill-reference-verify
PASS=140; stale-path-verify PASS=108. ruff/biome/go/actionlint
behavioral cases skipped here (binaries absent); skip-path and source
pins still ran. `session-event-log.test.sh` PASS=53 isolated under the
fan-out.

**CI on `cdb93f61`:** lint, hook-utils, test-linux (0–3), test-windows,
changes, ci-status, and managed-files-guard all succeeded.
https://github.com/melodic-software/claude-code-plugins/actions/runs/34066676378
https://github.com/melodic-software/claude-code-plugins/actions/runs/34066676488

## Related

Refs #3838, #3732, #3678, #1979, #3488, #2891. Same leftover-fork class
as open PRs #3849 / #3851 / #3869 / #3873 / #3872 / #3871 / #3870 /
#3779 / #3880 / #3886 (those stay hook-specific). N/A for a dedicated
issue.

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-fdfdc962-be1b-4c9c-9833-3aec57852330?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-fdfdc962-be1b-4c9c-9833-3aec57852330&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
claude and others added 3 commits September 7, 2026 09:56
The Stop hook fires on every turn of every session, gated or not. Its
interactive default path (no gate footprint in any settings file) cost 4
process creations and 2 launches (grep, uname); it now costs 1 and 1
(uname), the managed-settings platform primitive. An enabled lane's first
unsignaled stop went from 48 creations and 18 launches to 10 and 5, a
signaled stop from 44/16 to 9/4, an armed lane's stop from 60/20 to 11/5.
Counted with strace -f -e trace=clone,clone3,fork,vfork,execve from a
staged install; process counts are the proxy because a spawn is ~1 ms on
this Linux host against 180-2,841 ms on the #3508 Windows hosts.

The cost was in how the work was written, not what it was: every $( )
capture of a lib helper that is only parameter expansion has a _to <var>
form; the five per-field printf | jq | tr payload reads are one
hook::jq_fields pass; the three per-key settings reads are one jq per
settings file, read once and answered from memory; the arm record is read
in one jq pass instead of five; the grep -q settings scan is a builtin
read; the sentinel escape (sed) and match (grep -E) are the shell's own;
uname runs once per stop with its redirection on the enclosing group; the
telemetry data object is assembled from its closed vocabulary. uname -s
stays the platform primitive, hook::buffer_stdin and its validation pass
belong to the synced shared library (untouched, as are all 17 copies),
and the block decision is still one jq.

Verdicts are unchanged: 95 old-versus-new scenarios compared byte for
byte on rc, stdout and marker/ledger side effects. One disclosed
divergence, a configured sentinel that itself holds a newline: grep read
that newline as a pattern separator and authorized on either half; the
shell match authorizes only the whole token standing alone. No launcher
writes such a token; the suite pins the choice.

The suite pins the budget by trace: exactly 1 creation and 1 launch on
the default path, ceilings of 10 and 5 on the enabled block path (room
left for the shared library's share), and no dirname, tr, sed, grep,
cksum or date on either. Moving one redirection back inside its
substitution fails it (2 vs 1; 11 vs 10).

Closes #3515. Parent #3508; the in-file approach follows #3779.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
`gate_file_mentions` replaced the pre-filter's `grep -q lane_stop_gate
"$f" 2>/dev/null` with a builtin NUL-chunk read, but wrote the loop's
redirections as `done <"$1" 2>/dev/null`. Bash applies a command's
redirections left to right, so the open was attempted before stderr was
silenced: a settings file that exists but this hook may not read printed
"Permission denied" where `grep` had been silent. The gate runs on every
Stop of every session, so that is per-turn noise, and it reaches the user
even though the harness ignores stderr from a hook that exits 0.

Swapping the two redirections is the whole fix. The verdict is unchanged
in both directions -- an unreadable file still yields no chunk and
returns 1, contributing no verdict, so the gate stays off and the stop is
allowed -- and only stderr discriminates, so new case 51 asserts stderr
is empty while re-asserting the verdict. Verified by running the suite
as an unprivileged user, where the case runs for real: it passes on the
fixed tree and fails with the "Permission denied" line on a tree with the
old ordering. Where chmod 000 denies nothing (root, or a filesystem
without POSIX modes) the case skips visibly rather than passing vacuously.

The strace budget is unchanged, re-measured rather than assumed: the
default path is still exactly 1 creation and 1 launch (`uname`), and the
enabled block path still 10 creations and 5 launches (`jq -e .` from the
shared library's `hook::buffer_stdin`, the `jq -j` payload pass, `uname`,
the `jq -j` settings pass, the `jq -nc` block decision).

Suite is PASS=103 FAIL=0 unprivileged, PASS=102 with the visible skip.

Refs #3515.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
Trust EPOCHSECONDS only when Bash 5.0+ provides it; on older bash an
inherited value is an ordinary variable a repo env block can set and
would choose the arm-record TTL verdict. Preserve CR in
last_assistant_message so LANE-STOP\r-OK cannot become LANE-STOP-OK.
Silence stderr before the input open on the remaining group redirections
(gate_resolve_plugin_name, gate_settings_options_to, arm-record load).

Suite pins the pre-5.0 EPOCHSECONDS path, the embedded-CR token, and
unreadable-file stderr silence at each remaining site.

Refs #3515.

Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
@cursor
cursor Bot force-pushed the claude/3515-lane-stop-gate-perf branch from fd0e757 to ba2b0a2 Compare September 7, 2026 10:02
Case 54 left the user settings file from the CR-token cases in place, so
the hook still enabled from settings and blocked even though the arm
record was unreadable. Drop the leftover settings file first.

Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
@kyle-sexton
kyle-sexton enabled auto-merge (squash) September 7, 2026 13:34
cursoragent and others added 2 commits September 7, 2026 14:07
Keep the lane-stop-gate work current so auto-merge can land once lint is
green. No content conflicts: merge-tree against origin/main was clean.

Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
The NUL-split payload loop lived in a group, not a function, so `local f`
was invalid (SC2168) and the group's 2>/dev/null swallowed the diagnostic.
Use `_gate_pf` at script scope and unset it after the read. Case 53 now
initializes `got` before the nameref fill (SC2154) and isolates the
EPOCHSECONDS spoof to its subshell (SC2030/SC2031).

Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
@kyle-sexton
kyle-sexton merged commit 012aa85 into main Sep 7, 2026
12 checks passed
@kyle-sexton
kyle-sexton deleted the claude/3515-lane-stop-gate-perf branch September 7, 2026 14:12
kyle-sexton added a commit that referenced this pull request Sep 7, 2026
…it (#3529) (#3872)

Closes #3529

## Summary

`block-dangerous-git.sh` is the guard with the highest external-command
call-site count in the marketplace, but on the dispatched path it
executes nothing: its cost is forks that never exec, which a PATH shim
cannot see. On every Bash and PowerShell call the guard created three
such processes of its own. This PR removes the one that was this file's
to remove on the common path, and three more on the alias and lease
paths, in-file only. `lib/hook-utils.sh`, its per-plugin copies, and
`run-guards.sh` are untouched.

## Fix

- **Common path (every Bash/PowerShell call):** the eager
`SUBJECT=$(hook::extract_bash_subject ...)` at file scope fed a
telemetry envelope that is off by default and that the verdict never
reads. It is now derived inside `emit_tel`, behind the start-stamp and
sink gates.
- **Lease path (`--force-with-lease=<ref>:<hex>`):** `out="$(git ...
rev-parse --show-object-format 2>&1)"` cost two creations for one exec,
because bash execs a substitution's body in the substitution's own
subshell only when the body carries no redirection of its own. The
`2>&1` cannot move onto an outer group here (git's stderr is the
diagnostic the block message quotes, and an outer `2>&1` would send it
to the hook's stdout), so the body is now `exec git ...`: the subshell
becomes git. A missing git still lands in the same `*` error branch,
with bash's own "not found" text captured, and the wording of that
captured text changes: see the stderr note under Verification.
- **`!` alias reparse:** one `$(printf '%q')` per trailing argument is
now `printf -v`; `$(effective_dir ...)` around a builtins-only function
is now `effective_dir_to`, a nameref assignment (the default base is
read before the nameref is written, so the caller's scratch variable
then assigns `HOOK_EFFECTIVE_BASE`).
- `$(dirname ...)` was already gone from this file before this PR
(`${BASH_SOURCE[0]%/*}`); the scout's note was stale. The PowerShell
lane's `$(cd "$_HOOK_SELF/.." && pwd)` is left alone: it runs only when
`CLAUDE_PLUGIN_ROOT` is unset, which Claude Code never leaves unset.
- Guardrails **0.32.13** (`main` is 0.32.10; #3849 takes 0.32.11 and
#3869 takes 0.32.12, re-verified against `origin/main` and the open PR
list immediately before opening this). README hook-budget accounting
entry added per hook-budget Rule 1.

## Verification

**Kernel census** (`strace -f -e trace=clone,clone3,fork,vfork,execve`;
guard share = `run-guards.sh block-dangerous-git.sh` minus a no-op guard
dispatched the same way; this repository as cwd, `HOOK_TELEMETRY_SINK`
unset, `CLAUDE_PROJECT_DIR` empty; three identical repeats). strace
rather than a PATH shim or xtrace because the subject is a fork that
never execs, which neither of those can see. execve reported separately:
it does not move anywhere, which is the evidence this is latency, not
removed work.

| Scenario | creations before → after | execve before → after |
|---|---|---|
| Guard share, benign `git status --short` / `echo hello` | 3 → 2 | 0 →
0 |
| Guard share, blocked `git push --force origin main` / `git reset
--hard` | 3 → 2 | 0 → 0 |
| Guard share, lease `--force-with-lease=main:<40-hex>` | 5 → 3 | 1 → 1
|
| Guard share, `!` alias no trailing args | 5 → 3 | 0 → 0 |
| Guard share, `!` alias with three trailing args | 8 → 3 | 0 → 0 |
| Guard share, `!` alias whose body carries a lease | 7 → 4 | 1 → 1 |
| Guard share, PowerShell `git status` | 15 → 14 | 3 → 3 |
| Standalone `bash block-dangerous-git.sh`, benign | 9 → 8 | 3 → 3 |
| Whole Bash dispatcher line from hooks.json, benign | 35 → 34 | 3 → 3 |

**Deny paths still deny.** A/B against a pristine `origin/main` export
on exit code and stderr: 190 paired runs, 0 differences. 87 Bash
commands (every form the guard matches: `--force`, `-f`, `+refspec`,
`--mirror`,
bare/`=ref`/`=ref:movable`/`=ref:<40-hex>`/`=ref:<64-hex>`/`--force-if-includes`/`--no-force-with-lease`/`--dry-run`
lease spellings, `reset --hard`/`--h`, `clean -f/-fd/-fdx/--force`,
`checkout .`/`:/`/`-f`/`--pathspec-from-file`/exclude-only, `restore .`,
`switch --discard-changes`; their near-miss safe variants `reset
--keep`, `clean -n`, `checkout -- file`, `restore --staged .`, `branch
-D`, `filter-branch`, quoted text; `!` and inline aliases, `bash -c`/`sh
-c`/`env`/`sudo` wrappers, multi-segment lines) and 8 PowerShell
commands, standalone and dispatched, across a SHA-1 repo, a SHA-256
repo, a non-repo and a nonexistent `-C` dir (probe-failure path).
Verdict tally on the new tree: 51 blocked / 36 allowed Bash, 5 / 3
PowerShell.

**One stderr string is not identical, and it is not covered by those 190
runs.** Every run above had `git` on `PATH`. On a `PATH` carrying no
`git` the lookup now fails inside the `exec` rather than around it, so
the diagnostic the block message quotes changes wording:

- before: `<hook>: line 341: git: command not found`
- after: `<hook>: line 363: exec: git: not found`

Measured directly on both trees, running the real hook against a `PATH`
shadow that excludes `git` and nothing else: both exit **2**, both emit
the same `BLOCKED: ... hash format could not be determined (...)` frame
and the same remedy line, and the probe's own status is **127** on both,
so the same `*` branch runs and the push is blocked either way. The
claim this section previously made, "full stderr", overstated that:
verdicts and stderr agree everywhere the 190 runs reached, and on the
git-absent probe the only thing that moves is the wording of a quoted
error message on a path that still denies. (Related, same mechanism: a
`git` on `PATH` that is present but not executable is rc **126** on both
trees, and the `exec` form appends a second `cannot execute: Permission
denied` line.) The 0.32.13 CHANGELOG entry and the README hook-budget
entry state the same qualification.

**Permissive-normalization check.** Field reads are not touched by this
PR. Probed anyway: a CR mid-token (`git push --for\rce origin main`) and
after the token are both blocked, identically on standalone and
dispatched paths and on both trees, so the CR strip already lives in the
library's field read rather than in the dispatcher cache, and for this
guard it acts in the restrictive direction. BOM-prefixed `git`, a
zero-width space glued to `--force`, and U+2028 glued to `--force` are
allowed on both trees (the bytes make the token something bash would
hand to git verbatim, and git rejects it); no divergence introduced.

**Contract suite.** `block-dangerous-git.test.sh` 479 → 492, all passing
(re-run at the tip of this branch: `PASS=492 FAIL=0`, strace pins
included). The 13 new assertions are strace-based pins: benign share
exactly 2 creations / 0 execve; blocked share equals benign; `!` alias
reparse is benign + 1 (the shared parser's re-entry); trailing alias
arguments add 0; lease probe is benign + 1 creation and exactly 1
execve; and a per-process check that the `git rev-parse
--show-object-format` exec's parent itself exec'd. Skips visibly where
strace is absent. **Non-vacuity proven by mutation**, each change
reverted alone: eager subject → benign pin fails (3); `exec` removed →
lease pins fail (4, `extra-fork`); `$(printf)` restored → trailing-args
pin fails (6); `$(effective_dir)` restored → alias pin fails (4).

**Gates.** `scripts/affected-tests.sh --run`: every selected suite
passes except
`plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh`
(2 of 24, PS4 trace probe), which fails identically on a clean
`origin/main` export, so it is pre-existing and host-specific, not this
change. `check-changelog-parity.sh` `--check`, `--check-order`,
`--check-bump origin/main`, `--check-preserved origin/main`: all four
pass. `check-killswitch-hoist.sh` passes. `check-purged-em-dashes.sh`
passes. shellcheck and shfmt clean on both shell files;
markdownlint-cli2 clean on CHANGELOG and README.

**Acceptance criteria in #3529, stated plainly.** (1) Spawn count
before/after: reported above, by kernel trace, a superset of the
PATH-shim count the issue asked for. (2) "No more than 2 external
process spawns on the common path": this guard's own share is now 0
execs and 2 forks; the two forks are `$(hook::buffer_stdin)` and the
shared parser's `< <(printf ...)`, both `lib/hook-utils.sh` (#3740,
#3838) and outside this PR's fence, so that line is not closed from
inside this file. (3) "A non-`git` command exits before any spawn": this
guard spawns nothing of its own on any command; the dispatcher's two
`jq` execs are batch-wide since #3788 and not attributable to this
guard. (4) Behavioural tests pass and every form blocked before is still
blocked (190/190 A/B on verdict). (5) Wall-clock alongside
concurrent-process count: not reported. This Linux host's spawn floor is
under a millisecond, so a timing here would not transfer to the Windows
spawn tax the parent describes; the process count is the durable figure.

## Related

- Refs #3508 (parent). Its "per-field jq" diagnosis does not apply to
this guard: since #3788 the dispatcher buffers stdin and primes jq once
per batch, so no individual guard contributes a jq spawn; the remaining
cost here was forks that never exec.
- Precedent for the mechanism and the strace pin: #3779 (context-guard),
#3849 (guardrails 0.32.11), #3869 (guardrails 0.32.12), #3851, #3870,
#3871.
- Version-chain siblings: #3849, #3869 (entries kept verbatim; this PR
adds only 0.32.13).
- Out of fence, left for their owners: `$(hook::buffer_stdin)` fork-free
form in #3740 / #3838; the PowerShell lane's 14 creations live in
`lib/powershell/ps-command.sh`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob

---
_Generated by [Claude
Code](https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(autonomy): Stop hook: lane-stop gate averages 27.6s and timed out 73x - reduce process spawns

3 participants