Files
gstack/bin/gstack-memorable
T
Nikhil Krishnaswamy 01c5ff2791 fix: refuse the second registration, and say what leaves the machine
Two things the first cut got wrong.

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 ran the same command twice on every prompt: context
injected twice, and the session captured twice against the user's own
extraction allowance. `enable` now looks for it and refuses, naming the entry
and the file it lives in; `status` says who registered it rather than reporting
none. Matched on the command rather than on a tag, for the reason the hook
table already gives: Claude Code rewrites settings and private tags do not
survive it.

The removal instruction says to delete the entry by hand because Memorable has
no command that removes its own hook. `uninstall-hooks` is not a command in
0.5.18; it answers "unknown command".

The README said "Memorable, not gstack, owns the captured data and any network
access", which answers the question by pointing away from it. It now carries a
per-command table of exactly what leaves the machine, in the shape the adopted
gbrain section uses, and it is explicit that the hook makes no network call of
its own, that every row is the third-party CLI acting under its own consent,
and that `gstack-egress` will therefore not show any of it. Under it, the split
between what gstack pin-tests (the gating and the wiring) and what is
Memorable's claim (storage, sending, and what disable and forget erase).

The CHANGELOG entry is removed. This file has never carried an [Unreleased]
heading; every entry is a version and a date, written at release. The text is
in the pull request for whoever cuts the next one.

Three tests added: enable refuses and touches neither consent nor settings when
Memorable already holds the hook, status names that registration, and a foreign
UserPromptSubmit hook is not mistaken for Memorable's.

(cherry picked from commit e0899afa8c)
2026-09-08 17:28:41 +00:00

178 lines
5.7 KiB
Bash
Executable File

#!/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 <<EOF
Usage: gstack-memorable <enable|disable|status>
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 <<EOF
gstack-memorable: Memorable already registers this hook itself:
$existing
Registering gstack's as well would run it twice on every prompt: injected
twice, and the session captured twice against your extraction 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 to remove its own hook.
EOF
return 1
}
"$MEMORABLE_CLI" enable || return $?
"$SETTINGS_HOOK" ensure-event \
--event UserPromptSubmit \
--command "$MEMORABLE_HOOK" \
--source "$HOOK_SOURCE"
}
disable_memorable() {
local hook_rc=0 cli_rc=0
if [ -x "$SETTINGS_HOOK" ]; then
"$SETTINGS_HOOK" remove-source --source "$HOOK_SOURCE" || hook_rc=$?
else
echo "gstack-memorable: missing hook manager: $SETTINGS_HOOK" >&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