You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Three claims in the original body are superseded. See the correction comments. Do not work from the original acceptance criteria — they conflict with the marketplace hook budget and with the child issues.
Superseded: the acceptance criteria
The original body proposed "median PreToolUse hook-stack wall-clock under 2 s" and "no hook exceeds 5 external process spawns", while every child issue says "no more than 2 spawns". Three different bars coexisted. The binding bar is docs/conventions/hook-budget/README.md (adopted #1809, surfaced via .claude/rules/hook-budget.md):
Surface
Budget
Per tool call (PreToolUse + PostToolUse, one matcher)
<= 1 s typical, <= 2 s worst-case
Per turn (Stop / notification-shaped)
<= 500 ms
Measured as parallel wall time, not the sum. Note the original "under 2 s per hook" let a single hook consume the entire worst-case budget for the whole set — with 8 guardrails hooks on the Bash matcher, all could "pass" while the set failed the convention several times over. The Stop-event children were 4x over the per-turn ceiling under the old criterion.
Use instead, for every child:
Spawn count on the common path, reported before and after, measured by PATH shim (the drift-immune criterion).
The hook's measured share, by the convention's method (EPOCHREALTIME around direct invocation, >= 10 runs, concurrent launch), stated in the plugin README per hook-budget Rule 1.
The always-on set stays within <= 1 s typical / <= 2 s worst-case per tool call, or <= 500 ms per turn, reported alongside the spawn-cost baseline at measurement time.
Matcher-narrowing work should additionally cite docs/conventions/hook-precision/README.md, which owns what a hook fires on.
Superseded: "they serialize"
The original body said hooks "serialize" on one tool call. The convention states Claude Code runs matching hooks in parallel — "the wall is the max of the set under spawn contention, not the sum." The transcript evidence (up to 8 hooks timing out against one toolUseID) shows they contend, which is not the same as serializing. The corrected reading: parallel dispatch, with wall-clock set by the slowest hook plus contention among them. That is also why hook cost must never be presented additively.
Superseded: the flat spawn cost and the timeout
Spawn cost is bimodal (180-2,841 ms, 15.7x spread, at 501 concurrent processes), not the flat 661/872/908 ms quoted in the body. Never quote a spawn figure without concurrent_processes_at_sample.
"Against a 15 s timeout" is wrong as a universal. Actual configured timeouts: source-control 15 s, guardrails 60 s, context-guard 60 s, disk-hygiene Stop 20 s, claude-ops Stop 10 s, autonomy Stop 15 s.
Method pin (was missing)
Transcript counts: hook_cancelled attachment records with timedOut: true, reading durationMs / timeoutMs / hookName / command, across 236 session files under ~/.claude/projects/**/*.jsonl, 2026-08-23 to 2026-08-31.
The static call-site counts in the original body do not reproduce and should be treated as indicative only. An independent pass on shipped guardrails 0.29.24 yields, for block-dangerous-git.sh, 184 (with comments) / 163 (lines-with-match) / 44 (comment-stripped) — none equal to the published 127. The ordering holds under every method, so #3529's headline stands, but absolute counts need the exact command and version pinned before being quoted.
hook-utils.sh exists in 17 copies across plugins/*/hooks/, delivered by scripts/sync-hook-utils.sh. Changes go through that script with all copies verified identical.
Validate with scripts/affected-tests.sh --run.
Original body — SUPERSEDED by the block above. Kept for the record; do not work from its numbers or checklists.
Summary
On a Windows host, the marketplace hook set blocks the agent loop for hours per day. Over a 9-day window on one machine: 1,778 hook timeouts totalling 74,123 s (20.6 hours) of blocked wall-clock, mean 41.7 s each against a 15 s timeout.
The scripts are not the problem. Process creation on this host costs 0.3-0.9 s per spawn. Every hook pays that toll once for its own shell and again for each external command it invokes.
This is the parent issue for the root cause. Per-hook children are listed at the bottom.
Measured: process spawn cost
bash -c true x10 -> 6.614 s real = 661 ms/spawn (user time 0.090 s TOTAL)
bash --noprofile --norc -c true x10 -> 2.775 s real = 277 ms/spawn
jq -n 1 x10 -> 9.083 s real = 908 ms/spawn
cmd.exe /c exit x10 -> 8.719 s real = 872 ms/spawn
Two things to read off this:
User CPU time for 10 bash spawns is 0.090 s. ~99% of wall-clock is process-creation overhead, not script work. Optimizing script logic cannot fix this.
cmd.exe is as slow as bash. This is not MSYS/Git-Bash fork() emulation. It is native Windows process creation.
Healthy reference on Linux/macOS is ~3-5 ms/spawn. This host is ~150-200x slower.
Cost model for any hook: 0.66 s (its own bash) + N x 0.91 s (each jq). A hook making 5 jq calls costs ~5.2 s before executing a single line of its own logic.
Measured: fan-out per tool call
59 hook commands are registered across 16 events by 22 enabled plugins - 17 on PreToolUse, 22 on PostToolUse. They serialize:
Every Bash, PowerShell, and Edit call pays the whole stack.
Measured: worst offenders
hookName || command, by timeout count, from transcript hook_cancelled attachments with timedOut: true:
423x PreToolUse:Bash PR body / pr-issue-linkage gate avg 24.7s max 115.3s
418x PreToolUse:Bash git worktree add nesting invariant avg 26.0s max 115.3s
126x PreToolUse:Bash commit subject / PR title convention avg 70.5s max 162.8s
113x Stop unsurfaced hook failures avg 21.9s max 74.1s
82x PreToolUse:PowerShell hook-bypass attempts avg 75.1s max 134.1s
81x PreToolUse:PowerShell commit subject / PR title convention avg 81.8s max 146.5s
73x Stop lane-stop gate avg 27.6s max 66.8s
68x PreToolUse:PowerShell commit-message convention avg 78.0s max 134.1s
55x PreToolUse:PowerShell dangerous git commands avg 75.9s max 134.1s
52x Stop silent destructive-guard failures avg 31.8s max 57.9s
43x PreToolUse:PowerShell --no-verify bypass avg 76.1s max 134.1s
27x PreToolUse:Bash Windows drive-root /tmp writes avg 73.4s max 112.6s
10x PostToolBatch context-zone crossing avg 73.0s max 96.0s
Note every duration is 2-8x past the 15 s timeout. The guard is killed mid-run and never renders a verdict - the user pays 15-160 s for a check that does not complete. These guards are currently pure cost with zero protective value on this platform.
Static call-site counts (indicative, not runtime)
External-command call sites per script (jq|grep|sed|awk|date|git|python|realpath|dirname|basename|cut|tr). Many sit in branches, so this is an upper bound, not an executed-spawn count:
hook-utils.sh is sourced by every guardrails hook, so its 113 call sites are paid on every hook run that reaches them.
Environment / what is NOT the cause
Windows 11 Pro 26200, AzureAD-joined. Claude Code 2.1.251 (native install). Git Bash (MSYS).
Defender is not the cause.WinDefend service is Stopped (StartType Manual); Get-MpPreference and Get-MpComputerStatus both fail with "Provider load failure".
No AppLocker policy. AppInit_DLLs empty. Smart App Control off.
Prime suspect:Win32_DeviceGuard reports CodeIntegrityPolicyEnforcementStatus = 2 (WDAC kernel-mode policy enforced) with VirtualizationBasedSecurityStatus = 2. A WDAC policy in enforcement mode validates code integrity on every process launch, which fits 300-900 ms spawns - especially for unsigned MSYS binaries that miss the signature cache.
Not yet checked: fltmc filters requires elevation. Enumerating minifilter drivers is the remaining unknown.
Proposed work, in value order
1. Cut spawn COUNT, not script runtime. This is the whole game. Concretely:
Collapse multi-jq call sites into a single jq invocation per hook (hook::jq_fields already does this in places - apply it everywhere).
Replace grep/sed/cut/tr/basename/dirname subprocess calls with bash builtins (parameter expansion, [[ =~ ]]). Each one removed saves ~0.9 s on this class of host.
Avoid sourcing the full hook-utils.sh when a hook needs two helpers.
2. Consolidate hooks per event. 17 PreToolUse commands is 17 shells. A single dispatcher process that runs the guards in-process and returns one verdict would cut PreToolUse cost by roughly 17x on the spawn axis alone.
3. Narrow matchers. Several guards fire on every Bash call but only care about git/gh invocations. A matcher that excludes non-git commands removes most of the fan-out for free.
4. Add a platform escape hatch. A documented switch to disable the shell-based guard set on Windows hosts where spawn cost is pathological, so the fleet degrades to "unprotected but usable" instead of "protected in theory, unusable in practice".
5. Raise or make configurable the 15 s hook timeout - secondary. Currently every one of these runs is killed, so the guard provides no protection while still costing the full wall-clock.
Out of scope for this repo
The 872 ms native process-creation cost is a machine/IT problem (likely the enforced WDAC policy). Worth pursuing in parallel with IT - a code-integrity exclusion for the toolchain would recover most of the 20.6 hours without any change here. This issue covers making the hook set resilient to slow hosts regardless.
Per-hook children
To be filed separately so a Windows host can work them independently:
source-control: pr-issue-linkage gate (423 timeouts - highest single offender)
guardrails: secret-pattern-detection.sh / hardcoded-path-check.sh (PreToolUse:Edit, ~60 s each observed)
Acceptance criteria
Median PreToolUse hook-stack wall-clock on a Windows host is under 2 s.
No hook in the set exceeds 5 external process spawns on its common path.
A documented way to disable the shell-guard set per-platform exists.
Re-measuring the transcript corpus after the change shows a >90% reduction in timedOut: true hook attachments.
Method
Counts derived from ~/.claude/projects/**/*.jsonlhook_cancelled attachment records (timedOut: true, durationMs, timeoutMs) across 236 sessions, 2026-08-23 to 2026-08-31. Spawn timings from time over 10 iterations each.
Warning
Three claims in the original body are superseded. See the correction comments. Do not work from the original acceptance criteria — they conflict with the marketplace hook budget and with the child issues.
Superseded: the acceptance criteria
The original body proposed "median PreToolUse hook-stack wall-clock under 2 s" and "no hook exceeds 5 external process spawns", while every child issue says "no more than 2 spawns". Three different bars coexisted. The binding bar is
docs/conventions/hook-budget/README.md(adopted #1809, surfaced via.claude/rules/hook-budget.md):PreToolUse+PostToolUse, one matcher)Stop/ notification-shaped)Measured as parallel wall time, not the sum. Note the original "under 2 s per hook" let a single hook consume the entire worst-case budget for the whole set — with 8 guardrails hooks on the Bash matcher, all could "pass" while the set failed the convention several times over. The Stop-event children were 4x over the per-turn ceiling under the old criterion.
Use instead, for every child:
EPOCHREALTIMEaround direct invocation, >= 10 runs, concurrent launch), stated in the plugin README per hook-budget Rule 1.Matcher-narrowing work should additionally cite
docs/conventions/hook-precision/README.md, which owns what a hook fires on.Superseded: "they serialize"
The original body said hooks "serialize" on one tool call. The convention states Claude Code runs matching hooks in parallel — "the wall is the max of the set under spawn contention, not the sum." The transcript evidence (up to 8 hooks timing out against one
toolUseID) shows they contend, which is not the same as serializing. The corrected reading: parallel dispatch, with wall-clock set by the slowest hook plus contention among them. That is also why hook cost must never be presented additively.Superseded: the flat spawn cost and the timeout
concurrent_processes_at_sample.Method pin (was missing)
Transcript counts:
hook_cancelledattachment records withtimedOut: true, readingdurationMs/timeoutMs/hookName/command, across 236 session files under~/.claude/projects/**/*.jsonl, 2026-08-23 to 2026-08-31.The static call-site counts in the original body do not reproduce and should be treated as indicative only. An independent pass on shipped guardrails 0.29.24 yields, for
block-dangerous-git.sh, 184 (with comments) / 163 (lines-with-match) / 44 (comment-stripped) — none equal to the published 127. The ordering holds under every method, so #3529's headline stands, but absolute counts need the exact command and version pinned before being quoted.Prior art, and the delivery constraint
hook::jq_fields,strip_quoted_spans, a deferredgit rev-parseprobe, a 65-spawn-per-Bash-call census, and a 437 ms unit price — plus the reason fix(guardrails): restore PreToolUse enforcement — guards were killed at their timeout #1385 did not merge: four contract-test regressions, two failing open.hook-utils.shexists in 17 copies acrossplugins/*/hooks/, delivered byscripts/sync-hook-utils.sh. Changes go through that script with all copies verified identical.scripts/affected-tests.sh --run.Original body — SUPERSEDED by the block above. Kept for the record; do not work from its numbers or checklists.
Summary
On a Windows host, the marketplace hook set blocks the agent loop for hours per day. Over a 9-day window on one machine: 1,778 hook timeouts totalling 74,123 s (20.6 hours) of blocked wall-clock, mean 41.7 s each against a 15 s timeout.
The scripts are not the problem. Process creation on this host costs 0.3-0.9 s per spawn. Every hook pays that toll once for its own shell and again for each external command it invokes.
This is the parent issue for the root cause. Per-hook children are listed at the bottom.
Measured: process spawn cost
Two things to read off this:
cmd.exeis as slow asbash. This is not MSYS/Git-Bashfork()emulation. It is native Windows process creation.Healthy reference on Linux/macOS is ~3-5 ms/spawn. This host is ~150-200x slower.
Cost model for any hook:
0.66 s (its own bash) + N x 0.91 s (each jq). A hook making 5 jq calls costs ~5.2 s before executing a single line of its own logic.Measured: fan-out per tool call
59 hook commands are registered across 16 events by 22 enabled plugins - 17 on PreToolUse, 22 on PostToolUse. They serialize:
Every Bash, PowerShell, and Edit call pays the whole stack.
Measured: worst offenders
hookName || command, by timeout count, from transcripthook_cancelledattachments withtimedOut: true:Note every duration is 2-8x past the 15 s timeout. The guard is killed mid-run and never renders a verdict - the user pays 15-160 s for a check that does not complete. These guards are currently pure cost with zero protective value on this platform.
Static call-site counts (indicative, not runtime)
External-command call sites per script (
jq|grep|sed|awk|date|git|python|realpath|dirname|basename|cut|tr). Many sit in branches, so this is an upper bound, not an executed-spawn count:hook-utils.shis sourced by every guardrails hook, so its 113 call sites are paid on every hook run that reaches them.Environment / what is NOT the cause
WinDefendservice is Stopped (StartType Manual);Get-MpPreferenceandGet-MpComputerStatusboth fail with "Provider load failure".AppInit_DLLsempty. Smart App Control off.Win32_DeviceGuardreportsCodeIntegrityPolicyEnforcementStatus = 2(WDAC kernel-mode policy enforced) withVirtualizationBasedSecurityStatus = 2. A WDAC policy in enforcement mode validates code integrity on every process launch, which fits 300-900 ms spawns - especially for unsigned MSYS binaries that miss the signature cache.fltmc filtersrequires elevation. Enumerating minifilter drivers is the remaining unknown.Proposed work, in value order
1. Cut spawn COUNT, not script runtime. This is the whole game. Concretely:
jqcall sites into a singlejqinvocation per hook (hook::jq_fieldsalready does this in places - apply it everywhere).grep/sed/cut/tr/basename/dirnamesubprocess calls with bash builtins (parameter expansion,[[ =~ ]]). Each one removed saves ~0.9 s on this class of host.hook-utils.shwhen a hook needs two helpers.2. Consolidate hooks per event. 17 PreToolUse commands is 17 shells. A single dispatcher process that runs the guards in-process and returns one verdict would cut PreToolUse cost by roughly 17x on the spawn axis alone.
3. Narrow matchers. Several guards fire on every Bash call but only care about
git/ghinvocations. A matcher that excludes non-git commands removes most of the fan-out for free.4. Add a platform escape hatch. A documented switch to disable the shell-based guard set on Windows hosts where spawn cost is pathological, so the fleet degrades to "unprotected but usable" instead of "protected in theory, unusable in practice".
5. Raise or make configurable the 15 s hook timeout - secondary. Currently every one of these runs is killed, so the guard provides no protection while still costing the full wall-clock.
Out of scope for this repo
The 872 ms native process-creation cost is a machine/IT problem (likely the enforced WDAC policy). Worth pursuing in parallel with IT - a code-integrity exclusion for the toolchain would recover most of the 20.6 hours without any change here. This issue covers making the hook set resilient to slow hosts regardless.
Per-hook children
To be filed separately so a Windows host can work them independently:
source-control: pr-issue-linkage gate (423 timeouts - highest single offender)source-control: git worktree nesting invariant (418)guardrails:block-convention-violation.shcommit subject / PR title (207 across Bash + PowerShell)guardrails: Stop - unsurfaced hook failures (113)guardrails:block-hook-bypass.shperf (105) - correctness tracked separately in fix(guardrails): block-hook-bypass.sh fails CLOSED on a Windows stdin read stall, blocking valid tool calls with 'hook stdin is not valid JSON' #3507guardrails:block-noncanonical-commit.sh(91)autonomy: Stop - lane-stop gate (73)guardrails:block-dangerous-git.sh(59)guardrails: Stop - silent destructive-guard failures (52)guardrails:block-windows-drive-tmp.sh(49)guardrails:block-no-verify.sh(44)guardrails:block-exported-msys-pathconv.sh(15)context-guard: PostToolBatch context-zone crossing (10)guardrails:secret-pattern-detection.sh/hardcoded-path-check.sh(PreToolUse:Edit, ~60 s each observed)Acceptance criteria
timedOut: truehook attachments.Method
Counts derived from
~/.claude/projects/**/*.jsonlhook_cancelledattachment records (timedOut: true,durationMs,timeoutMs) across 236 sessions, 2026-08-23 to 2026-08-31. Spawn timings fromtimeover 10 iterations each.