Skip to content

perf(post-tool): give PostToolUse the #227 env-cache fast path - #352

Merged
fdaviddpt merged 2 commits into
mainfrom
fix/350
Aug 18, 2026
Merged

perf(post-tool): give PostToolUse the #227 env-cache fast path#352
fdaviddpt merged 2 commits into
mainfrom
fix/350

Conversation

@fdaviddpt

Copy link
Copy Markdown
Contributor

Closes #350 (part 1 only — the save-session.sh position sidecar is deliberately not here; see "What is not in this PR" below).

The problem

scripts/post-tool-hook.sh registers with no matcher, so it runs after every single tool call and the agent blocks on it. Every invocation sourced the whole chain unconditionally — resolve-paths.shdetect-tools.shbootstrap-dirs.shlog.sh — which is a git rev-parse, a slug, a three-layer config merge, a one-pass flatten, and a python3 -V spent only to validate an interpreter.

The reporter measured 750–1000 ms per tool call on Windows 11 / Git Bash, against ~90 ms for user-prompt-hook.sh on the same machine — the hook that already replays. Their measurement is a claim I did not reproduce (no Windows box); I re-measured the shape on macOS and it holds there too.

The change

The #227 fast path now covers this hook. When lib-env-cache.sh can replay a published resolution and no executable listener is installed under hooks.d/after_post_tool/, the chain is skipped.

Observed, macOS / bash 3.2.57, external spawns per warm tool call:

fixture before after
no save yet 14 spawns / 336 ms 6 / 130 ms
a save behind it 15 spawns / 405 ms 8 / 248 ms

The first tool call of a session — and the first after any config edit — still takes the whole chain, and now publishes it, so the cost is paid once per project per config change instead of per tool call.

The objection #227 raised, answered rather than dropped

tests/test_post_tool_hook_spawns.py says in its own docstring why #227 skipped this hook: it needs config(), and therefore the merged config file, "caching which is a materially bigger risk than caching a handful of paths". That is right, and the merged config file is still never cached — it can carry a live haiku.oauth_token, which is why lib-memory-dir.sh creates it 0600 per PID under an EXIT trap (#232/#68).

What is cached are the two scalars log.sh resolves out of it — cooldowns.save_seconds and thresholds.delta_lines_trigger — in the same 0600 file that has carried REMEMBER_TZ since #227, under the same config-mtime invalidation, with the same "required, not defaulted" upgrade rule #301 gave REMEMBER_PROMPT_STAMP. A cooldown and a line threshold are neither secret nor expensive to be one prompt stale about.

Four things that were easy to get wrong

The gate asks for an executable, not a directory. user-prompt-hook.sh tests [ ! -d hooks.d/after_user_prompt ] and gets away with it because the distribution ships no such directory. It does ship hooks.d/after_post_tool/.gitkeep (checked: git ls-files hooks.d), so the same -d test here would have refused the fast path for every user who never installed a listener — which is all of them. It asks what dispatch() asks, from one shared definition, so the gate and the dispatch cannot come to disagree about whether a listener exists.

PYTHON is not replayed. lib-env-cache.sh validates config mtimes and environment identity; it does not validate binaries, and it was not asked to. detect-tools.sh is sourced at the one site that needs an interpreter — reading the saved position, which a store only reaches once a save has landed — so the Microsoft Store alias that passes command -v and fails -V is still caught by exactly the check that has always caught it.

