# Your Claude Code Hooks Are Costing You Minutes a Day — Here's How I Measured It

> Source: <https://dev.to/bokuwalily/your-claude-code-hooks-are-costing-you-minutes-a-day-heres-how-i-measured-it-4im4>
> Published: 2026-08-20 11:04:09+00:00

If Claude Code feels sluggish lately, the culprit probably isn't the model — it's the pile of shell scripts you wired into it months ago and never looked at again. Going from $0/month to a real income in six months came down to a lot of small habits, and one of them was refusing to leave my Claude Code hooks unmeasured.

Claude Code has a feature called "hooks." It's a simple mechanism, wired up in `settings.json`

, that lets you inject arbitrary shell scripts before and after tool calls. Right now I have a dozen-plus hooks bundled across three types — PreToolUse, PostToolUse, and Stop — running everything from automatic git commits to latency reports, self-audits, and project categorization.

The problem is that **hooks run dozens of times in a single session**.

Say Claude Code edits files 10 times in one session. If a PostToolUse hook fires each time, the execution time of that one hook × 10 becomes pure waiting cost. A lot of people never notice this and just feel like "Claude Code got slower somehow." I was one of them — I once had a heavy Python-based process wired into my self-audit hook, and everything felt sluggish. It took me days to figure out why.

**Perceived "heaviness" is proportional not to the number of hooks, but to the latency of each one.**

Three hooks are fine if they all finish under 50ms. But a single hook with a p95 above 2000ms racks up 20 seconds of pure waiting after just 10 calls. The operator is doing nothing and losing 20 seconds. Run several sessions a day, and you're burning minutes — or tens of minutes — without realizing it.

You can't spot this loss by staring at the hooks section of `settings.json`

. All that's written there is a command string; nothing records how many milliseconds it takes. That's why **measurement is the only answer**.

What matters here is that you can **start with zero configuration changes**. The `hook-latency-wrap.sh`

script described below is just a shell script that takes an existing hook binary as an argument and wraps it. Without altering the original hook's behavior at all, it writes the elapsed time and exit code of each invocation to a log file in JSONL format. You don't need to rewrite your production hook logic to instrument it.

Most people using Claude Code for side projects or solo development get absorbed in just getting hooks working and never get around to measuring them. I was the same. Every time I added a hook, I felt "upgraded" and left it at that. But that's close to loading a car with so many parts that fuel economy tanks while you celebrate the "mods."

Precisely because Claude Code is an autonomous agent, the density of a single session matters. The time between issuing an instruction and getting a response shifts a lot with accumulated hooks. I think of it as "cutting hook latency = raising my own hourly rate." Behind a figure like ¥1.2M monthly revenue is a stack of unglamorous habits, including making the tools I use as fast as possible.

`$EPOCHREALTIME`

The wrapper script I'm showing here doesn't use Python — it measures with the bash builtin variable `$EPOCHREALTIME`

. This variable is available in bash 5.0 and later and returns the current time in `seconds.microseconds`

format (e.g. `1720000000.123456`

).

Why not call Python? Because it defeats the purpose if the measurement script itself becomes heavy. Python3 startup can cost tens to hundreds of milliseconds depending on the environment. If you invoke Python every time just to measure, that startup cost contaminates the measurement itself. With a bash builtin, you get the current time at microsecond resolution without spawning an additional process.

That said, if bash is older than 5.0 (like the default shell on older macOS), `$EPOCHREALTIME`

comes back empty. The implementation includes a Python3 fallback for that case. I'll cover it in the code walkthrough in the next section.

Here's how the whole system is structured.

```
settings.json
  └─ command: "hook-latency-wrap.sh  本来のhook.sh"
                        │
                        ├─ 本来の hook.sh を実行（動作は変わらない）
                        │
                        └─ 経過時間・終了コード を JSONL に追記
                                      │
                            ~/.claude/logs/hook-latency.jsonl
                                      │
                            hook-latency-report.sh [days]
                                      │
                              ターミナルに集計表を出力
                              （hook名・回数・mean・p95・max・fail）
```

