#!/usr/bin/env bash # gstack-memorable — enable | disable | status for the Memorable recall bridge # (hosts/claude/hooks/memorable-user-prompt-hook, a Claude Code UserPromptSubmit # hook that hands each prompt to the third-party `memorable` CLI under gstack's # consent key, receipts and trust envelope). # # Two independent facts make up the bridge's state, and this CLI is the only # writer of both: # # registration (settings.json) gate (config.yaml memorable_recall) # NONE ──enable──▶ GSTACK ──┐ off ──enable──▶ on # ▲ │ Claude Code strips ▲ │ # └──── disable ──────────┘ the tag: still └─── disable ────┘ # (identity) GSTACK by identity # VENDOR-OWN: `memorable install-hooks` registered its own hook. enable # refuses (two entries would run the hook twice per prompt). # Mismatches are reported by `status`, never silently repaired: # gate on + NONE -> "gate on, no hook" (enable to fix) # gate off + GSTACK -> "hook is inert" (disable removes it) # # What each verb hands to the vendor binary: nothing. enable/disable/status # never execute `memorable`; they only check that it exists. The vendor's # own consent (`memorable enable` / `disable` / `forget`) is yours to run. # # Style: `set -uo pipefail` WITHOUT -e (like bin/gstack-verify-gate). Every # external call is checked explicitly with `|| return N`, so a failure is # reported where it happens and partial states are never reported as success. # # Exit codes: 0 ok · 1 refused / usage · 3 settings.json unparseable · # 4 unexpected settings shape · 5 could not acquire the lock # (the hook manager's own codes, passed through). # Heredoc delivery guard (see bin/gstack-settings-hook for the rationale). BASH_COMPAT=50 set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" GSTACK_CONFIG="$SCRIPT_DIR/gstack-config" STATE_DIR="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}}" SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}" HOOK_SOURCE="gstack-memorable" CONFIG_KEY="memorable_recall" SINK="memorable-recall" HOOK_REL="hosts/claude/hooks/memorable-user-prompt-hook" RESOLUTION_ORDER="GSTACK_MEMORABLE_BIN, MEMORABLE_BIN, ~/.memorable/bin/memorable, PATH" # JavaScript RegExp (applied by gstack-settings-hook list-items to items no # KNOWN_HOOKS row owns). Matches the vendor installer's own registration, # verified against memorable-cli 0.5.18: "/.memorable/bin/memorable" hook user-prompt VENDOR_OWN_RE='[Mm]emorable.*hook\s+user-prompt' # Canonical install root — the hook command MUST point at the stable install, # never at the tree this CLI happens to run from (setup's phantom-hooks rule). # Copied from setup:2481-2489; TODO D24 extracts a shared helper. CANONICAL_GSTACK_ROOT="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack" if [ ! -x "$CANONICAL_GSTACK_ROOT/bin/gstack-session-update" ] \ && [ -x "$HOME/.claude/skills/gstack/bin/gstack-session-update" ]; then CANONICAL_GSTACK_ROOT="$HOME/.claude/skills/gstack" fi HOOK_CMD_PATH="$CANONICAL_GSTACK_ROOT/$HOOK_REL" # Mutations go through the CANONICAL hook manager so the code that registers # is the code that will run. Every verb falls back to this tree's copy when the # canonical one is missing (enable cannot get past compat_check then; disable # and status must still work against a half-removed install). SETTINGS_HOOK="$CANONICAL_GSTACK_ROOT/bin/gstack-settings-hook" [ -x "$SETTINGS_HOOK" ] || SETTINGS_HOOK="$SCRIPT_DIR/gstack-settings-hook" EGRESS_BIN="$CANONICAL_GSTACK_ROOT/bin/gstack-egress" [ -x "$EGRESS_BIN" ] || EGRESS_BIN="$SCRIPT_DIR/gstack-egress" # Platform detection copied from setup:76-79 (TODO D24). Windows support for # this bridge is deferred whole (no process groups to contain the vendor). IS_WINDOWS=0 case "${GSTACK_MEMORABLE_TEST_UNAME:-$(uname -s)}" in MINGW*|MSYS*|CYGWIN*|Windows_NT) IS_WINDOWS=1 ;; esac usage() { cat < enable Register gstack's Memorable UserPromptSubmit hook (canonical path, timeout 5) and set memorable_recall=on. Never runs \`memorable enable\`. disable Set memorable_recall=off, remove gstack's hook entry (by identity, tag or no tag), verify both. Never runs \`memorable disable\`. status Read-only: vendor CLI, gate, registration, receipts, recent errors. Vendor CLI resolution: $RESOLUTION_ORDER. USAGE } _err() { printf 'gstack-memorable: %s\n' "$*" >&2; } # ─── lock: one lifecycle transition at a time ──────────────────────────── LOCK_DIR="$STATE_DIR/locks/memorable-bridge.lock" LOCK_STALE_S=30 # a holder older than this is a crashed writer LOCK_TRIES=50 # x LOCK_SLEEP = the 5 s give-up LOCK_SLEEP=0.1 LOCK_HELD=0 _lock_release() { [ "$LOCK_HELD" -eq 1 ] || return 0 if [ "$(cat "$LOCK_DIR/owner" 2>/dev/null)" = "$$" ]; then rm -rf "$LOCK_DIR"; fi LOCK_HELD=0 } _lock_acquire() { mkdir -p "$STATE_DIR/locks" 2>/dev/null || { _err "cannot create $STATE_DIR/locks (state directory not writable; nothing can be recorded there)"; return 5; } local tries=0 mtime now stale judged moved owner_pid while ! mkdir "$LOCK_DIR" 2>/dev/null; do tries=$((tries + 1)) # Staleness from the directory's own mtime (set atomically by the holder's # mkdir), never from a file written after it: a contender that looks in # the gap between mkdir and bookkeeping must wait, not reclaim. GNU stat # first, BSD stat second, garbage -> no takeover (same idiom as # bin/gstack-settings-hook). mtime="$(stat -c %Y "$LOCK_DIR" 2>/dev/null || stat -f %m "$LOCK_DIR" 2>/dev/null || echo "")" case "$mtime" in *[!0-9]*|"") mtime="" ;; esac now="$(date +%s)" # A holder whose recorded pid is still alive is slow, not crashed: wait. owner_pid="$(cat "$LOCK_DIR/owner" 2>/dev/null || echo "")" case "$owner_pid" in *[!0-9]*|"") owner_pid="" ;; esac if [ -n "$owner_pid" ] && kill -0 "$owner_pid" 2>/dev/null; then mtime=""; fi if [ -n "$mtime" ] && [ $((now - mtime)) -gt "$LOCK_STALE_S" ]; then # Atomic rename: exactly one contender reclaims a stale lock; the loser # loops and re-contends against the winner's fresh mkdir. The inode # check closes the gap between judging and renaming: a contender that # judged the OLD directory stale must not carry off the FRESH one a # faster contender just created in its place. A rename that fails # (locks dir not writable by this user) falls through to the give-up # counter below instead of spinning. judged="$(stat -c %i "$LOCK_DIR" 2>/dev/null || stat -f %i "$LOCK_DIR" 2>/dev/null || echo "")" stale="$LOCK_DIR.stale.$$-$RANDOM" if mv "$LOCK_DIR" "$stale" 2>/dev/null; then moved="$(stat -c %i "$stale" 2>/dev/null || stat -f %i "$stale" 2>/dev/null || echo "")" if [ -n "$judged" ] && [ "$moved" = "$judged" ]; then rm -rf "$stale" 2>/dev/null || true else # Not the directory we judged: a fresh holder's lock. Put it back. mv "$stale" "$LOCK_DIR" 2>/dev/null || _err "lock bookkeeping: could not restore a fresh lock moved aside at $stale" fi continue fi fi if [ "$tries" -ge "$LOCK_TRIES" ]; then _err "another gstack-memorable is running (lock $LOCK_DIR; stale but not reclaimable if older than ${LOCK_STALE_S}s); try again"; return 5; fi sleep "$LOCK_SLEEP" done printf '%s\n' "$$" > "$LOCK_DIR/owner" LOCK_HELD=1 trap _lock_release EXIT } # ─── probes (read-only) ────────────────────────────────────────────────── resolve_memorable() { local override="${GSTACK_MEMORABLE_BIN:-${MEMORABLE_BIN:-}}" if [ -n "$override" ]; then override="${override%\"}"; override="${override#\"}" case "$override" in /*) [ -f "$override" ] && [ -x "$override" ] && { printf '%s\n' "$override"; return 0; } ;; *) command -v "$override" 2>/dev/null && return 0 ;; esac return 1 # an explicit override that does not resolve is an error, never a fall-through 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 } # Gate value or "unknown" (gstack-config missing/failed). gate_value() { local v v="$("$GSTACK_CONFIG" get "$CONFIG_KEY" 2>/dev/null)" || { echo unknown; return 0; } printf '%s\n' "${v:-off}" } # Registration state via the hook manager's identity view. Sets: # REG_STATE none | gstack | vendor | both | unparseable | shape | unreadable # REG_GSTACK newline-separated JSON string literals of gstack-owned commands # REG_VENDOR newline-separated JSON string literals of the vendor's own commands REG_STATE=""; REG_GSTACK=""; REG_VENDOR="" registration_state() { local rc REG_GSTACK="$("$SETTINGS_HOOK" list-items --event UserPromptSubmit --owned-by "$HOOK_SOURCE" 2>/dev/null)"; rc=$? case "$rc" in 0) ;; 3) REG_STATE="unparseable"; return 0 ;; 4) REG_STATE="shape"; return 0 ;; *) REG_STATE="unreadable"; return 0 ;; esac REG_VENDOR="$("$SETTINGS_HOOK" list-items --event UserPromptSubmit --command-regex "$VENDOR_OWN_RE" 2>/dev/null)"; rc=$? [ "$rc" -eq 0 ] || { REG_STATE="unreadable"; return 0; } if [ -n "$REG_GSTACK" ] && [ -n "$REG_VENDOR" ]; then REG_STATE="both" elif [ -n "$REG_GSTACK" ]; then REG_STATE="gstack" elif [ -n "$REG_VENDOR" ]; then REG_STATE="vendor" else REG_STATE="none"; fi } _reg_exit_code() { case "$REG_STATE" in unparseable) echo 3 ;; shape) echo 4 ;; *) echo 1 ;; esac } _reg_problem_text() { case "$REG_STATE" in unparseable) echo "$SETTINGS_FILE is not valid JSON (fix or restore it; see gstack-settings-hook rollback)" ;; shape) echo "$SETTINGS_FILE has an unexpected shape under hooks.UserPromptSubmit (not an array)" ;; unreadable) echo "the hook manager could not read $SETTINGS_FILE" ;; esac } # The canonical install must carry THIS bridge: a worktree CLI registering an # older hook at the stable path would run code without the gate or receipts. compat_check() { [ -x "$HOOK_CMD_PATH" ] || { _err "no stable install carries the bridge hook at $HOOK_CMD_PATH; run ./setup (or /gstack-upgrade) first"; return 1; } [ -f "$HOOK_CMD_PATH.ts" ] || { _err "the stable install at $CANONICAL_GSTACK_ROOT predates this bridge (no memorable-user-prompt-hook.ts); run ./setup first"; return 1; } local here there here="$(cat "$ROOT_DIR/VERSION" 2>/dev/null)"; there="$(cat "$CANONICAL_GSTACK_ROOT/VERSION" 2>/dev/null)" if [ -n "$here" ] && [ "$here" != "$there" ]; then _err "the stable install at $CANONICAL_GSTACK_ROOT is version '${there:-unknown}' but this tree is '$here'; run ./setup so the registered hook is the code that will run" return 1 fi # Captured, not piped: under pipefail the probe's own non-zero exit would # mask a matching grep and let an old hook manager through. local probe probe="$("$SETTINGS_HOOK" list-items 2>&1)" || true if printf '%s' "$probe" | grep -q "Unknown action"; then _err "the stable install's hook manager does not know list-items; run ./setup first"; return 1 fi return 0 } # ─── enable ────────────────────────────────────────────────────────────── enable_bridge() { local vendor prior_gate ensure_out ensure_rc verb _lock_acquire || return $? [ -x "$SETTINGS_HOOK" ] || { _err "missing hook manager: $SETTINGS_HOOK"; return 1; } [ -x "$GSTACK_CONFIG" ] || { _err "missing $GSTACK_CONFIG"; return 1; } if [ "$IS_WINDOWS" -eq 1 ]; then _err "Windows is not supported by the Memorable bridge yet (no way to contain the vendor process); tracked in TODOS.md: Windows support for the Memorable bridge (D21)" return 1 fi vendor="$(resolve_memorable)" || { _err "Memorable CLI not found (checked $RESOLUTION_ORDER). Install it yourself: npm i -g memorable-cli. gstack never installs it."; return 1; } compat_check || return 1 prior_gate="$(gate_value)" registration_state case "$REG_STATE" in unparseable|shape|unreadable) _err "cannot read the current registration: $(_reg_problem_text)"; return "$(_reg_exit_code)" ;; vendor|both) cat >&2 <&1)"; ensure_rc=$? if [ "$ensure_rc" -ne 0 ]; then _err "settings hook update failed: $(printf '%s\n' "$ensure_out" | head -1): run $SETTINGS_HOOK manually (nothing changed; the gate is still '$prior_gate')" return "$ensure_rc" fi case "$ensure_out" in *unchanged*) verb="unchanged" ;; *re-pointed*) verb="re-pointed" ;; *) verb="registered" ;; esac if ! "$GSTACK_CONFIG" set "$CONFIG_KEY" on >/dev/null 2>&1; then # Restore the CAPTURED prior state, never an assumed one: a registration # that predates this run stays; the gate goes back to what it was. if [ "$verb" = "registered" ] && [ "$REG_STATE" = "none" ]; then "$SETTINGS_HOOK" remove-source --source "$HOOK_SOURCE" >/dev/null 2>&1 || true fi case "$prior_gate" in on|off) "$GSTACK_CONFIG" set "$CONFIG_KEY" "$prior_gate" >/dev/null 2>&1 || true ;; esac _err "could not record consent (gstack-config set $CONFIG_KEY on failed); a registration made by this run was removed, a pre-existing one was kept; gate is '$prior_gate'" return 1 fi cat </dev/null 2>&1 || gate_rc=$? else _err "missing $GSTACK_CONFIG"; gate_rc=1 fi if [ -x "$SETTINGS_HOOK" ]; then remove_out="$("$SETTINGS_HOOK" remove-source --source "$HOOK_SOURCE" 2>&1)" || remove_rc=$? else _err "missing hook manager: $SETTINGS_HOOK"; remove_rc=1 fi # Verify BOTH resulting states; report each, never a blended "done". gate_after="$(gate_value)" registration_state local ok=0 if [ "$gate_rc" -eq 0 ] && [ "$gate_after" = "off" ]; then echo "consent: $CONFIG_KEY=off" else _err "consent: could not set $CONFIG_KEY=off (gstack-config exit $gate_rc, value now '$gate_after')"; ok=1 fi case "$REG_STATE" in none|vendor) if [ "$remove_rc" -eq 0 ]; then echo "hook: removed (${remove_out##*OK: })" else echo "hook: no gstack entry remains (the hook manager exited $remove_rc: $(printf '%s\n' "$remove_out" | head -1))"; fi ;; gstack|both) _err "hook: a gstack-owned entry survived in $SETTINGS_FILE:"; printf '%s\n' "$REG_GSTACK" | sed 's/^/ /' >&2; ok=1 ;; *) _err "hook: cannot verify removal: $(_reg_problem_text)"; ok=$(_reg_exit_code) ;; esac [ "$remove_rc" -eq 0 ] || { [ "$remove_rc" -ge 3 ] && ok=$remove_rc; } if resolve_memorable >/dev/null 2>&1; then echo "Memorable's own consent is unchanged; to stop or erase capture: memorable disable | memorable forget" else echo "Memorable CLI not found: nothing of the vendor's to revoke here (gstack's hook entry is gone)" fi echo "In-flight prompts that already passed the gate complete; the next prompt is off." return "$ok" } # ─── status (read-only; never executes the vendor) ─────────────────────── status_bridge() { local vendor gate n if ! command -v bun >/dev/null 2>&1; then echo "bun: missing (the hook manager and the hook itself need bun; install bun first)" fi if vendor="$(resolve_memorable)"; then echo "Memorable CLI: available ($vendor); tested against the memorable-cli 0.5.18 hook contract" else echo "Memorable CLI: not found (checked $RESOLUTION_ORDER)" fi gate="$(gate_value)" echo "memorable_recall: $gate" registration_state case "$REG_STATE" in none) echo "Claude UserPromptSubmit hook: not registered" ;; gstack) echo "Claude UserPromptSubmit hook: registered by gstack"; printf '%s\n' "$REG_GSTACK" | sed 's/^/ /' ;; vendor) echo "Claude UserPromptSubmit hook: registered by Memorable itself"; printf '%s\n' "$REG_VENDOR" | sed 's/^/ /' echo " gstack is not managing it; 'gstack-memorable enable' would refuse (it would double the hook)." ;; both) echo "Claude UserPromptSubmit hook: registered by BOTH gstack and Memorable (the hook runs twice per prompt; remove one)" printf '%s\n' "$REG_GSTACK" "$REG_VENDOR" | sed 's/^/ /' ;; *) echo "Claude UserPromptSubmit hook: unknown ($(_reg_problem_text))" ;; esac if [ "$gate" = "on" ] && [ "$REG_STATE" = "none" ]; then echo "mismatch: gate on, no hook registered (run: gstack-memorable enable)"; fi if [ "$gate" != "on" ] && { [ "$REG_STATE" = "gstack" ] || [ "$REG_STATE" = "both" ]; }; then echo "mismatch: hook registered but gate is '$gate' (hook is inert; run: gstack-memorable disable to remove it)"; fi if [ "$IS_WINDOWS" -eq 1 ]; then echo "platform: Windows is not supported by this bridge yet (TODOS.md: Windows support for the Memorable bridge, D21)"; fi if [ -x "$EGRESS_BIN" ] && command -v bun >/dev/null 2>&1; then # Count the filtered array, not a formatting artefact of the pretty-printed # JSON; a failed query is reported as unknown, never as an empty history. local egress_json if egress_json="$("$EGRESS_BIN" list --sink "$SINK" --json 2>/dev/null)"; then n="$(printf '%s' "$egress_json" | bun -e 'const a=JSON.parse(require("fs").readFileSync(0,"utf8")||"[]");console.log(Array.isArray(a)?a.length:0)' 2>/dev/null)" case "$n" in *[!0-9]*|"") n="unknown (could not parse gstack-egress output)" ;; esac else n="unknown (gstack-egress list failed; run it yourself)" fi echo "receipts: $n for sink $SINK (gstack-egress list --sink $SINK)" # Same resolution as lib/egress-receipt.ts resolveEgressHome: GSTACK_HOME, GSTACK_STATE_DIR, ~/.gstack. local ledger size ledger="${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}/security/egress.jsonl" if [ -f "$ledger" ]; then size="$(wc -c < "$ledger" | tr -d ' ')" if [ "${size:-0}" -gt 26214400 ]; then echo "ledger: $ledger ($((size / 1048576)) MiB; above the 25 MiB warning, rotation is a filed TODO: this sink appends two lines per prompt)" else echo "ledger: $ledger ($(( (size + 1023) / 1024 )) KiB; this sink appends two lines per prompt)" fi fi fi if [ -f "$STATE_DIR/hook-errors.log" ]; then n="$(grep -c 'memorable-user-prompt-hook' "$STATE_DIR/hook-errors.log" 2>/dev/null || true)" if [ "${n:-0}" -gt 0 ]; then echo "recent hook errors ($STATE_DIR/hook-errors.log):" grep 'memorable-user-prompt-hook' "$STATE_DIR/hook-errors.log" | tail -3 | sed 's/^/ /' fi fi return 0 } case "${1:-}" in enable) enable_bridge ;; disable) disable_bridge ;; status) status_bridge ;; -h|--help|help) usage ;; *) usage >&2; exit 1 ;; esac