log is upgraded, not stubbed. dispatch is stubbed, because the gate only let us in when there is nothing to dispatch to. log is different: the branches that call it are the ones where this hook is malfunctioning — a slug matching no session directory (#144) — and dropping those diagnostics to save a process is #144 arriving inside its own fix. The first call sources log.sh; the runs that never take those branches never pay for it.

That upgrade has to install a no-op when log.sh cannot be sourced at all, and the obvious guard — type log — is true on macOS regardless, because /usr/bin/log is Apple's unified logging CLI. Found by running it: the hook execed that binary once per diagnostic, printed nineteen lines of log: Unknown subcommand 'hook', and exited 64 from a hook documented "EXIT CODES: 0 Always". It asks declare -F.

The #200 wiring marker survives a store with no tmp/. bootstrap-dirs.sh is what creates $REMEMBER_DIR/tmp. tmp/post-tool-ran is how /remember:doctor answers "is PostToolUse wired at all", so it now creates the directory on the failing write rather than assuming one. Silently skipping it would tell users a wired hook had never fired — the exact regression #200 fixed.

Also done by hand, because the fast path skips whatever set them: the #204 nested-summarizer guard, umask 077 (#68), SYS_TMPDIR, REMEMBER_HOOKS_DIR, and bootstrap-dirs.sh's exec 2>> into hook-errors.log — without that last one a diagnostic lands in front of the user instead of in the log.

Tests

tests/test_post_tool_fast_path_350.py, 13 cases, written before the implementation and watched failing (9 red, 2 green-for-the-right-reason). Every "the chain did not run" assertion is paired with a positive control in the same fixture:

Two of them have their negative controls recorded rather than assumed: reverting log() to a stub and reverting declare -F to type each make the corresponding test fail, and I ran both.

A helper (_fast_path_run) establishes fast-path-ness from the spawn log rather than from EnvCacheProbe.assert_warm, because against a cache primed by another hook the probe answers warm for the slow path too — it cannot tell "replayed" from "never looked".

Two adjacent tests were measuring paths they did not name, and now say which. tests/test_post_tool_hook_spawns.py counted a second run of the same fixture, which after this change is the warm one — and whether it was warm at all depended on which side of a whole second two writes landed on (#303). It pins REMEMBER_ENV_CACHE=0 and is now explicitly the cold-path file. TestFreshProjectBootstrap::test_detect_before_bootstrap compared the first mention of two filenames anywhere in a hook, comments included; it now reads the source statements, with the premise (both are in fact sourced) asserted rather than assumed. test_case_divergence_298 pinned two hot-path libraries to origin/main byte for byte and so failed on a comment — the very comment that had become false; it now compares code lines only, verified still to catch a renamed function.

Full suite: 1590 passed, 43 skipped, coverage 92.95%.

What is not in this PR

Part 2 of the issue — having save-session.sh write a plain-integer tmp/position.<session_id> sidecar so pipeline.shell read-position stays off the hot path. It is worth about 2 spawns and ~118 ms per warm tool call on my machine, and it introduces a second source of truth for a position last-save.json already holds; a sidecar that disagrees with it produces a wrong delta silently. That is a separate argument and deserves its own issue.

Platform

Everything measured above is observed on macOS 24.3.0 / bash 3.2.57. Every bash test in this repo skips on win32, so the Windows CI leg does not execute any of this, and no Windows machine was available.

One claim is reasoned, not observed, and is written down above _after_post_tool_listener in the code: MSYS fakes the execute bit, and if Git Bash ever reported the shipped mode-0644 .gitkeep as executable, the fast path would never fire on Windows. That would be slow, not wrong — the slow path is today's behaviour exactly — and dispatch() has shipped the identical [ -x ] test for many releases, so it would already be trying to execute that file on every Windows tool call, which nobody has reported. One command in a real Git Bash session settles it: [ -x hooks.d/after_post_tool/.gitkeep ] && echo YES.

Also reasoned: CRLF in the cache file is stripped before the new digits-only guards, so a corrupted value falls back to the chain rather than reaching $(( )). SYS_TMPDIR and umask 077 are byte-identical replays of the expressions already in bootstrap-dirs.sh and resolve-paths.sh.

fdaviddpt and others added 2 commits August 15, 2026 02:01
post-tool-hook.sh registers with no matcher, so it runs after every tool
call and the agent waits for it. Every invocation sourced resolve-paths ->
detect-tools -> bootstrap-dirs -> log.sh unconditionally: a git rev-parse,
a slug, a three-layer config merge and a flatten, plus a python3 -V spent
only to validate an interpreter. The reporter measured 750-1000 ms per
tool call on Windows 11 / Git Bash against ~90 ms for the prompt hook that
already replays.

#227 skipped this hook because it needs config(), and therefore the merged
config file, which can carry a live OAuth token and is 0600 per PID for
that reason. That file is still never cached. What is cached are the two
scalars log.sh reads out of it, in the same 0600 file that has carried
REMEMBER_TZ since #227, under the same config-mtime invalidation.

macOS/bash 3.2.57, external spawns per warm tool call: 14 -> 6
(336 ms -> 130 ms), or 15 -> 8 (405 ms -> 248 ms) once a save has landed.

The gate asks for an executable, not a directory: the distribution ships
hooks.d/after_post_tool/.gitkeep, so a -d test would have refused the fast
path for every user. PYTHON is never replayed - detect-tools.sh is sourced
at the one place a Python is needed, so the Store alias that passes
command -v and fails -V is still caught. The #204 guard, umask 077,
SYS_TMPDIR and the stderr redirect are all done by hand; log() upgrades the
process on first use rather than being stubbed, so #144 diagnostic
survives; and the #200 wiring marker creates tmp/ on a failing write.

Two adjacent tests were measuring paths they did not name and now say
which: the cold-path budget pins REMEMBER_ENV_CACHE=0, and the
detect-before-bootstrap ordering test reads source statements rather than
the first mention of a filename anywhere in the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review pass over the committed diff, all findings from it:

- post-tool-hook.sh DEPENDENCIES listed only what the fast path reaches,
  dropping the four-script chain the resolving run still takes. Split into
  "every run" and "the resolving run" instead.
- lib-memory-dir.sh and tests/test_slug_index_297.py both said
  bootstrap-dirs.sh is sourced on EVERY tool call. It is not, as of the
  commit before this one.
- README said SessionStart and PostToolUse source log.sh. PostToolUse does
  so only on the resolving run, and installing a hooks.d listener is what
  puts it back on the chain - which the README did not say anywhere.
- Recorded, in the code and the changelog, the one platform claim that is
  reasoned rather than observed: whether Git Bash reports a mode-0644
  .gitkeep as executable. If it does, the fast path never fires on Windows
  - slow, not wrong - and dispatch() has shipped the same [ -x ] for many
  releases without anyone reporting it executing that file.

test_case_divergence_298 pinned lib-slug.sh and lib-memory-dir.sh to
origin/main byte for byte, so it failed on a comment - the very comment
that had become false. It now compares code lines only, the same one-liner
the arm above it already uses, with the premise (the stripped file is not
empty) asserted rather than assumed. Every executable byte stays pinned:
verified by renaming a function and watching it fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fdaviddpt

Copy link
Copy Markdown
Contributor Author

Part 2 of #350 — the tmp/position.<session_id> sidecar — is filed as #353 with the measurement it is worth (~2 spawns / ~118 ms per warm tool call, observed on macOS) and the argument against shipping it as-is. This PR closes #350; #353 is where that half continues.

@fdaviddpt
fdaviddpt merged commit fd721fc into main Aug 18, 2026
12 checks passed
fdaviddpt added a commit that referenced this pull request Aug 18, 2026
Three merged pull requests since v0.20.0: the PostToolUse env-cache fast path
(#352), the maintainer scaffold that introduced changelog.d (#351), and the
recent.md rotation that gets an over-cap store back out of the state 0.20.0
could only stop it entering (#348).

Gates, each performed rather than felt:
- main GREEN at leg level on d374051, 12/12. The second declared workflow,
  oss changelog, is pull_request-only and could not have run on a squash
  commit: no coverage, not a pass, not a failure.
- Security audit of v0.20.0..HEAD, two rounds, hard cap reached. Five
  findings, every one ranked misreports, none in a blocking row. Filed as
  #357, #358, #359 (round one) and #360, #361 (round two). Two could-not-check
  items are named in #360/#361 rather than absorbed: the assembler fragment
  sandbox was read but not adversarially tested, and no CI leg covers any
  shell change in this delta.
- Version 0.21.0 proposed by release_version.py from the fragments (1 added,
  1 fixed, 0.x line, feature change -> minor) and accepted. Both fragments
  declared nothing in the breaking field and are assumed compatible.
- Version sites swept unfiltered over tracked files: plugin.json and the
  README badge, nothing frozen at a third value.

Also here, and not part of the release proper: changelog_dir was left null by
the scaffold, so the version rule could not find the fragments #351 had just
introduced; and .claude/settings.json was untracked but ungitignored, one
git add -A away from committing a maintainer plugin roster into the artifact.

The soak threshold in .oss.json is 48h and #352 merged this morning. That
window was overridden deliberately: the #348 fix is what the reporter of #354
is waiting on.

Co-Authored-By: Claude Opus 5 (1M context) <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.

post-tool-hook.sh: apply the #227 env-cache fast path to PostToolUse (~0.75-1.0 s per tool call on Windows)

1 participant