The wrapper writes to JSONL, and the reporter reads the JSONL and aggregates it. Two scripts, one log file. The only change to settings.json is "prepend the wrapper's path to the command string."

It's 43 lines total. Here's the actual code, verbatim.

``` bash
#!/usr/bin/env bash
# hook-latency-wrap.sh — 任意の hook をラップして実行時間を JSONL に記録
#
# 使い方:
#   settings.json で command を以下に置き換える:
#     "command": "~/.claude/scripts/hook-latency-wrap.sh /path/to/hook.sh"
#
# 出力: ~/.claude/logs/hook-latency.jsonl  (1行 = 1呼び出し)
#   {"ts":"...","hook":"...","elapsed_ms":N,"exit_code":N}

set -uo pipefail

HOOK_BIN="${1:-}"
[ -z "$HOOK_BIN" ] && { echo "usage: $0 <hook-binary> [args...]" >&2; exit 64; }
shift || true

LOG_DIR="$HOME/.claude/logs"
mkdir -p "$LOG_DIR"
JSONL="$LOG_DIR/hook-latency.jsonl"

# EPOCHREALTIME = bash 5+ で SS.NNNNNN 形式（秒.マイクロ秒）
start_us=$(printf '%s' "${EPOCHREALTIME//./}" | sed 's/^0*//')
# 万一 EPOCHREALTIME が空(bash <5)なら python フォールバック
[ -z "$start_us" ] && start_us=$(python3 -c 'import time;print(int(time.time()*1000000))')

"$HOOK_BIN" "$@"
exit_code=$?

end_us=$(printf '%s' "${EPOCHREALTIME//./}" | sed 's/^0*//')
[ -z "$end_us" ] && end_us=$(python3 -c 'import time;print(int(time.time()*1000000))')

elapsed_ms=$(( (end_us - start_us) / 1000 ))
hook_name=$(basename "$HOOK_BIN")
ts=$(date -u +%Y-%m-%dT%H:%M:%S)

printf '{"ts":"%s","hook":"%s","elapsed_ms":%d,"exit_code":%d}\n' \
  "$ts" "$hook_name" "$elapsed_ms" "$exit_code" >> "$JSONL"

exit "$exit_code"
```

Three points worth calling out.

**① How timestamps are taken**

`$EPOCHREALTIME`

returns a string like `1720000000.123456`

. Stripping the dot with `${EPOCHREALTIME//./}`

gives `1720000000123456`

— an integer in microseconds. Leading zeros are trimmed with `sed 's/^0*//'`

. Taking the difference between start and end times in microseconds and dividing by 1000 gives elapsed time in milliseconds. The external `date`

command is used only to record the end timestamp (the `ts`

field); it isn't on the measurement critical path.

**② Preserving the original hook's exit code**

It matters that the wrapper ends with `exit "$exit_code"`

. Claude Code looks at a hook's exit code to determine errors. If the wrapper always returned 0, a failure in the original hook would never reach Claude Code. This design means inserting the wrapper doesn't change behavior as a hook.

**③ Writing in append mode ( >>)**

Because it appends with `>> "$JSONL"`

, the file doesn't get corrupted even when multiple hooks are called at the same time. JSONL is one record per line, so even if appends collide, the damage is limited to individual lines. The aggregation script uses `try/except`

to skip broken lines, so it's a non-issue in practice.

Here's the aggregation script in full as well.

