Files
gstack/bin/gstack-memorable
T
Garry TanandClaude Fable 5.1 05b97dbe6d fix(gstack-memorable): canonical hook path, no vendor consent, --timeout 5, identity-based status, verified disable, lifecycle lock
enable used to bake the hook path from whatever tree the CLI ran in and
to run the vendor's own `memorable enable` (its consent for storing AND
uploading session traces) before registering anything. It now resolves
the canonical install like setup does and refuses when that install does
not carry this bridge (version and hook-twin check), registers through
the canonical hook manager with --timeout 5, records gstack's own consent
in memorable_recall, never executes the vendor, and restores the captured
prior state if consent cannot be recorded. disable flips the gate first,
removes the entry by identity (tag or no tag), verifies both states and
reports partial failure instead of a blended success. status reads only:
resolution path, gate, registration by identity (gstack / vendor-own /
both / unknown), mismatch lines, receipt count, recent hook errors. enable
and disable serialise under a lock with stale takeover. Windows is
refused (TODOS.md D21). Exit codes mirror the hook manager (3/4/5).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 17:51:55 +00:00

348 lines
17 KiB
Bash
Executable File

#!/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"
# 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: "<HOME>/.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; status falls back to this tree's copy for reads.
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 <<USAGE
Usage: gstack-memorable <enable|disable|status>
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: GSTACK_MEMORABLE_BIN, MEMORABLE_BIN, ~/.memorable/bin/memorable, PATH.
USAGE
}
_err() { printf 'gstack-memorable: %s\n' "$*" >&2; }
# ─── lock: one lifecycle transition at a time ────────────────────────────
LOCK_DIR="$STATE_DIR/locks/memorable-bridge.lock"
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"; return 5; }
local tries=0 owner_ts now
while ! mkdir "$LOCK_DIR" 2>/dev/null; do
tries=$((tries + 1))
owner_ts="$(cat "$LOCK_DIR/ts" 2>/dev/null || echo 0)"
now="$(date +%s)"
if [ $((now - owner_ts)) -gt 30 ]; then rm -rf "$LOCK_DIR"; continue; fi # stale (30 s): a crashed writer
if [ "$tries" -ge 50 ]; then _err "another gstack-memorable is running (lock $LOCK_DIR); try again"; return 5; fi
sleep 0.1
done
printf '%s\n' "$$" > "$LOCK_DIR/owner"
date +%s > "$LOCK_DIR/ts"
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
if "$SETTINGS_HOOK" list-items 2>&1 | 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 D21"
return 1
fi
vendor="$(resolve_memorable)" || { _err "Memorable CLI not found (checked GSTACK_MEMORABLE_BIN, MEMORABLE_BIN, ~/.memorable/bin/memorable, PATH). 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 <<REFUSE
gstack-memorable: Memorable already registers this hook itself:
$(printf '%s\n' "$REG_VENDOR" | sed 's/^/ /')
Registering gstack's as well would run the hook twice on every prompt:
injected twice, and the session captured twice against your allowance.
Keep the one you have, or hand it to gstack: delete that entry from
$SETTINGS_FILE
and run this again. Memorable has no command that removes its own hook.
REFUSE
return 1 ;;
esac
ensure_out="$("$SETTINGS_HOOK" ensure-event --event UserPromptSubmit --command "$HOOK_CMD_PATH" --source "$HOOK_SOURCE" --timeout 5 2>&1)"; ensure_rc=$?
if [ "$ensure_rc" -ne 0 ]; then
_err "warning: settings hook update failed: $(printf '%s\n' "$ensure_out" | head -1): run $SETTINGS_HOOK manually"
return "$ensure_rc" # nothing changed: the gate is still '$prior_gate'
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); hook registration restored to its prior state, gate is '$prior_gate'"
return 1
fi
cat <<DONE
gstack-memorable: enabled.
hook: $verb ($HOOK_CMD_PATH, timeout 5 s, source $HOOK_SOURCE)
consent: $CONFIG_KEY=on (gstack's gate; revoke: gstack-memorable disable)
vendor: $vendor
What gstack hands to that binary on every prompt: Claude Code's UserPromptSubmit
JSON (session_id, cwd, transcript_path, prompt), unless it carries a HIGH-tier
credential shape or the repo's trust policy is deny/read-only. The binary runs
with your privileges in an allowlisted environment and its own process group.
Each hand-off is receipted first: gstack-egress list --sink $SINK
What the binary then sends is Memorable's claim, not gstack's.
Claude Code picks up the new hook automatically within a few seconds; if it does
not fire, restart the session. Verify with: gstack-memorable status
Memorable's own capture consent is separate and yours to run or inspect:
memorable status | memorable enable | memorable disable | memorable forget
DONE
}
# ─── disable ─────────────────────────────────────────────────────────────
disable_bridge() {
local gate_rc=0 remove_rc=0 remove_out="" gate_after
_lock_acquire || return $?
[ -x "$GSTACK_CONFIG" ] || { _err "missing $GSTACK_CONFIG"; return 1; }
# Gate first: the hook reads it on every prompt, so consent is revoked
# immediately even if the registration removal below fails.
"$GSTACK_CONFIG" set "$CONFIG_KEY" off >/dev/null 2>&1 || gate_rc=$?
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 _err "hook: removal reported exit $remove_rc but no gstack entry remains"; 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 GSTACK_MEMORABLE_BIN, MEMORABLE_BIN, ~/.memorable/bin/memorable, PATH)"
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 D21)"; fi
if [ -x "$EGRESS_BIN" ] && command -v bun >/dev/null 2>&1; then
n="$("$EGRESS_BIN" list --sink "$SINK" --json 2>/dev/null | grep -c '"sink": *"'"$SINK"'"' || true)"
echo "receipts: ${n:-0} for sink $SINK (gstack-egress list --sink $SINK)"
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