Conversation
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>
Contributor
Author
This was referenced Aug 18, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #350 (part 1 only — the
save-session.shposition sidecar is deliberately not here; see "What is not in this PR" below).The problem
scripts/post-tool-hook.shregisters 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.sh→detect-tools.sh→bootstrap-dirs.sh→log.sh— which is agit rev-parse, a slug, a three-layer config merge, a one-pass flatten, and apython3 -Vspent 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.shon 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.shcan replay a published resolution and no executable listener is installed underhooks.d/after_post_tool/, the chain is skipped.Observed, macOS / bash 3.2.57, external spawns per warm tool call:
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.pysays in its own docstring why #227 skipped this hook: it needsconfig(), 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 livehaiku.oauth_token, which is whylib-memory-dir.shcreates it0600per PID under anEXITtrap (#232/#68).What is cached are the two scalars
log.shresolves out of it —cooldowns.save_secondsandthresholds.delta_lines_trigger— in the same0600file that has carriedREMEMBER_TZsince #227, under the same config-mtime invalidation, with the same "required, not defaulted" upgrade rule #301 gaveREMEMBER_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.shtests[ ! -d hooks.d/after_user_prompt ]and gets away with it because the distribution ships no such directory. It does shiphooks.d/after_post_tool/.gitkeep(checked:git ls-files hooks.d), so the same-dtest here would have refused the fast path for every user who never installed a listener — which is all of them. It asks whatdispatch()asks, from one shared definition, so the gate and the dispatch cannot come to disagree about whether a listener exists.PYTHONis not replayed.lib-env-cache.shvalidates config mtimes and environment identity; it does not validate binaries, and it was not asked to.detect-tools.shis 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 passescommand -vand fails-Vis still caught by exactly the check that has always caught it.logis upgraded, not stubbed.dispatchis stubbed, because the gate only let us in when there is nothing to dispatch to.logis 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 sourceslog.sh; the runs that never take those branches never pay for it.That upgrade has to install a no-op when
log.shcannot be sourced at all, and the obvious guard —type log— is true on macOS regardless, because/usr/bin/logis Apple's unified logging CLI. Found by running it: the hook execed that binary once per diagnostic, printed nineteen lines oflog: Unknown subcommand 'hook', and exited 64 from a hook documented "EXIT CODES: 0 Always". It asksdeclare -F.The #200 wiring marker survives a store with no
tmp/.bootstrap-dirs.shis what creates$REMEMBER_DIR/tmp.tmp/post-tool-ranis how/remember:doctoranswers "isPostToolUsewired 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, andbootstrap-dirs.sh'sexec 2>>intohook-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:gitandjq(so "no git, no jq" on the warm run is evidence of something) and the warm run asserted to spawnls(so it is evidence of a hook that ran);tmp/deleted, the False "previous session was not captured" warning fires on every /clear #206 per-session marker, the Nested Haiku summarizer re-triggers the plugin's own hooks, creating a spurious$TMPDIR-slug memory directory #204 guard with a control run beside it,.gitkeepnot disabling the fast path, an executable listener taking the slow path and being dispatched, and clean stdout/stderr.Two of them have their negative controls recorded rather than assumed: reverting
log()to a stub and revertingdeclare -Ftotypeeach make the corresponding test fail, and I ran both.A helper (
_fast_path_run) establishes fast-path-ness from the spawn log rather than fromEnvCacheProbe.assert_warm, because against a cache primed by another hook the probe answerswarmfor 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.pycounted 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 pinsREMEMBER_ENV_CACHE=0and is now explicitly the cold-path file.TestFreshProjectBootstrap::test_detect_before_bootstrapcompared the first mention of two filenames anywhere in a hook, comments included; it now reads thesourcestatements, with the premise (both are in fact sourced) asserted rather than assumed.test_case_divergence_298pinned two hot-path libraries toorigin/mainbyte 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.shwrite a plain-integertmp/position.<session_id>sidecar sopipeline.shell read-positionstays 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 positionlast-save.jsonalready 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_listenerin the code: MSYS fakes the execute bit, and if Git Bash ever reported the shipped mode-0644.gitkeepas executable, the fast path would never fire on Windows. That would be slow, not wrong — the slow path is today's behaviour exactly — anddispatch()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_TMPDIRandumask 077are byte-identical replays of the expressions already inbootstrap-dirs.shandresolve-paths.sh.