``` bash
#!/usr/bin/env bash
# hook-latency-report.sh — hook-latency.jsonl を hook ごとに集計
# 使い方: ~/.claude/scripts/hook-latency-report.sh [days]
#   days=7 がデフォルト

set -uo pipefail
DAYS="${1:-7}"
JSONL="$HOME/.claude/logs/hook-latency.jsonl"
[ -f "$JSONL" ] || { echo "no data: $JSONL"; exit 0; }

python3 - "$JSONL" "$DAYS" <<'PY'
import sys, json, datetime, collections
log, days = sys.argv[1], int(sys.argv[2])
cutoff = datetime.datetime.now() - datetime.timedelta(days=days)

stats = collections.defaultdict(list)
fail = collections.Counter()
total_records = 0
with open(log) as f:
    for line in f:
        try:
            r = json.loads(line)
            ts = datetime.datetime.fromisoformat(r["ts"])
            if ts < cutoff:
                continue
            total_records += 1
            stats[r["hook"]].append(r["elapsed_ms"])
            if r.get("exit_code", 0) not in (0, ):
                fail[r["hook"]] += 1
        except Exception:
            continue

if not stats:
    print(f"no records in last {days}d")
    sys.exit(0)

# 集計: count, mean, p95, max
rows = []
for hook, vals in stats.items():
    vals_sorted = sorted(vals)
    n = len(vals_sorted)
    p95 = vals_sorted[min(n-1, int(n*0.95))]
    rows.append((hook, n, sum(vals_sorted)//n, p95, vals_sorted[-1], fail.get(hook, 0)))
rows.sort(key=lambda r: -r[3])  # p95 降順（遅いものを上に）

print(f"=== hook latency (last {days}d, {total_records} records) ===")
print(f"{'hook':<32} {'n':>5} {'mean':>7} {'p95':>7} {'max':>7} {'fail':>5}")
print("-" * 70)
for hook, n, mean, p95, mx, fl in rows:
    flag = " ⚠" if p95 > 1500 else ""
    print(f"{hook:<32} {n:>5} {mean:>6}ms {p95:>6}ms {mx:>6}ms {fl:>5}{flag}")
PY
```

The Python script is defined inline with a `<<'PY'`

heredoc. I didn't split it into a separate `.py`

file because I didn't want to create a "doesn't work unless you have both files" dependency. Hold on to this one script and aggregation is self-contained.

The core of the aggregation logic is the single line `p95 = vals_sorted[min(n-1, int(n*0.95))]`

. It computes the 95th-percentile index from the sorted list. The `min(n-1, ...)`

is a guard against out-of-range access when the sample count is small. Output is sorted by p95 descending, so **the most problematic hook is always at the top**. One glance tells you what to fix.

A `⚠`

is appended to the end of the line when p95 exceeds 1500ms. I picked that threshold on the judgment that "1.5 seconds of pure waiting per tool call is simply not acceptable."

Sample output looks like this.

```
=== hook latency (last 7d, 843 records) ===
hook                              n    mean     p95     max  fail
----------------------------------------------------------------------
self_audit_stop.sh              127  1823ms  3240ms  8102ms     0 ⚠
pre_git_guard.sh                 98   420ms   890ms  2100ms     2
hook-latency-wrap.sh            618    12ms    18ms    45ms     0
```

Looking at this table, you immediately see: "`self_audit_stop.sh`

has a p95 of 3240ms with a ⚠ — that's the top improvement target." Its mean is 1823ms, so even on average it takes 1.8 seconds. It was called 127 times, so over 7 days it consumed at least 1823ms × 127 ≈ **3 minutes 54 seconds** of pure waiting. These are numbers you only see once you measure.

The code itself is what you've read so far, but to actually run it, how you write the `settings.json`

side is the key. It looks like this.

```
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/scripts/hook-latency-wrap.sh ~/.claude/scripts/self_audit_stop.sh"
          }
        ]
      }
    ]
  }
}
```

You put the wrapper's path at the front of the `command`

field and pass the original hook path as the first argument. If there are additional arguments, write them after the original hook and they pass straight through via `"$@"`

. The only change is this one line — just move the original command behind `command`

.

When Claude Code invokes a hook, it **streams the tool-call context to stdin as JSON**. For PostToolUse it's a structure like `{"tool_name":"Edit","tool_input":{...},"tool_response":{...}}`

