perf(hook-utils): build the telemetry envelope and read file_path with builtins - #3678
Conversation
…h builtins Phase 4b of the hook-performance program (#3623). With a telemetry sink wired, every guard that reached hook::emit_telemetry paid two jq processes, a mktemp and an rm per run, and hook::read_file_path cost a jq plus four realpath and two cygpath processes on every Write and Edit. On Windows Git Bash each spawn is tens of milliseconds. hook::emit_telemetry now assembles the envelope with shell builtins: hook::json_escape_jq_to escapes the string fields exactly as jq does and hook::json_compact_to compacts the caller's data object exactly as jq -c does, when that can be proven (jq's own output, pretty or compact, and the compact literal fallbacks the hooks carry all qualify). Anything it cannot prove (a \u or \/ escape, a raw control byte, a fraction or exponent, a non-object) goes to the jq path, which now runs jq -nc so both paths write the same bytes. The envelope is one compact line where it was jq's pretty-printed document before; the sink contract is one JSON document on stdin and the repo's own sink appends a byte-identical record for both forms. jq's absence is fail-open only on the fallback. hook::read_file_path reads stdin into the shell and takes .tool_input.file_path with hook::_fast_file_path_to when it can prove jq's answer: one string decoding to tool_input and one to file_path in the whole payload, tool_input a direct member of the root object whose value is a flat object, file_path a plain string inside it. Any other shape, a payload over 64 KiB, a NUL byte, or a non-ASCII \u escape falls back to the unchanged jq filter. The membership comparison then resolves the file, the project root and the temp roots with one batched realpath (hook::_physical_prime) into a per-process cache of plain arrays, and hook::under_temp_root reads the cache instead of resolving each candidate again. dirname is hook::dirname_to. Differential harness (origin/main lib vs this lib, separate processes): emit_telemetry 28 cases, 0 diffs (same document, new raw bytes equal jq -c of old, same sink record; 9 take the jq fallback); read_file_path 39 cases, identical stdout and status on all (7 take the jq fallback). typos-format PostToolUse Write with a sink: 24 external execs to 17, wall median 1130 ms to 861 ms. lib/hook-utils.test.sh PASS=278 FAIL=0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
scripts/sync-hook-utils.sh copies the builtin telemetry envelope and the builtin file_path reader into every carrying plugin; each carrier's manifest moves one patch above origin/main so the update cache delivers the change, with a CHANGELOG entry naming the shared-library change. Phase 4b of the hook-performance program (#3623). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Claude finished @kyle-sexton's task in 6m 7s —— View job Code review complete
SummaryTraced the new builtin JSON machinery in Independently confirmed (not just author-claimed):
Not independently verified — sandboxed to
One trivial nit, not worth a blocking comment: the PR body's own verification table (row 30, "tool_input nested in another object") labels the No blocking findings. |
|
Claude finished @kyle-sexton's task in 4m 26s —— View job Security review (
|
…e and sinks parse JSON Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Last security-reviewed head: |
|
Claude has reviewed this PR 4 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
|
Claude finished @kyle-sexton's task in 9m 41s —— View job Code review — PR #3678
SummaryThis is the code-review lane; per I read I also independently verified, rather than just taking the PR body's word for it:
No inline findings — I did not find a concrete, high-confidence correctness defect in the changed lines that a careful reviewer would block on. The one thing I could not do is execute anything (test suite, Worth checking before merge: the top-level |
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…N paths prove anything jq rejects a text with an invalid escape (\q, \uZZZZ, a trailing backslash) anywhere in it, so the fast file_path reader must not prove a value from such a text and the compactor must not splice it. The skeleton pass now deletes every well-formed escape from each string body with literal glob substitution and falls back to jq if a backslash survives. The string cap per payload rises from 800 to 2000 so a large Edit payload with many quoted strings still qualifies. Copies re-synced. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 6m 27s —— View job Code review — PR #3678
SummaryThis PR was already reviewed three times at head I hand-traced the new logic (I could not execute shell scripts in this sandbox — same limitation noted in the prior two review passes — so this is static analysis, not a test run):
No blocking findings. Not independently verified here (sandbox has no shell-execution approval in this lane): the differential-harness results, |
|
Claude finished @kyle-sexton's task in 2m 40s —— View job Security review (
|
…spawn on the unwired path The envelope is one compact line now, so block-windows-drive-tmp's suite parses it instead of matching pretty-printed spacing, markdown-format's unwired case expects the single stdin-probe jq (file_path is read with builtins), and the compactor's truncated-literal fixtures carry the spellchecker directive. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 22s —— View job Code review — PR #3678
SummaryThis invocation is triggered by one new commit since the last code-review pass,
Independently confirmed rather than taken on faith:
No blocking findings. This PR has now been reviewed five times across code-review and security-review lanes at three successive heads ( Not independently executed in this sandbox (no shell-execution approval in this review lane, same caveat as every prior pass): the differential harness, |
|
Claude finished @kyle-sexton's task in 44s —— View job Security review (
|
…sals Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
#3678 landed Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
No related issue: phase 8 of the hook-performance program, tracking issue #3623 ## Summary The hook-performance program landed nine PRs today (#3621, #3662, #3666, #3668, #3671, #3675, #3676, #3677, #3678, plus dotfiles #620). This PR records the outcome where the convention says it lives: the hook-budget convention gains a dated reference-figures section in spawn-equivalents with the harness sha256, and the four plugins that lacked a hook budget accounting row gain one under Requirements. Documentation only; no hook, script or registration changes. ## Fix - `docs/conventions/hook-budget/README.md`: a "Reference figures (2026-09-02, after the hook-performance program)" section with the harness identity, the before-and-after spawn-equivalents per surface (before at S = 33 ms, after at S = 18 ms on `main` `5e3d749cb`), the reference-host conversion, and a plain reading against the budget table: per-turn rows meet the 500 ms ceiling; per-tool-call rows sit at 1.4 to 1.9 s against the 1 s typical ceiling, with the guardrails dispatcher named as the whole of the remainder. - `plugins/typos-format/README.md`, `plugins/eol-normalizer/README.md`, `plugins/markdown-format/README.md`, `plugins/context-guard/README.md`: a "Hook budget accounting" section each, carrying the measured rows their CHANGELOG entries already state (36.3 to 26.0, 41.0 to 21.5, 41.6 to 32.0 spawn-equivalents; 11 to 2, 9 to 4 and 6 to 1 processes for context-guard), what changed, and the residual. guardrails and rate-limit-guard already carried one. - Version bumps with a documentation-only CHANGELOG entry: typos-format 0.6.37, eol-normalizer 0.6.30, markdown-format 0.11.40, context-guard 0.7.36. ## Verification - Final harness run on the installed cache at `main` `5e3d749cb`, `--runs 3`, S = 18 ms, valid, quiet host; every measured plugin's cache directory byte-compared against `origin/main` (0 stale files in 17); `enabledPlugins` unchanged against the pre-program snapshot; 52 `hooks.json` entries listed, every one `type: command`, no `async` row. The per-event block, the STATED CHECK and the reading against goal (B) are in the program's PLAN.md and DEVIATIONS.md on `perf/hook-performance-program`. - Per event (ms, slowest hook): PreToolUse:Bash 2,475 before to 1,599 after; PostToolBatch 1,254 to 282; UserPromptSubmit 975 to 297; in-repo PostToolUse:Write 13,225 to 1,949; in-repo PostToolUse:Edit 17,192 to 3,048. - `markdownlint-cli2` 0 issues on the five files; em dash counts unchanged; `scripts/check-changelog-parity.sh --check-bump origin/main` exit 0; `scripts/affected-tests.sh --explain` selects no suites (every changed file is a recorded no-suite class covered by a non-shell CI lane). ## Related - #3623 (tracking issue) - Merged today: #3621, #3662, #3666, #3668, #3671, #3675, #3676, #3677, #3678; dotfiles #620 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Pre-prune commit: bac012f (PLAN.md with the restated goal, the constraint 1 mapping table and the final run block; DEVIATIONS.md with every ruling, finding and evidence run). Durable outcomes graduated to: the outcome and follow-up comments on issue #3623, docs/conventions/hook-budget/README.md and the six plugin READMEs (#3679), docs/conventions/hook-telemetry/README.md (#3678), and issues #3680 to #3685. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…and emit_tel jq (#3732) <!-- CURSOR_AGENT_PR_BODY_BEGIN --> No linked issue ## Summary Phase 1 of a measurement-first shell-script performance pass: cut remaining process creations on the always-on hook hot path in `lib/hook-utils.sh` (17 plugin copies) and stop spending `jq` on wired-sink `{tool,subject,form}` telemetry. ## Fix Discovery ranked remaining waste after the hook-performance program (#3623). Guardrails already measured and rejected dispatcher `repo_root` priming, per-guard `dirname` replacement, and merging `hardcoded-path-check` git probes. This PR takes the three leftover cuts that had a drift-immune counter and a matching repo pattern: 1. **`hook::buffer_stdin` timeout/slice wrappers.** GNU Bash runs command substitution and process substitution in a subshell even for builtins. The suite already documented those two startup forks. `_to` helpers write into caller variables with `printf -v`. 2. **`hook::repo_root` `git | tr`.** Same in-shell CR strip `buffer_stdin` already uses (`${root//$'\\r'/}`). 3. **`hook::json_str_object_to` + seven always-on Bash-guard `emit_tel` builders.** Compact `{tool,subject,form}` is now builtin and byte-identical to `jq -nc --arg …`. The envelope was already builtin (#3678). Unwired default path is unchanged (zero telemetry spawns). Carrying plugin versions are bumped so consumers receive the synced copies. After merging `main` twice, those versions were re-bumped past the numbers `main` shipped in the meantime, and every earlier heading this branch introduced (including claude-ops `0.41.14`) is kept so `--check-preserved` holds. Phase 2 (not in this PR): context-guard resolver in-process; skill/stale-path grep short-circuit; `affected-tests.sh` manifest cache. ## Verification Headline counters on this Linux host (wall-clock refused as binding; Windows Git Bash remains the consumer cost model): | Subject | Before | After | |---|---|---| | `buffer_stdin` timeout/slice | two subshells (`$$` ≠ `$BASHPID`) | in-process (`$$` = `$BASHPID`) | | `hook::repo_root` | `spawns=2 [1 git 1 tr]` | `spawns=1 [1 git]` | | `emit_tel` data object | `spawns=1 [1 jq]` | `spawns=0` | | emit compact bytes | `{"tool":"Bash","subject":"git status --short","form":""}` | identical (`cmp`) | `bash lib/hook-utils.test.sh`: PASS=286 FAIL=0. Critical guard contracts: block-no-verify 238, block-dangerous-git 479, block-hook-bypass 576, block-noncanonical-commit 213, block-convention-violation 70, block-windows-drive-tmp 214, block-exported-msys-pathconv 127, run-guards 66 — all FAIL=0. `scripts/affected-tests.sh --run`: 142 shell suites passed; 9 selected non-shell suites named as NOT RUN (CI lanes). Zero FAIL lines. Post-merge gates vs `origin/main`: `sync-hook-utils.sh --check` / `--check-bump`, `check-changelog-parity.sh --check` / `--check-bump` / `--check-preserved` / `--check-order`, `check-shell-portability.sh`, and `shellcheck -x lib/hook-utils.test.sh` all OK. Re-ran `hook-utils.test.sh` (286/0) on the merge. ## Related Refs #3623, #1403, #3678. Follows `docs/conventions/hook-budget/README.md` (budget never relaxes) and `plugins/performance/reference/harness-integrity.md` (spawn count over two-pass wall-clock). <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-491558a0-20ba-4b4e-ad79-4f386f90c774?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> <a href="https://cursor.com/background-agent?bcId=bc-491558a0-20ba-4b4e-ad79-4f386f90c774&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> </div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
…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> <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> </div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
No related issue: phase 4b of the hook-performance program, tracking issue #3623
Summary
With
HOOK_TELEMETRY_SINKwired, every guard that reachedhook::emit_telemetrypaid two jq processes, amktempand anrmper run, andhook::read_file_pathcost a jq plus fourrealpathand twocygpathprocesses on every Write and Edit. On Windows Git Bash each spawn is tens of milliseconds; the phase 4a profile put the telemetry cost at about 1.2 s of the 2.5 s Bash dispatcher wall across five guards.Both functions now do their work with shell builtins and hand anything they cannot prove to the unchanged jq path.
Fix
hook::emit_telemetryhook::json_escape_jq_toescapes the string fields exactly as jq does (backslash, quote,\b \f \n \r \t, other C0 bytes and DEL as\u00xx, non-ASCII verbatim) andhook::json_compact_tocompacts the caller's data object exactly asjq -cdoes when that can be proven. jq's own output (pretty or compact), and the compact literal fallbacks the hooks carry, all qualify.\uor\/escape, a raw control byte, a fraction, an exponent, a 16-digit integer, a non-object, invalid JSON) goes to the jq path, which now runsjq -ncso both paths write the same bytes. jq's absence is fail-open only there.jq -nwithout-c, so today's envelope is jq's pretty-printed multi-line document (CRLF line endings with a Windows jq). The new envelope is one compact line. The sink contract is one JSON document on stdin; the repo's own sink appends a byte-identical record for both forms (proof below), and a compact line is also valid JSONL for a sink that appends raw envelopes. The hook-telemetry convention README now states the compact-line shape and that sinks must parse JSON rather than line layout.mktemp, norm, no temp file on the builtin path. The timestamp usesprintf -v '%()T'with adate -ufallback when printf binds nothing.jq absent) is rewritten: it asserted "returns 0 with no output" because the envelope needed jq; it now asserts the envelope is delivered with an empty PATH and with a failingjqshadow that is never invoked.hook::read_file_path.tool_input.file_pathis taken byhook::_fast_file_path_towhen it can prove jq's answer: the payload is one JSON value by the grammar (every string escape one jq accepts, no raw control bytes), exactly one string in the whole payload decodes totool_inputand exactly one tofile_path,tool_inputis a direct member of the root object whose value is a flat object (no nested object or array), andfile_pathis a plain string inside it. Key strings are compared after decoding escapes. A top-levelfile_path, atool_inputnested elsewhere, two keys, a non-string value, a payload over 64 KiB, a NUL byte, or a non-ASCII\uescape all fall back to the unchanged jq filter.%%on a 60 KB payload cost more than the jq process they would replace on this host).realpath(hook::_physical_prime) into a per-process cache of plain indexed arrays (Bash 3.2 safe), andhook::under_temp_rootreads the cache. The file's entry is forgotten after use so a later call in the same process re-resolves it.hook::dirname_to(builtin dirname with the.and/fallbacks)._to <var>variants store into a variable so the hot path forks nothing it does not need.No check was removed.
hook::json_escape(a different tool: it drops residual control bytes, which is right for a notice) is unchanged.Verification
Differential harness under
$TEMP/phase4b/(emit-diff.sh,rfp-diff.sh,exec-count.sh,rfp-time.sh): the OLD lib fromgit show origin/main:lib/hook-utils.shand the NEW lib run in separate bash processes on the same inputs.emit_telemetry (28 cases, 0 diffs, 9 take the jq fallback)
Columns:
documentisjq -c .of old vs new;compactis the NEW raw bytes vsjq -c .of the OLD (the builtin escaper and compactor reproduce jq's compact rendering byte for byte);sink rowis the record.claude/hooks/hook-telemetry-sink.shappends tohook-events.jsonlfor old vs new.timestampandduration_msare masked (nondeterministic across two processes) and the timestamp shape is asserted separately.data.toollookup on an array)read_file_path (42 cases: 41 identical, 1 intended difference, 7 take the jq fallback)
Same stdin, same environment, separate processes; stdout and return code compared.
Cases 38 and 39 are payloads jq rejects outright (an invalid escape anywhere in the text); the skeleton pass validates every escape in every string, so the fast path never proves a value from a text jq would refuse. Case 42 is the one behavior change on this function and it is intended: with jq absent the old reader could never extract a path (the jq filter failed, so every file was skipped); the builtin path needs no jq, so a well-formed payload is admitted. Every hook in this marketplace gates on
hook::require_jqorhook::require_jq_blockingbefore reaching the reader, so no shipped hook observes it.Exec count and wall time: typos-format PostToolUse Write, sink wired
plugins/typos-format/hooks/typos-format.shrun from a scratch copy of the plugin dir with the OLD and NEW lib swapped in (the carrier copy was never edited), on an in-repo scratch.md,HOOK_TELEMETRY_SINKpointed at a file sink,CLAUDE_PROJECT_DIRset. External programs counted from aPS4='+X ' bash -xtrace; wall is the median of 5 runs.A second run of the same harness on the final lib, on a busier host: old 24 execs / 1390 ms median, new 17 execs / 994 ms median.
By program, old: jq 7, realpath 4, cygpath 4, tr 3, rm 1, mktemp 1, git 1, basename 1, sink 1, typos 1. New: jq 5, cygpath 4, tr 3, realpath 1, git 1, basename 1, sink 1, typos 1. The remaining jq processes belong to the hook itself (
buffer_stdinvalidation, tool_name,build_data_json, findings), not to the lib; the four cygpath arehook::repo_relative_pathandexpand_8dot3on an 8.3-spelled temp candidate, both out of scope here.hook::read_file_pathalone (median of 7,CLAUDE_PROJECT_DIRset, in-project file):Suites (one at a time, this Windows host)
lib/hook-utils.test.sh.claude/hooks/hook-telemetry-sink.test.shlib/rewrite-guard.test.shplugins/guardrails/hooks/run-guards.test.shplugins/guardrails/hooks/block-windows-drive-tmp.test.sh/usr/bin/mkdir /tmp/xcases on this Windows machine; the telemetry assertions (now read through jq) passplugins/markdown-format/hooks/markdown-format.test.shFirst CI run failed on exactly those two suites (they grepped the pretty-envelope spelling and counted two jq spawns) plus a typos hit on two deliberate JSON-typo test literals; all three are fixed in the last commit.
scripts/affected-tests.sh --explainselects 142 suites for this change (not run on this host per the shared-host discipline; CI runs everything).Gates
shellcheck --rcfile .shellcheckrc lib/hook-utils.sh lib/hook-utils.test.sh: cleanbash scripts/check-shell-portability.sh origin/main: exit 0 (three\bsites annotatedportability-ok, they are bash ANSI-C backspace bytes, not GNU grep word boundaries; onerealpathmulti-operand site annotated)bash scripts/sync-hook-utils.sh --check: all 17 copies matchbash scripts/sync-hook-utils.sh --check-bump origin/main: exit 0 (origin/main re-fetched at 9f07fb5 before the bump)bash scripts/check-changelog-parity.sh --check-bump origin/main: exit 0markdownlint-cli2 plugins/*/CHANGELOG.md: 0 issues${var,,}outside the existing OSTYPE guard,%()Tguarded by adate -ufallbackRelated
🤖 Generated with Claude Code