Skip to content

fix(claude): the user-level hook stands down where the repo runs its own - #409

Open
k-lpmg wants to merge 3 commits into
trailhq:mainfrom
k-lpmg:fix/claude-user-hook-stands-down
Open

k-lpmg wants to merge 3 commits into
trailhq:mainfrom
k-lpmg:fix/claude-user-hook-stands-down

Conversation

@k-lpmg

@k-lpmg k-lpmg commented Sep 16, 2026

Copy link
Copy Markdown

Problem

Since #276, graft init wires Claude Code twice: the repo's own .claude/settings.json, and ~/.claude/settings.json as the floor that keeps graft alive in a worktree whose .gitignore swallowed the repo files. Claude Code runs every matching hook entry, so a repo that carries both copies — the common case: settings.json is tracked, so every worktree has it — fires each hook twice.

That is not just a duplicated line in the transcript:

event with both copies (0.18.0)
prompt two graft ask children race on every prompt; both read the session before either writes it, so the retrieval pack is injected twice
tool-savings recordToolUse has no idempotency key, so every source read and every [graft] tokens saved footer is counted twice — graft stats and the session_summary ratio report double
session-start the orientation (and any upkeep line) lands twice; runUpkeep / flushClosedSessions run twice
post-edit two graft check children, two blast-radius injections

And nothing at the user level can fix it by hand: the session-start replay (reconcileWiring → runInit → installClaudeGlobal) rewrites the ~/.claude copy from any clone without a current stamp, and a fresh worktree never has one — so deleting the entries from ~/.claude/settings.json lasts exactly until the next worktree session. --no-global only reaches the stamp of the clone it was run in.

Fix

This is the runtime form of what hosts/claude-global.ts already states as the intent of the user-level copy: "A project that has its own .mcp.json and settings keeps using them; this is the floor underneath, not a replacement."