. Because the wrapper launches the original hook with `"$HOOK_BIN" "$@"`

without reading stdin, stdin is automatically inherited by the child process through bash's process inheritance. This part requires no special code — "doing nothing" is the correct answer.

Let's read the timestamp line carefully once more.

```
start_us=$(printf '%s' "${EPOCHREALTIME//./}" | sed 's/^0*//')
```

`$EPOCHREALTIME`

returns a string like `1720543200.847231`

on bash 5 and later. Replacing all dots with `//./`

yields `1720543200847231`

. That's a UNIX timestamp in microseconds. `sed 's/^0*//'`

strips leading zeros, but in practice UNIX timestamps don't have leading zeros, so this sed is mostly defensive code.

The same processing happens after completion, and the difference is computed.

```
elapsed_ms=$(( (end_us - start_us) / 1000 ))
```

Dividing the microsecond difference by 1000 gives milliseconds. Bash's integer arithmetic `$(( ))`

truncates the fractional part, so this yields integer milliseconds.

One caveat: **bash integers are a mix of 32-bit and 64-bit depending on the environment**. `1720543200847231`

is just under 16 decimal digits. A 64-bit integer (`long long`

) maxes out at 9,223,372,036,854,775,807, so it fits with plenty of room. macOS's bash 5 is a 64-bit build, so there's no problem. But if you use this on an embedded device or an old 32-bit Linux environment, the arithmetic could overflow. I don't think many people care that much, but it can surface as a cause when you hit the problem "a negative elapsed_ms got logged and broke the report" (covered later).

Look at the cutoff computation in the aggregation script.

```
cutoff = datetime.datetime.now() - datetime.timedelta(days=days)
```

And in wrap.sh, the timestamp is recorded like this.

```
ts=$(date -u +%Y-%m-%dT%H:%M:%S)
```

The `-u`

flag records in **UTC**. But on the Python side, `datetime.datetime.now()`

returns **local time**. For Japan Standard Time (JST), that's UTC+9.

This produces a skew. Concretely: if you run `hook-latency-report.sh 1`

at 8:00 in the morning JST, the cutoff becomes "24 hours ago in local time" (yesterday 8:00 JST). But the log's `ts`

is recorded in UTC, so the cutoff is yesterday 8:00 JST = yesterday 23:00 UTC. In other words, only "records after 23:00 UTC" actually get picked up, which in JST terms means "you only see the 9 hours since 8:00 this morning."

When the day count is large (like `hook-latency-report.sh 7`

), the impact is relatively small, but when you specify `1`

or `2`

, the 9-hour skew becomes non-negligible. To fix it properly, the cutoff should be compared in UTC — it should be changed to `datetime.datetime.utcnow()`

or `datetime.datetime.now(datetime.timezone.utc)`

. This is a known skew in the current implementation.

The error-detection code looks a bit odd at first glance.

```
if r.get("exit_code", 0) not in (0, ):
    fail[r["hook"]] += 1
```

It's written as `not in (0, )`

with a tuple. A plain `!= 0`

would give the same result, but writing it as a tuple makes the design "easy to add more normal exit codes later." For example, if you wanted to treat termination by `SIGINT`

(exit code 130) as normal, you'd just write `not in (0, 130)`

. Right now only `(0, )`

is in there, but this pattern is an intentional extension point.

Also, the default value of `0`

in `r.get("exit_code", 0)`

exists so that records missing the `exit_code`

field (e.g. JSONL corrupted mid-write) aren't miscounted as errors. If it's missing, "treat it as a success" — a conservative design.

The implementation is small, but in a few weeks of actual operation I hit five problems.

This is the first thing that tripped me up. macOS's default `/bin/bash`

is in the 3.2 line. Because Apple doesn't want to adopt GPLv3, it hasn't been updated since 2007. `$EPOCHREALTIME`

