Skip to content

perf: skip the jq spawn on non-skill reads - #6

Open
learte4 wants to merge 1 commit into
silverlogic:mainfrom
learte4:perf/fast-exit-before-jq
Open

perf: skip the jq spawn on non-skill reads#6
learte4 wants to merge 1 commit into
silverlogic:mainfrom
learte4:perf/fast-exit-before-jq

Conversation

@learte4

@learte4 learte4 commented Aug 11, 2026

Copy link
Copy Markdown

The hook is registered for Read, so it runs for every file the model opens — but only reads under .claude/skills/ produce a log entry, which is a small fraction of them. On the machine where I noticed this, one project's log had 23 entries across three weeks while the Read tool fired several thousand times.

The problem is the ordering: main() spawns jq to parse the payload before it can tell a skill read from an ordinary one. So the subprocess is paid on every read and thrown away almost every time. The comment at the top of the file already promises "Fast-exits for non-skill reads" — this just makes the exit happen early enough to be worth the name.

Change

Decide ahead of the parse, with a plain shell pattern match against the raw stdin:

case "${input}" in
  *'.claude/skills/'*|*'.claude\\skills\\'*) ;;
  *) exit 0 ;;
esac

The second pattern covers JSON-escaped Windows paths, so nothing is dropped on that platform. The match is strictly wider than the file_path guard that follows: any payload that used to reach the logging code still reaches it, and the guard still has the final say. Worst case a payload mentions the path in some other field, passes here, and exits at the existing guard — the behavior you have today.

Measurement

Git Bash on Windows 11, 25 invocations with a non-skill read payload:

ms per invocation
before 538
after 188

The remaining 188 ms is bash startup, which a type: command hook can't avoid. Windows is the extreme case because process creation is expensive there; on macOS and Linux the absolute saving is smaller, but the spawn goes away all the same.

How I verified it

There's no test harness in the repo, so I used a throwaway one: put a failing jq first on PATH, then feed the script three payloads.

  1. Non-skill read → exits 0 and never calls jq (the fake would have failed loudly).
  2. Skill read, with the real jq → still appends the event, "skill":"ponytail".
  3. JSON-escaped Windows path → passes the filter and reaches the parser.

All three pass. Happy to add this under tests/ if you'd like a permanent version — I left it out because the repo doesn't have a test layout yet and that felt like your call rather than mine.

verification harness
#!/usr/bin/env bash
set -uo pipefail
TRACKER="${1:?usage: check.sh <abs path to skill-tracker.sh>}"
WORK="$(mktemp -d)"; trap 'rm -rf "${WORK}"' EXIT
mkdir -p "${WORK}/fakebin"
printf '#!/usr/bin/env bash\ntouch "${JQ_SPY:?}"\nexit 1\n' > "${WORK}/fakebin/jq"
chmod +x "${WORK}/fakebin/jq"
REAL_PATH="${PATH}"; fail=0

export JQ_SPY="${WORK}/jq-was-called"
plain='{"tool_name":"Read","session_id":"s1","tool_input":{"file_path":"D:\\proj\\src\\index.ts"}}'
( cd "${WORK}" && PATH="${WORK}/fakebin:${REAL_PATH}" bash "${TRACKER}" <<< "${plain}" )
rc=$?
if [[ ${rc} -ne 0 ]]; then echo "FAIL: plain read should exit 0, got ${rc}"; fail=1
elif [[ -e "${JQ_SPY}" ]]; then echo "FAIL: jq was spawned on a plain read"; fail=1
else echo "ok  plain read: exit 0, no jq spawn"; fi

rm -f "${JQ_SPY}"
SKILLDIR="${WORK}/proj/.claude/skills/ponytail"; mkdir -p "${SKILLDIR}"
printf 'a\nb\n' > "${SKILLDIR}/SKILL.md"
skill="{\"tool_name\":\"Read\",\"session_id\":\"s2\",\"tool_input\":{\"file_path\":\"${SKILLDIR}/SKILL.md\"}}"
( cd "${WORK}/proj" && PATH="${REAL_PATH}" bash "${TRACKER}" <<< "${skill}" )
LOG="${WORK}/proj/.skill-observer/logs/skills.jsonl"
if ! grep -q '"skill":"ponytail"' "${LOG}" 2>/dev/null; then echo "FAIL: skill read not logged"; fail=1
else echo "ok  skill read: logged"; fi

rm -rf "${WORK}/proj/.skill-observer"; export JQ_SPY="${WORK}/jq-win"
win='{"tool_name":"Read","session_id":"s3","tool_input":{"file_path":"D:\\proj\\.claude\\skills\\deploy\\SKILL.md"}}'
( cd "${WORK}/proj" && PATH="${WORK}/fakebin:${REAL_PATH}" bash "${TRACKER}" <<< "${win}" >/dev/null 2>&1 )
if [[ -e "${JQ_SPY}" ]]; then echo "ok  escaped Windows path: reaches the parser"
else echo "FAIL: escaped Windows path dropped by the fast-exit"; fail=1; fi
exit ${fail}

Independent of #5, which fixes an unrelated Windows bug a few lines further down in the same function. Whichever lands first, the other needs at most a trivial rebase — I'm happy to do it.

Note that on Windows today, #5 is what makes the logging work at all; this PR only changes how much it costs to decide not to log.

The hook is registered for every Read, but only reads under .claude/skills/
produce a log entry — in practice well under 1% of calls. The current code
still spawns jq to parse the payload before it can decide that, so the
subprocess is paid on every read and thrown away almost every time.

Move the decision ahead of the parse with a plain shell pattern match on the
raw stdin. The pattern covers both separator styles, so JSON-escaped Windows
paths still match, and it is strictly wider than the file_path guard that
follows — anything that used to be logged still is.

Measured on Windows (Git Bash, 25 runs, non-skill read): 538 ms -> 188 ms per
invocation. The remainder is bash startup, which a `type: command` hook cannot
avoid. On macOS/Linux the saving is smaller in absolute terms but the spawn is
removed all the same.

Verified with a throwaway harness that puts a failing `jq` first on PATH: a
non-skill read exits 0 without ever calling it, a skill read still logs the
event, and a JSON-escaped Windows path still reaches the parser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.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.

1 participant