fix(setup): hooks register the global-install path and re-point stale ones

Registering hooks from a dev worktree baked that worktree's absolute path
into settings.json — deleting the worktree left a dead hook erroring on
every session stop, and the presence-only dedup (list-sources | grep) could
never re-point it. setup's hook paths now route through _hook_install_path
(global install preferred, source dir fallback), and the new ensure-event
verb on gstack-settings-hook compares the registered command payload against
canonical: identical → no write, different → single atomic replacement
(never zero or two registrations). The plan-tune hooks had the same stale
pattern and get the same fix without re-triggering their consent prompt.

Also hardened: bun 1.3.13 turns an uncaught sync fs error in bun -e into a
SILENT exit 0 — the registrar's write path now catches, prints, and exits 1,
so a failed update can never report fake-green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-17 10:52:23 -07:00
co-authored by Claude Fable 5
parent bdc0b11664
commit c9b5ccddcf
3 changed files with 261 additions and 25 deletions
+59 -18
View File
@@ -1978,6 +1978,23 @@ if [ -x "$DETECT_BIN" ]; then
fi
fi
# Hook commands registered into ~/.claude/settings.json must survive deletion
# of the directory setup ran from. A dev-worktree setup used to bake
# $SOURCE_GSTACK_DIR's absolute path into the registration; deleting that
# worktree left a dead hook erroring on every trigger. Prefer the global
# install (~/.claude/skills/gstack — a persistent checkout, or a stable
# symlink) and fall back to the setup-time source tree only when no global
# install exists yet (first install from a fresh clone).
_hook_install_path() {
local rel="$1"
local global_hook="$HOME/.claude/skills/gstack/$rel"
if [ -x "$global_hook" ]; then
printf '%s' "$global_hook"
else
printf '%s' "$SOURCE_GSTACK_DIR/$rel"
fi
}
# 11. Plan-tune cathedral hook install (T8).
#
# Registers PostToolUse (deterministic AUQ capture) + PreToolUse (preference
@@ -1986,10 +2003,12 @@ fi
# per D4 + Codex: never mutate settings.json silently.
#
# Idempotent via _gstack_source tag = 'plan-tune-cathedral'. If both hooks
# already registered under that tag, the install is a no-op (no prompt).
PLAN_TUNE_LOG_HOOK="$SOURCE_GSTACK_DIR/hosts/claude/hooks/question-log-hook"
PLAN_TUNE_PREF_HOOK="$SOURCE_GSTACK_DIR/hosts/claude/hooks/question-preference-hook"
AUQ_ERROR_FALLBACK_HOOK="$SOURCE_GSTACK_DIR/hosts/claude/hooks/auq-error-fallback-hook"
# already registered under that tag, the install skips the consent prompt and
# only refreshes the registered command paths in place (ensure-event is a
# no-op when they already match — see the stale-path note above).
PLAN_TUNE_LOG_HOOK="$(_hook_install_path hosts/claude/hooks/question-log-hook)"
PLAN_TUNE_PREF_HOOK="$(_hook_install_path hosts/claude/hooks/question-preference-hook)"
AUQ_ERROR_FALLBACK_HOOK="$(_hook_install_path hosts/claude/hooks/auq-error-fallback-hook)"
PLAN_TUNE_INSTALL_MARKER="$HOME/.gstack/.plan-tune-hooks-prompted"
if [ "$NO_TEAM_MODE" -ne 1 ] \
@@ -2040,13 +2059,16 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
fi
_install_plan_tune_hooks() {
"$SETTINGS_HOOK" add-event \
# ensure-event (not add-event): registers when missing, RE-POINTS a stale
# command path in place when the registration differs, and is a true no-op
# (no write, no backup churn) when it already matches.
"$SETTINGS_HOOK" ensure-event \
--event PostToolUse \
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
--command "$PLAN_TUNE_LOG_HOOK" \
--source plan-tune-cathedral \
--timeout 5
"$SETTINGS_HOOK" add-event \
"$SETTINGS_HOOK" ensure-event \
--event PreToolUse \
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
--command "$PLAN_TUNE_PREF_HOOK" \
@@ -2060,7 +2082,7 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
# question-log capture hook (same event+matcher). A distinct source = a second
# PostToolUse entry; both run in parallel.
if [ -x "$AUQ_ERROR_FALLBACK_HOOK" ]; then
"$SETTINGS_HOOK" add-event \
"$SETTINGS_HOOK" ensure-event \
--event PostToolUse \
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
--command "$AUQ_ERROR_FALLBACK_HOOK" \
@@ -2070,6 +2092,10 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
}
if [ "$ALREADY_INSTALLED" -eq 1 ]; then
# Consent already recorded — no prompt. But a registration from an earlier
# setup may carry a stale absolute path (a since-deleted dev worktree);
# ensure-event re-points it in place and no-ops when everything matches.
_install_plan_tune_hooks >/dev/null 2>&1 || true
log ""
log "Plan-tune hooks already installed. Run \`$SETTINGS_HOOK list-sources\` to inspect."
elif [ "$PT_DECISION" = "yes" ]; then
@@ -2162,18 +2188,33 @@ fi
# unenforceable — interrupted sessions leaked started > completed forever.
# Register a Stop-event hook that closes dangling entries. FAIL-OPEN contract
# (F5): the hook always exits 0 and repairs best-effort — it can never block
# a session. Idempotent via the (event, source) dedup in gstack-settings-hook;
# removed by --no-team and gstack-uninstall.
TIMELINE_STOP_HOOK="$SOURCE_GSTACK_DIR/hosts/claude/hooks/timeline-stop-hook"
# a session. Removed by --no-team and gstack-uninstall.
#
# The command path prefers the global install (see _hook_install_path): a
# dev-worktree setup used to bake its own absolute dir into settings.json, so
# deleting the worktree left a dead hook erroring on every session stop — and
# the old presence-only dedup (list-sources | grep) never re-pointed it on a
# re-run. ensure-event registers when missing, replaces a stale path in place
# (one atomic write — never zero or two registrations), and no-ops when the
# registration already matches.
TIMELINE_STOP_HOOK="$(_hook_install_path hosts/claude/hooks/timeline-stop-hook)"
if [ "$NO_TEAM_MODE" -ne 1 ] && [ -x "$SETTINGS_HOOK" ] && [ -x "$TIMELINE_STOP_HOOK" ]; then
if ! "$SETTINGS_HOOK" list-sources 2>/dev/null | grep -q "gstack-timeline-stop"; then
if "$SETTINGS_HOOK" add-event \
--event Stop \
--command "$TIMELINE_STOP_HOOK" \
--source gstack-timeline-stop \
--timeout 5 >/dev/null 2>&1; then
log " registered Stop hook: session timeline entries now close even when a skill is interrupted (backup: settings.json.bak.<ts>; remove: $SETTINGS_HOOK remove-source --source gstack-timeline-stop)"
fi
if _TL_ENSURE_OUT=$("$SETTINGS_HOOK" ensure-event \
--event Stop \
--command "$TIMELINE_STOP_HOOK" \
--source gstack-timeline-stop \
--timeout 5 2>/dev/null); then
case "$_TL_ENSURE_OUT" in
*unchanged*)
: # already registered with the canonical command — quiet no-op
;;
*re-pointed*)
log " re-pointed Stop hook to $TIMELINE_STOP_HOOK (previous registration held a stale path)"
;;
*)
log " registered Stop hook: session timeline entries now close even when a skill is interrupted (backup: settings.json.bak.<ts>; remove: $SETTINGS_HOOK remove-source --source gstack-timeline-stop)"
;;
esac
fi
fi