was added in bash 5.0, so it's undefined on the system bash.

Since `set -uo pipefail`

is at the top, the shell dies instantly with exit code 1 the moment it references an undefined variable.

```
# bash 3.2 では EPOCHREALTIME が unbound variable → ここで死ぬ
start_us=$(printf '%s' "${EPOCHREALTIME//./}" | sed 's/^0*//')
```

The symptom was "no sign the hook is running at all." The log file stayed empty, but no error was visible either. Claude Code was silently discarding the hook's error output, so I had no idea what was happening.

I only noticed when I tried it directly in a shell.

```
$ /bin/bash --version
GNU bash, version 3.2.57(1)-release

$ /bin/bash ~/.claude/scripts/hook-latency-wrap.sh ~/.claude/scripts/self_audit_stop.sh
/bin/bash: EPOCHREALTIME: unbound variable
```

The fix is to install bash 5.x with `brew install bash`

and set your PATH so that `#!/usr/bin/env bash`

resolves to the Homebrew bash. You could also make the shebang explicit as `#!/opt/homebrew/bin/bash`

, but that hurts portability, so I chose the former.

The reason the fallback existed but didn't work is that it died at `set -u`

before ever reaching the fallback.

When I learned that Claude Code streams JSON to hooks via stdin, I worried: "if the wrapper reads stdin, won't it stop reaching the original hook?" To be safe, I tried a form that receives stdin once and pipes it through again.

```
# やってしまったNG実装
input=$(cat)
echo "$input" | "$HOOK_BIN" "$@"
```

This was wrong in two ways. First, because `cat`

reads all of stdin into a variable before proceeding, it creates contention if the original hook was implemented to read stdin asynchronously or as a stream. Second, passing it via `echo`

through a pipe turns stdin into a pipe, which breaks cases where the hook expects stdin to be a tty.

In reality, when bash runs a command, stdin is inherited as-is by the child process. `"$HOOK_BIN" "$@"`

alone is enough for stdin to flow. "Do nothing" was the right answer. Reverting it made it work.

`hook_name=$(basename "$HOOK_BIN")`

takes **only the file name**. If you use identically named hooks in different projects, they're treated as the same hook in the aggregation.

I had `pre_git_guard.sh`

in both my global config and a certain personal project. When I looked at the report, `pre_git_guard.sh`

's invocation count was implausibly high and its p95 was higher than expected.

```
hook                              n    mean     p95
pre_git_guard.sh               312   198ms   940ms
```

In reality it was the sum of the global version (fast) and the project version (a heavy process that checks the Git remote), which pulled the p95 up. It looks like "pre_git_guard.sh is slow," but really only one of them is.

As a fix, I added the full path to the log fields.

```
hook_name=$(basename "$HOOK_BIN")
hook_path="$HOOK_BIN"   # フルパスも記録

printf '{"ts":"%s","hook":"%s","path":"%s","elapsed_ms":%d,"exit_code":%d}\n' \
  "$ts" "$hook_name" "$hook_path" "$elapsed_ms" "$exit_code" >> "$JSONL"
```

On the `report.sh`

side I changed the group-by from `hook`

to `path`

. That separates identically named hooks at different paths in the view.

I manage both the global `~/.claude/settings.json`

and a project `.claude/settings.json`

, and at one point when consolidating settings, I applied the wrapper on top of a hook that was already wrapped.

```
"command": "~/.claude/scripts/hook-latency-wrap.sh ~/.claude/scripts/hook-latency-wrap.sh ~/.claude/scripts/self_audit_stop.sh"
```

The symptoms were "the report's n has doubled" and "`hook-latency-wrap.sh`

itself appears in the report." Because the wrapper records its own execution time, the outer wrapper appends to the JSONL under the hook name `hook-latency-wrap.sh`

.

```
hook                              n    mean     p95
hook-latency-wrap.sh            843     8ms    14ms   ← これが出たら二重ラップ
self_audit_stop.sh              843  1923ms  3890ms
```

