Skip to content

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

Closed

Description

@blackbullet01

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 entries

Observed 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

  1. list-sources can'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.

  2. setup's idempotence check inherits that blind spot. The plan-tune install block sets ALREADY_INSTALLED=1 only when list-sources contains both plan-tune-cathedral and auq-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

  1. Include command in 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 makes add-event genuinely idempotent and self-healing for untagged entries.
  2. Make untagged gstack hooks visible. Have list-sources also list entries whose command resolves inside the gstack install but which carry no tag, flagged as untagged, so the condition is diagnosable.
  3. 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions