Conversation
Two halves of the same hook wiring, from issue trailhq#366. `checkStaleCount`'s `graft check` child kept the flat CHILD_TIMEOUT_MS while `promptAskTimeout` has derived its cap from the installed budget since the prompt hook hit the same problem. On a repo wired before post-edit's budget was raised, PostToolUse is still 8000 - the same number as the child - so the child could consume the whole hook and leave nothing for readWiring() and formatBlastRadius(); the SIGKILL takes emit() and patchStats() with it, so the edit gets no blast radius and no stats write. `hookTimeoutIn` returned the first graft timeout it found in an event rather than the smallest. That made no difference for UserPromptSubmit, which declares one entry, but PostToolUse declares two (post-edit and tool-savings) and nothing in the hook can tell which matcher launched it - the same reason installedHookTimeout already takes the smallest across files. Taking the smallest within a file too puts the post-edit child at 6000 under the current template instead of 8000, which is the headroom the issue is about. mergeGraftSettings dropped graft's own hook entries whole and rewrote them from the template, so a timeout raised by hand in .claude/settings.json could not survive - and reconcileWiring() fires that rewrite on every version bump, so the raise reverted unattended. Both merge sites now go through mergeHookEntries, which carries a raised timeout forward per hook. Raise-only, so a template can still lift the floor for every repo, and still one graft entry per template block, so re-running converges. Matched by hook name rather than by the whole command, since the helpers path differs between the repo-level and user-level installs and has changed between versions.
🌱 graft blast radius1 area changed → 3 areas can be affected. 5 dependent symbols, depth 2. flowchart TB
A0(("Global Hook Installation<br/>2 symbols"))
A1(("Wiring Configuration<br/>2 symbols"))
A2(("Initialization<br/>1 symbol"))
classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
class A0,A1,A2 reached;
Who knows this code — 2 people across 4 areas
Ownership is git history over each area's own files, weighted towards recent work (120-day half-life). Merge commits and bots are dropped, and you are dropped from your own PR. A name with no All 5 dependent symbols, grouped by areaGlobal Hook Installation — 2 symbols in 1 file
Wiring Configuration — 2 symbols in 2 files
Initialization — 1 symbol in 1 file
Test signal per changed area — 1 ✓Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.
3 test suites also reference this code3 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.
Open the interactive graph → — click an area to see its dependent symbols at file:line. |
Fixes #366. Both halves, since they are the same wiring and the workaround in the issue exists only because neither side alone was enough.
1. The post-edit child now tracks the installed budget
promptAskTimeouthas derived its cap from the budget actually installed in the repo since the prompt hook hit exactly this;checkStaleCounthad no equivalent and kept the flatCHILD_TIMEOUT_MS.postEditCheckTimeoutis the same three lines againstPostToolUse.The sharp case is a repo wired before post-edit's budget was raised:
PostToolUseis still8000there, the same number as the child, so the child could consume the entire hook budget and leave nothing forreadWiring()+formatBlastRadius(). The SIGKILL takesemit()andpatchStats()with it — no blast radius for the edit, no stats write, silently. That is the failurepromptAskTimeout's own docstring describes, one hook over.One thing the suggestion in the issue needed to work.
hookTimeoutInreturned the first graft timeout it found in an event, not the smallest. ForUserPromptSubmitthat is the same thing (one entry).PostToolUsedeclares two — post-edit10000and tool-savings8000— so a straightinstalledHookTimeout(dir, 'PostToolUse')read back10000, derived8000, and the change would have been a no-op at the current template: still an 8s child inside a 10s budget, still ~2s for everything after it.So
hookTimeoutInnow takes the smallest within a file, which is whatinstalledHookTimeoutalready does across files and for the reason it documents — nothing in the hook can tell which matcher launched it, guessing high gets the hook killed, guessing low only shortens onegraft check. With that, the post-edit child is6000under the current template instead of8000, which is the headroom the issue asks for.promptAskTimeoutis unchanged unless a repo declares twoUserPromptSubmitgraft entries, where it also becomes conservative.I did not touch
HOOK_OVERHEAD_MS. Whether 2s is the right allowance after agraft checkon a 568-card graph is a judgement your measurements support and mine do not — happy to follow up if you want a larger allowance for this hook specifically.2. A hand-raised timeout survives a refresh
Both merge sites go through one
mergeHookEntries, which keeps foreign entries untouched, replaces graft's from the template, and carries forward a timeout raised above it:Raise-only in both directions: a template can still lift the floor for every repo, and a deliberate raise is not pulled back down. Still exactly one graft entry per template block, so
graft initconverges rather than stacking — the property the// drop old Graft entries → idempotentcomment was protecting, which turns out not to require discarding the value.Matched by hook name rather than by the whole command, one step beyond the
Math.maxsketch in the issue. The command carries the helpers path, which differs between the repo-level and user-level installs, andsettings-merge.ts:146notes that the invocation form has changed before — so keying on the command would silently lose the raise on exactly the version bump that triggers the rewrite. Keying per hook also matters becausePostToolUsehas two entries and a raise belongs to the one it was made on.Testing
test/claude-hooks.test.ts—postEditCheckTimeout, in the idiom of thepromptAskTimeoutcases next to it: a repo wired at8000→6000; the current template (10000+8000) →6000; both raised to20000→18000; theMIN_CHILD_TIMEOUT_MSfloor; unreadable/absent/non-numeric →6000. Plus one asserting the two hooks read separate events, so neither can cap the other.test/claude-settings-merge.test.ts— the carry-forward: a raise survives while its sibling entry keeps the template value; the template still raises a repo below it; a raise written by an older graft (different helpers path) is still recognised;'20000'/null/undefined/NaN/Infinityare ignored rather than carried; a foreign hook on the same event keeps its own timeout; merging twice is byte-identical.(
LC_ALL=en_US.UTF-8— on a German localetool-savings counts a REAL savings linefails on a clean checkout too, because it matches~100,000against atoLocaleStringthat renders~100.000. Unrelated to this branch; mentioning it in case it is worth pinning the locale in that assertion.)Negative controls, each isolating one piece: reverting
hookTimeoutInto first-found reddens the derivation case and nothing else; removing the carry-forward reddens 3 of the merge cases.To be straightforward about one gap: reverting
checkStaleCountto the default cap reddens nothing. The derived number is unit-tested, but the wiring of it into that one call is only observable through a real timeout — a stubgraft checksleeping several seconds — and I would rather not put a multi-second timing test into a 1229-test suite unless you want it. The change itself is the single argument onhooks.ts:176.