The hook process now knows which entry launched it: process.argv[1] is the shim file node was asked to run. In main(), the ~/.claude/helpers/graft-hooks.cjs copy returns before doing anything when it can be sure the repo's copy fires on exactly the same occasions:

  1. the repo's own shim exists (<repo>/.claude/helpers/graft-hooks.cjs);
  2. the repo's settings.json / settings.local.json run it for the same event — matched on the command's tail (graft-hooks.cjs" tool-savings), since post-edit and tool-savings share PostToolUse — by a command of the repo's own, not one that names the user-level shim (then both entries would be this process, and both would stand down);
  3. that entry sits under the same settings event with the same matcher as the user-level entry in ~/.claude/settings.json. Compared as strings, so nothing depends on how Claude Code interprets a matcher.

Every mismatch errs toward the old double run, never toward silence:

  • the repo's own copy never stands down;
  • a missing repo shim or missing repo settings — the worktree case the floor exists for — leaves the user-level copy running as before;
  • a repo wired by an older graft keeps the user-level copy for whatever the two do not agree on: the events it lacks, or the narrower PostToolUse matcher from before 0.16 (a Read fires only the user-level entry there, so standing down would lose that source read until the replay rewrites the repo);
  • only the Claude Code user-level shim is affected. Codex's user-level hooks call a shim of the same name with the same event args, but Claude's repo hooks never run in a Codex session, so a shim anywhere but ~/.claude/helpers is left alone.

One occasion it cannot see: a graft init in a live session, whose new repo entries Claude Code loads on restart — which init already asks for.

The logic lives in hooks.ts, not the shim template, so an npm upgrade fixes every already-installed shim without a re-run of graft init.

Verification

Unit tests in test/claude-hooks.test.ts cover the decision table above — the repo settings built by the same mergeGraftHooks init uses, the user level wired by the real installClaudeGlobal — including the legacy-matcher and pasted-command cases, and the double count through main(). npm test: 1221 pass; the 4 failures in claude-shim-resolve.test.ts are identical on pristine main on this machine (a global 0.18.0 under the node lib/ outranks the tests' fake installs) and unrelated.

End to end, with a scratch repo wired by the real graft init --no-global and a scratch home wired by the real installClaudeGlobal(), firing both entries per event the way Claude Code does (concurrently for prompt):

installed 0.18.0 this branch
prompt — copies that emitted a pack repo and user-level repo only
tool-savings — one 1,000-token footer recorded as 2,000 (graftReads 2) 1,000 (graftReads 1)
session-start — copies that emitted orientation repo and user-level repo only

🤖 Generated with Claude Code

https://claude.ai/code/session_01Hr2FhKYEJo311WHCPuuVm1

Since trailhq#276 `graft init` wires Claude Code twice: the repo's own
.claude/settings.json, and ~/.claude/settings.json as the floor that keeps
graft alive in a worktree whose .gitignore swallowed the repo files. Claude
Code runs every matching entry, so a repo that carries both copies — the
common case, settings.json tracked and therefore present in every worktree
— fires each hook twice. Not just noise: two `graft ask` children race on
every prompt and the pack lands twice, recordToolUse counts every read and
every [graft] footer twice so `graft stats` and the session summary report
double, and the SessionStart orientation is injected twice.

Nothing at the user level can fix this by hand: the session-start replay
(reconcileWiring → runInit → installClaudeGlobal) rewrites the ~/.claude
copy from any clone without a current stamp, and a fresh worktree never
has one.

The hook process now knows which entry launched it — process.argv[1] is
the shim file node was asked to run — and the ~/.claude/helpers copy
returns before doing anything when the repo's shim exists and the repo's
settings run it for the same event. The repo's copy never stands down, a
missing repo shim or settings (the worktree case the floor exists for)
leaves the user-level copy running as before, and a repo wired by an older
graft with fewer hooks keeps the user-level copy for the events it lacks.
Only the Claude Code user-level shim is affected: Codex's user-level hooks
call a shim of the same name with the same event args, but Claude's repo
hooks never run in a Codex session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hr2FhKYEJo311WHCPuuVm1
@trailhq-graft

trailhq-graft Bot commented Sep 16, 2026

Copy link
Copy Markdown

🌱 graft blast radius

Nothing outside this diff depends on it. 1 area changed; no indexed dependents at depth 2.
Tests: 1 area updated its tests.
Tag: @shhdwi — Hook Configuration · @anirudhkumar-nanonets — Hook Configuration

Who knows this code — 2 people across 1 area
Area Who knows it
Hook Configuration · changed @shhdwi — 10 commits, last 1mo ago · @anirudhkumar-nanonets — 9 commits, last 14d ago

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 @ has no GitHub handle in its commit email — tag them by hand, or add a .mailmap entry. A suggestion from history, not a CODEOWNERS rule.

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.

  • Hook Configuration — 1 of 8 reached · 1 test file changed here: test/claude-hooks.test.ts
    • not reached: repoSettingsFiles, canonicalPath, graftHookEntry, hookSettingsFiles, fold, userShimPath, main

graft blast · origin/main...HEAD · depth 2 · 2 changed files

Open the interactive graph → — click an area to see its dependent symbols at file:line.

github-actions Bot added a commit that referenced this pull request Sep 16, 2026
…casions

Two ways the first cut could leave nobody running, or count too little:

- A repo settings file whose graft command names the user-level shim
  (pasted from ~/.claude/settings.json) made both entries this process,
  and both stood down. A repo entry now has to run a shim of the repo's
  own to count.
- A repo wired before 0.16 carries the narrower PostToolUse matcher
  (Bash|mcp__graft__): a Read fires the user-level entry only, so standing
  down lost that source read until the replay rewrote the repo. The repo
  entry now has to sit under the same settings event with the same matcher
  as the user-level entry — compared as strings, so nothing depends on how
  Claude Code interprets a matcher.

Every mismatch errs toward the old double run, never toward silence. Test
fixtures come from the same mergeGraftHooks and installClaudeGlobal that
init uses, so a template change cannot drift them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hr2FhKYEJo311WHCPuuVm1
github-actions Bot added a commit that referenced this pull request Sep 16, 2026
…ts launcher

shadowedByRepoHook reads process.argv[1] for exactly that. installedHookTimeout
still takes the smallest declared budget — it does not ask which entry it is
in, and the smallest fits either — so only the reason changes, not the number.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hr2FhKYEJo311WHCPuuVm1
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