Problem
merge_settings() in scripts/install.sh cannot remove a stale prompt-type hook group, so one accumulates permanently in ~/.claude/settings.json.
The merge decides which groups it owns by testing whether any command in the group references /.claude/hooks/:
[ ($eh[$event] // [])[]
| select(
[ .hooks[]?.command // "" ]
| map(contains("/.claude/hooks/")) | any | not
)
]
+ ($nh[$event] // [])
| unique
A type: "prompt" hook has no command, so .hooks[]?.command // "" yields "", the any is false, and the group is kept as user-authored. The template's group is then appended.
The trailing unique only collapses groups that are byte-identical. When the template's Stop group is [prompt, command] and the stale group is [prompt] alone, the two are distinct objects and both survive — forever, across every re-merge.
Observed
On this machine hooks.Stop held two groups:
[prompt] — orphan, prompt only
[prompt, command(stop-check-clean-repo.sh)] — the template's
Both carried identical prompt text, so the end-of-turn review ran twice per turn. ./scripts/install.sh settings reported "already up to date -- skipping" because the merge is a fixed point in this state: re-running it reproduces the same two groups.
The code comment at scripts/install.sh:140-141 anticipates this case — "The final unique collapses identical groups, including the prompt-type Stop hook that carries no command for the ours-test to match" — but that only holds while the template's group is also prompt-only. Once a command hook was added alongside the prompt, the two stopped being identical and the orphan became permanent.
Suggested fix
Extend the ours-test so a group is recognized as ours when it has no command hooks but its prompt text matches a prompt in the template's group for the same event. Roughly: treat a group as ours if any command references /.claude/hooks/ or every hook in it is a prompt whose text appears in the template's groups for that event.
Worth adding a regression test that merges a [prompt]-only existing group against a [prompt, command] template group and asserts one group out, not two.
Workaround
Delete the orphan group by hand:
jq --slurpfile c ~/Desktop/Projects/claude-defaults/settings.json \
'.hooks.Stop = $c[0].hooks.Stop' ~/.claude/settings.json > /tmp/s.json
Already applied here; hooks.Stop is back to a single group.
Related
Found while fixing #158 (permission-denial exceptions in the Stop review prompt).
Problem
merge_settings()inscripts/install.shcannot remove a stale prompt-type hook group, so one accumulates permanently in~/.claude/settings.json.The merge decides which groups it owns by testing whether any command in the group references
/.claude/hooks/:A
type: "prompt"hook has nocommand, so.hooks[]?.command // ""yields"", theanyis false, and the group is kept as user-authored. The template's group is then appended.The trailing
uniqueonly collapses groups that are byte-identical. When the template'sStopgroup is[prompt, command]and the stale group is[prompt]alone, the two are distinct objects and both survive — forever, across every re-merge.Observed
On this machine
hooks.Stopheld two groups:[prompt]— orphan, prompt only[prompt, command(stop-check-clean-repo.sh)]— the template'sBoth carried identical prompt text, so the end-of-turn review ran twice per turn.
./scripts/install.sh settingsreported "already up to date -- skipping" because the merge is a fixed point in this state: re-running it reproduces the same two groups.The code comment at
scripts/install.sh:140-141anticipates this case — "The final unique collapses identical groups, including the prompt-type Stop hook that carries no command for the ours-test to match" — but that only holds while the template's group is also prompt-only. Once a command hook was added alongside the prompt, the two stopped being identical and the orphan became permanent.Suggested fix
Extend the ours-test so a group is recognized as ours when it has no command hooks but its prompt text matches a prompt in the template's group for the same event. Roughly: treat a group as ours if any command references
/.claude/hooks/or every hook in it is a prompt whose text appears in the template's groups for that event.Worth adding a regression test that merges a
[prompt]-only existing group against a[prompt, command]template group and asserts one group out, not two.Workaround
Delete the orphan group by hand:
Already applied here;
hooks.Stopis back to a single group.Related
Found while fixing #158 (permission-denial exceptions in the Stop review prompt).