I've made it a rule to immediately suspect double-wrapping if `hook-latency-wrap.sh`

shows up in the report. The check command is:

```
grep -r "hook-latency-wrap" ~/.claude/settings*.json .claude/settings*.json 2>/dev/null
```

Find a line where the path appears nested, and that's the spot.

Once, the report's mean displayed as `-1ms`

. Opening the JSONL, I found a few records mixed in like `"elapsed_ms":-7`

.

The cause wasn't **bash integer overflow** but a precision issue with `$EPOCHREALTIME`

. When a hook finishes extremely fast (under 1ms), `start_us`

and `end_us`

can match exactly and the difference can be zero. But going negative is strange.

Investigating, it turns out that within the same bash session, `$EPOCHREALTIME`

values can occasionally "invert." This happens when the timing of a macOS system-clock adjustment by NTP correction overlaps with the timing of the bash variable update. It's a matter of single microseconds, so it's usually not noticeable, but the occurrence rate goes up in environments where NTP corrections happen frequently (VMs, etc.).

The remedy is to discard records with `elapsed_ms < 0`

on the report.sh side.

```
if r.get("elapsed_ms", 0) < 0:
    continue
```

Since adding this, the aggregate values have been stable. The original implementation doesn't include this guard, so if you see negative values, I recommend adding it.

Beyond the five covered in "Where I Got Stuck" (instant death on bash 3.2, stdin wrapping, basename collisions, double wrapping, negative elapsed_ms), there are several more points where I got stuck while continuing to operate this. Here's a comprehensive list so you don't stall in the same places.

**Forgetting chmod +x gives you a silent exit with "Permission denied."** Claude Code doesn't display hook error output, so you can't distinguish "the wrapper never launched" from "the hook failed." When the JSONL is empty, permissions are the first thing to check. Run

`ls -la ~/.claude/scripts/hook-latency-wrap.sh`

and confirm the `x`

in `-rwxr-xr-x`

is there. Both wrap.sh and the original hook need the execute bit.**Don't rely on tilde ~ expansion in settings.json.** If you write

`"command": "~/.claude/scripts/hook-latency-wrap.sh ..."`

, Claude Code's implementation may not expand the tilde in a path it passes to execv without going through a shell. There are cases where it looks like it works but breaks after a session restart. The safe move is to use `$HOME`

, as in `$HOME/.claude/scripts/hook-latency-wrap.sh`

, or to write an absolute path.**When running report.sh periodically via launchd or cron, PATH is insufficient.** The Homebrew PATH (`/opt/homebrew/bin`

) set in `~/.zshrc`

is only read by interactive shells. Unless you specify launchd's `EnvironmentVariables`

explicitly, `python3`

isn't found and report.sh fails silently. Writing `<key>PATH</key><string>/opt/homebrew/bin:/usr/bin:/bin</string>`

in the plist, or `export PATH="/opt/homebrew/bin:$PATH"`

at the top of the script, is the reliable route.

**On macOS Ventura and later, if python3 isn't installed the fallback dies too.** On macOS Ventura and later, the

`python3`

command doesn't exist in environments without Xcode Command Line Tools installed. Because wrap.sh's `$EPOCHREALTIME`

fallback (for bash < 5) calls `python3`

, the fallback dies at the same time. Running `brew install python`

, or using Homebrew's bash 5, solves it at the root. Either route is a single command.**The JSONL grows without bound.** There's no mechanism to stop appending to `hook-latency.jsonl`

, and after months of operation it exceeds tens of thousands of lines. Since report.sh parses every line before filtering by period, there were environments where startup began taking hundreds of milliseconds past 100,000 lines. It's safer to put in a one-line launchd entry for monthly compress-and-rotate from the very beginning.

