Repository navigation
gstack-settings-hook add-event dedups on (event, matcher, _gstack_source) but ignores command — an untagged entry silently duplicates the same hook, firing it N× per AskUserQuestion #2382
Description
Summary
gstack-settings-hook add-event dedupes on (event, matcher, _gstack_source) and never looks at command. So an entry that is functionally identical — same event, same matcher, same command — is appended as a duplicate whenever its _gstack_source doesn't match. The hook then fires once per copy on every matching tool call.
The dedup comment at the top of the script says it dedupes "so multiple gstack registrations (plan-tune, ...) don't collide," which is true for the tagged case. The gap is the untagged/retagged one.
Root cause
bin/gstack-settings-hook, add-event:
const matchesEntry = (entry) => {
const sameMatcher = (entry.matcher || "") === matcher;
const sameSource = entry._gstack_source === source; // undefined !== 'plan-tune-cathedral'
return sameMatcher && sameSource;
};
...
if (existing) { existing.hooks = [hookEntry]; }
else { settings.hooks[event].push({ _gstack_source: source, hooks: [hookEntry], ... }); }An entry with no _gstack_source can never satisfy sameSource, so it can never be updated in place — only duplicated alongside.
Reproduction
Fully self-contained via the GSTACK_SETTINGS_FILE override (no live config touched):
T=/tmp/repro-settings.json; echo '{}' > "$T"
export GSTACK_SETTINGS_FILE="$T"
H=~/.claude/skills/gstack/bin/gstack-settings-hook
CMD=~/.claude/skills/gstack/hosts/claude/hooks/question-log-hook
M='(AskUserQuestion|mcp__.*__AskUserQuestion)'
$H add-event --event PostToolUse --matcher "$M" --command "$CMD" --source A --timeout 5 # 1 entry
$H add-event --event PostToolUse --matcher "$M" --command "$CMD" --source A --timeout 5 # 1 entry — dedup OK
# simulate the tag being absent, by any cause
python3 -c "
import json;d=json.load(open('$T'))
for g in d['hooks']['PostToolUse']: g.pop('_gstack_source',None)
json.dump(d,open('$T','w'),indent=2)"
$H list-sources # '(no gstack-tagged hooks)'
$H add-event --event PostToolUse --matcher "$M" --command "$CMD" --source A --timeout 5 # 2 entries <-- duplicate
$H add-event --event PostToolUse --matcher "$M" --command "$CMD" --source B --timeout 5 # 3 entriesObserved entry counts: 1 → 1 → (strip) → 2 → 3. The final file registers the same question-log-hook command three times, i.e. three invocations per AskUserQuestion.
Step 5 (--source B) shows the same gap without any tag loss at all: re-registering an existing command under a new source name duplicates rather than replaces.
Two things that make this hard to notice
-
list-sourcescan't see untagged entries. It only prints entries that have_gstack_source(if (entry._gstack_source)). An untagged gstack hook is wired and firing but reports as(no gstack-tagged hooks)— so the state that causes the duplication is invisible in the tool built to inspect it. -
setup's idempotence check inherits that blind spot. The plan-tune install block setsALREADY_INSTALLED=1only whenlist-sourcescontains bothplan-tune-cathedralandauq-error-fallback. With untagged entries it greps empty, concludes nothing is installed, and proceeds to register — appending duplicates next to the hooks already there.
Worth noting the consent gate hides this in CI-ish runs: with plan_tune_hooks: prompt, a non-TTY setup degrades to skip, so a headless /gstack-upgrade leaves everything alone and looks healthy. The duplication surfaces on the first interactive setup, or with plan_tune_hooks: yes.
How I hit it
On a real install, all three AUQ hook entries (question-log-hook, question-preference-hook, auq-error-fallback-hook) had no _gstack_source, while a backup from 9 days earlier showed plan-tune-cathedral present on two of them. list-sources reported none; diff-event showed the next registration would append a duplicate.
I could not determine what removed the tags, and I'm not claiming gstack did — the intervening backups were gone by the time I looked. That's why the repro above simulates the loss rather than reproducing its cause. The reportable part is that the tool duplicates instead of converging once an entry is untagged, whatever the reason — and step 5 shows the same failure with no tag loss involved.
Suggested fixes
- Include
commandin the match. Treat an entry with the same(event, matcher, command)as the same hook regardless of source — update it in place and adopt/refresh its_gstack_source. This makesadd-eventgenuinely idempotent and self-healing for untagged entries. - Make untagged gstack hooks visible. Have
list-sourcesalso list entries whosecommandresolves inside the gstack install but which carry no tag, flagged as untagged, so the condition is diagnosable. - Guard against the duplicate outcome directly — before pushing, refuse (or warn) if an entry with the same event+matcher+command already exists.
(1) alone fixes the reported behaviour; (2) is what would have made it self-diagnosing.
Environment
- gstack
1.60.1.0, global git install at~/.claude/skills/gstack(origin/main@a325940) - macOS 15.5 (Darwin 25.5.0), Claude Code
Activity
v1.67.0.0 fix: the tracker wave — XProtect self-heal, complete instal…