Conversation
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
🌱 graft blast radiusNothing outside this diff depends on it. 1 area changed; no indexed dependents at depth 2. Who knows this code — 2 people across 1 area
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 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.
Open the interactive graph → — click an area to see its dependent symbols at file:line. |
…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
…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
Problem
Since #276,
graft initwires Claude Code twice: the repo's own.claude/settings.json, and~/.claude/settings.jsonas the floor that keeps graft alive in a worktree whose.gitignoreswallowed the repo files. Claude Code runs every matching hook entry, so a repo that carries both copies — the common case:settings.jsonis tracked, so every worktree has it — fires each hook twice.That is not just a duplicated line in the transcript:
promptgraft askchildren race on every prompt; both read the session before either writes it, so the retrieval pack is injected twicetool-savingsrecordToolUsehas no idempotency key, so every source read and every[graft] tokens savedfooter is counted twice —graft statsand thesession_summaryratio report doublesession-startrunUpkeep/flushClosedSessionsrun twicepost-editgraft checkchildren, two blast-radius injectionsAnd nothing at the user level can fix it by hand: the session-start replay (
reconcileWiring → runInit → installClaudeGlobal) rewrites the~/.claudecopy from any clone without a current stamp, and a fresh worktree never has one — so deleting the entries from~/.claude/settings.jsonlasts exactly until the next worktree session.--no-globalonly reaches the stamp of the clone it was run in.Fix
This is the runtime form of what
hosts/claude-global.tsalready states as the intent of the user-level copy: "A project that has its own.mcp.jsonand 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. Inmain(), the~/.claude/helpers/graft-hooks.cjscopy returns before doing anything when it can be sure the repo's copy fires on exactly the same occasions:<repo>/.claude/helpers/graft-hooks.cjs);settings.json/settings.local.jsonrun it for the same event — matched on the command's tail (graft-hooks.cjs" tool-savings), sincepost-editandtool-savingssharePostToolUse— 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);~/.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:
PostToolUsematcher from before 0.16 (aReadfires only the user-level entry there, so standing down would lose that source read until the replay rewrites the repo);~/.claude/helpersis left alone.One occasion it cannot see: a
graft initin 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 ofgraft init.Verification
Unit tests in
test/claude-hooks.test.tscover the decision table above — the repo settings built by the samemergeGraftHooksinit uses, the user level wired by the realinstallClaudeGlobal— including the legacy-matcher and pasted-command cases, and the double count throughmain().npm test: 1221 pass; the 4 failures inclaude-shim-resolve.test.tsare identical on pristinemainon this machine (a global 0.18.0 under the nodelib/outranks the tests' fake installs) and unrelated.End to end, with a scratch repo wired by the real
graft init --no-globaland a scratch home wired by the realinstallClaudeGlobal(), firing both entries per event the way Claude Code does (concurrently forprompt):prompt— copies that emitted a packtool-savings— one 1,000-token footer recorded assession-start— copies that emitted orientation🤖 Generated with Claude Code
https://claude.ai/code/session_01Hr2FhKYEJo311WHCPuuVm1