```
# 月次実行（launchd StartCalendarInterval）
gzip -c ~/.claude/logs/hook-latency.jsonl \
  > ~/.claude/logs/hook-latency-$(date +%Y%m).jsonl.gz \
  && : > ~/.claude/logs/hook-latency.jsonl
```

**Omitting the days argument and staying stuck on the fixed 7 days.** Right after introduction you only have a day's worth of data, so

`hook-latency-report.sh`

's `days=7`

default leaves you in a state where "n is 10 or fewer and p95 isn't trustworthy." A stable usage pattern is to look at the current day with `hook-latency-report.sh 1`

for the first week, then switch to `hook-latency-report.sh 7`

after a week.**Feeling that things got slower after adding the wrapper, and suspecting the wrapper.** The perceived heaviness is latency from hooks that existed before the wrapper and has "only now become visible thanks to measurement." wrap.sh's own overhead measures around mean 12ms / p95 18ms (see the `hook-latency-wrap.sh`

row in the sample output above). Latency beyond that value is the original hook's cost. Feeling "it got faster" after removing the wrapper is the illusion of measurement disappearing.

**JSONL breaks if a hook name contains pipe characters or quotes.** `hook_name=$(basename "$HOOK_BIN")`

embeds the file name directly into a JSON string field. If the script name contains `"`

or `\`

, the JSON is corrupted, gets absorbed by report.sh's `try/except`

, and the count silently drops. Standardizing hook names to alphanumerics, underscores, and hyphens only is the safest approach.

**Multiple Claude Code sessions running simultaneously mix up the aggregation.** If you run the desktop app and the terminal CLI at the same time, logs from both sessions get mixed into the JSONL. If you hit the phenomenon "p95 hasn't changed even though I improved it in that session," suspect contamination from another session. If you want to append a `session_id`

to the JSONL, the first step is to check whether a `CLAUDE_SESSION_ID`

environment variable exists (as of July 2026 it isn't exposed as an environment variable, so substituting the process ID is the realistic approach).

**Overlooking the 9-hour skew between report.sh's local-time cutoff and UTC logs.** As detailed earlier, logs recorded in UTC with the `-u`

flag and a cutoff taken in local time (JST) via `datetime.datetime.now()`

are out of sync. When you specify `hook-latency-report.sh 1`

and run it at 8 a.m., you can hit the extreme situation of seeing "only the 0 hours since 8 a.m. this morning." In a JST environment, if the record count is abnormally low when you specify `days=1`

or `days=2`

, this skew is the cause.

After running this for a while, here are 12 items where I felt "I should have done it this way from the start."

**① Start with measurement, optimize later**

It's easy to feel "upgraded" every time you add a hook, but improvement without numbers is a placebo. Stick to the order: install `hook-latency-wrap.sh`

, accumulate a week of data, then start improving. Numbers first, intuition second.

**② Use p95, not mean, as your improvement metric**

Hook latency isn't uniform. It may finish within 200ms in most cases, but the instant you hit a Git remote timeout it records 5000ms. That spike is the true identity of perceived "heaviness." Looking at p95 lets you grasp the reality that "5 out of every 100 calls have unacceptable latency." This is why focusing on lowering the mean doesn't change how it feels.

**③ For hooks with p95 > 1500ms, identify the cause before deleting**

It's important not to immediately try to delete a hook that shows ⚠. The countermeasure differs completely depending on whether the heaviness comes from network I/O (Git remote), CPU (Python processing), or shell startup cost (heavy subshell use). First run it standalone with `time ~/.claude/scripts/slow_hook.sh < /dev/null`

to isolate the cause.

**④ Know the wrapper's own overhead as a constant**

In my environment, wrap.sh's own overhead was mean 12ms / p95 18ms (see the `hook-latency-wrap.sh`

row in the sample output above). When setting improvement targets for a hook, treat the value with this fixed cost subtracted as "the original hook's cost."

**⑤ Use #!/usr/bin/env bash as the shebang so you go through Homebrew bash**

`#!/bin/bash`

