#!/usr/bin/env bash # Opt-in wiring between gstack's Claude hook manager and Memorable. set -u SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" SETTINGS_HOOK="$SCRIPT_DIR/gstack-settings-hook" SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}" MEMORABLE_HOOK="$ROOT_DIR/hosts/claude/hooks/memorable-user-prompt-hook" HOOK_SOURCE="gstack-memorable" usage() { cat < enable Enable Memorable, then register its Claude UserPromptSubmit hook disable Remove the gstack hook, then disable Memorable status Report Memorable CLI availability and hook registration EOF } resolve_memorable() { if [ -n "${MEMORABLE_BIN:-}" ]; then [ -f "$MEMORABLE_BIN" ] && [ -x "$MEMORABLE_BIN" ] || return 1 printf '%s\n' "$MEMORABLE_BIN" return 0 fi if [ -n "${HOME:-}" ] && [ -f "$HOME/.memorable/bin/memorable" ] && [ -x "$HOME/.memorable/bin/memorable" ]; then printf '%s\n' "$HOME/.memorable/bin/memorable" return 0 fi command -v memorable 2>/dev/null } require_memorable() { MEMORABLE_CLI="$(resolve_memorable 2>/dev/null)" || { echo "gstack-memorable: Memorable CLI not found; install memorable-cli or set MEMORABLE_BIN." >&2 return 1 } [ -n "$MEMORABLE_CLI" ] || return 1 } # Memorable's own installer registers the SAME UserPromptSubmit hook, under # its own name and outside gstack's table. `memorable start`, `memorable setup` # and `memorable install-hooks` all do it, and that is the documented way to # install the CLI — so on most machines it is already there before gstack is # asked. Registering ours beside it runs the same command twice on every # prompt: context injected twice, and the session captured twice against the # user's own extraction allowance. # # Matched on the command, not on a tag, for the same reason the hook table in # gstack-settings-hook matches on command: Claude Code rewrites settings and # private tags do not survive it. memorable_own_hook() { [ -f "$SETTINGS_FILE" ] || return 1 GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e ' const fs = require("fs"); let s = {}; try { s = JSON.parse(fs.readFileSync(process.env.GSTACK_SETTINGS_PATH, "utf8")); } catch { process.exit(1); } const groups = (s.hooks && s.hooks.UserPromptSubmit) || []; const ours = process.env.GSTACK_MEMORABLE_HOOK || ""; for (const g of groups) { for (const h of (g.hooks || [])) { const c = String(h.command || ""); if (c === ours || c.includes("memorable-user-prompt-hook")) continue; if (/memorable/i.test(c) && /hook\s+user-prompt/.test(c)) { console.log(c); process.exit(0); } } } process.exit(1); ' 2>/dev/null } hook_present() { [ -x "$SETTINGS_HOOK" ] || return 1 if "$SETTINGS_HOOK" list-sources 2>/dev/null | awk -F '\t' -v source="$HOOK_SOURCE" ' $1 == "UserPromptSubmit" && $2 == source { found = 1 } END { exit(found ? 0 : 1) } '; then return 0 fi # Claude Code may strip the private source tag when it rewrites settings. # A no-op diff still proves that the canonical command itself is present. "$SETTINGS_HOOK" diff-event \ --event UserPromptSubmit \ --command "$MEMORABLE_HOOK" \ --source "$HOOK_SOURCE" 2>/dev/null | awk ' /^--- BEFORE$/ { section = 1; saw_before = 1; next } /^--- AFTER$/ { section = 2; saw_after = 1; next } section == 1 { before = before $0 "\n" } section == 2 { after = after $0 "\n" } END { exit(saw_before && saw_after && before == after ? 0 : 1) } ' } enable_memorable() { local existing require_memorable || return 1 [ -x "$SETTINGS_HOOK" ] || { echo "gstack-memorable: missing hook manager: $SETTINGS_HOOK" >&2 return 1 } [ -x "$MEMORABLE_HOOK" ] || { echo "gstack-memorable: missing executable hook: $MEMORABLE_HOOK" >&2 return 1 } existing="$(GSTACK_MEMORABLE_HOOK="$MEMORABLE_HOOK" memorable_own_hook)" && { cat >&2 <&2 hook_rc=1 fi if require_memorable; then "$MEMORABLE_CLI" disable || cli_rc=$? else cli_rc=1 fi [ "$hook_rc" -eq 0 ] && [ "$cli_rc" -eq 0 ] } status_memorable() { local existing if MEMORABLE_CLI="$(resolve_memorable 2>/dev/null)" && [ -n "$MEMORABLE_CLI" ]; then printf 'Memorable CLI: available (%s)\n' "$MEMORABLE_CLI" else echo "Memorable CLI: unavailable" fi if hook_present; then echo "Claude UserPromptSubmit hook: registered by gstack" elif existing="$(GSTACK_MEMORABLE_HOOK="$MEMORABLE_HOOK" memorable_own_hook)"; then printf 'Claude UserPromptSubmit hook: registered by Memorable itself (%s)\n' "$existing" echo " gstack is not managing it; 'gstack-memorable enable' would double it." else echo "Claude UserPromptSubmit hook: not registered" fi } case "${1:-}" in enable) enable_memorable ;; disable) disable_memorable ;; status) status_memorable ;; -h|--help|help) usage ;; *) usage >&2; exit 1 ;; esac