binds directly to the system bash (3.2 on macOS). With `#!/usr/bin/env bash`

, whatever bash is on your PATH gets used. Install bash 5.x with `brew install bash`

and put it on your PATH, and all bash 3.2-derived problems disappear at once.

**⑥ Write settings.json commands with full paths**

Tilde notation is expanded when invoked via a shell, but that depends on Claude Code's implementation. Writing `$HOME/.claude/scripts/hook-latency-wrap.sh`

or an absolute path means you're unaffected if Claude Code's internals change in the future.

**⑦ Set up JSONL rotation first**

Add it later and you only notice once the existing log is already heavy. Set it up first. It's just two commands — compress + truncate — so all you do is put it in a monthly launchd `StartCalendarInterval`

.

**⑧ Add an elapsed_ms < 0 guard to report.sh**

It's not included in the current implementation (see the report.sh source above). If a negative value is occasionally recorded due to NTP correction timing, the mean goes negative and the report breaks. It's just one line added at the top of the `try/except`

block.

```
# with open(log) as f: ループ内、json.loads 直後に追記
if r.get("elapsed_ms", 0) < 0:
    continue
```

**⑨ Prefix project-specific hooks**

Using generic names like `pre_check.sh`

across multiple projects mixes them together in basename-based aggregation. Adding a project prefix, as in `proj-foo_pre_check.sh`

, makes it obvious at a glance in the table which project's what. To rename existing hooks, you can create an alias with `ln -s`

and migrate without changing the actual file.

**⑩ Run report.sh on a schedule and record changes**

It's easy to forget to run it manually. I run it via launchd every Monday at 9:00 and get a notification through `terminal-notifier`

. The point is not to miss changes like "this week's p95 got 300ms worse than last week's." Numbers only have meaning once they're recorded.

**⑪ Have a rule to check the total p95 of existing hooks before adding one**

My condition is: "before adding a new hook, look at report.sh and confirm the total p95 time of existing hooks is 3000ms or less." If it's over, cut before you add. Without this rule, hooks grow without limit. PostToolUse hooks are called dozens of times in a single session.

**⑫ Customize the 1500ms threshold to your environment**

The design shows `⚠`

at p95 > 1500ms, but for a hook that includes a Git remote check, there are cases where 2000ms as the norm is acceptable. Conversely, for a hook you want to keep within 100ms, you'd want a warning at 500ms. If you make the `1500`

in report.sh's `flag = " ⚠" if p95 > 1500 else ""`

an environment variable, you can change it dynamically at call time, as in `HOOK_WARN_MS=500 hook-latency-report.sh 7`

.

Claude Code hooks aren't a "it works, that's enough" design — "it runs fast" feeds directly into productivity. If one hook takes 2000ms and gets called 50 times in a session, you're spending 1 minute 40 seconds waiting on hooks rather than on the agent's response. If you don't notice, it goes on forever.

With just two scripts — `hook-latency-wrap.sh`

(43 lines) and `hook-latency-report.sh`

(53 lines) — you can make each hook's p95, mean, max, and error rate visible. As we saw above, in the case where `self_audit_stop.sh`

had a p95 of 3240ms and was called 127 times, it was only then that I learned there had been **3 minutes 54 seconds** of pure waiting over 7 days. Before measuring, all I had was a feeling of "maybe it's heavy," with no way to prioritize improvements.

You can start just by prepending the wrapper's path to the `command`

field in settings.json. Put it in tonight and you'll have a day's worth of data by tomorrow morning.

The ¥1.2M monthly revenue figure is the accumulation of converting each individual "feeling of heaviness" into a measurable problem and killing it. Make a tool you use every day 20 seconds faster and you get more than 2 hours back over a year. You can build the next system in that time.

I've written up the full picture of the setup, the breakdown of the ¥1.2M/month, and the 30-day procedure in a paid note.

📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)

*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.

Follow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*
