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)
This commit is contained in:
Nikhil Krishnaswamy
2026-09-08 17:28:41 +00:00
committed by Garry Tan
parent 645a870c97
commit 01c5ff2791
4 changed files with 117 additions and 9 deletions
-8
View File
@@ -1,13 +1,5 @@
# Changelog
## [Unreleased]
- Added `bin/gstack-memorable enable|status|disable`, a default-off, Claude
Code-only bridge to the external `memorable` CLI. When enabled, its hooks
capture all Claude Code prompts, not only gstack commands, and inject relevant
workflow guidance. Hook failures fail open so Claude continues normally. This
is procedural guidance, not deterministic replay, and is unrelated to Aside.
## [1.81.0.0] - 2026-09-06
**Aside is the browser gstack drives first. Every browsing skill, the PDF and diagram renderer, and web research go through it.**
+21
View File
@@ -311,6 +311,27 @@ The hooks fail open: if Memorable is missing or errors, Claude continues
normally. This is recalled procedural guidance, not deterministic replay, and
it is unrelated to Aside or browser automation.
**Exactly what leaves the machine, per command this bridge can trigger.** The
hook makes no network call of its own; every row below is the third-party CLI
acting under its own consent, which is why there is no gstack egress receipt to
read. `gstack-egress` will not show these.
| Command | What leaves the machine |
|---|---|
| `command -v memorable`, `gstack-memorable status` | Nothing. Both are local reads. |
| `gstack-memorable enable` | Nothing from gstack. It runs `memorable enable`, which records consent on your machine. |
| the hook, on every prompt | The prompt text you typed, to Memorable's embed endpoint, when the local lexical match misses. Nothing else at prompt time. |
| capture, at session end | Memorable's own hook, not this bridge and not gstack's consent. It sends the finished session's tool calls and their arguments to Memorable's extraction API under `memorable enable`. Turn it off with `memorable disable`. |
**What gstack pin-tests, and what is Memorable's own claim.** gstack tests the
gating and the wiring: that `enable` refuses when Memorable already registered
the hook itself, that `disable` removes only gstack's entry and never a foreign
one, that the hook exits zero and silent when the binary is missing, and that
`status` writes nothing. Everything past the process boundary is Memorable's
claim and not ours: what it stores, where it stores it, what it sends, and what
`memorable disable` and `memorable forget` actually erase. The CLI is a
closed-source npm package from a third party.
### Continuous checkpoint mode (opt-in, local by default)
Set `gstack-config set checkpoint_mode continuous` and skills auto-commit your work as you go with a `WIP:` prefix plus a structured `[gstack-context]` body (decisions, remaining work, failed approaches). Survives crashes and context switches. `/context-restore` reads those commits to reconstruct session state. `/ship` filter-squashes WIP commits before the PR (preserving non-WIP commits) so bisect stays clean. Push is opt-in via `checkpoint_push=true` — default is local-only so you don't trigger CI on every WIP commit.
+52 -1
View File
@@ -5,6 +5,7 @@ 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"
@@ -41,6 +42,36 @@ require_memorable() {
[ -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 |
@@ -67,6 +98,7 @@ hook_present() {
}
enable_memorable() {
local existing
require_memorable || return 1
[ -x "$SETTINGS_HOOK" ] || {
echo "gstack-memorable: missing hook manager: $SETTINGS_HOOK" >&2
@@ -77,6 +109,21 @@ enable_memorable() {
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 \
@@ -104,6 +151,7 @@ disable_memorable() {
}
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
@@ -111,7 +159,10 @@ status_memorable() {
fi
if hook_present; then
echo "Claude UserPromptSubmit hook: registered"
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
+44
View File
@@ -73,6 +73,50 @@ describe('gstack-memorable', () => {
expect(missing.stderr).toBe('');
});
test('enable refuses when Memorable already registered the hook itself', () => {
// Memorable's own installer (`memorable start`, `setup`, `install-hooks`)
// writes this same UserPromptSubmit hook under its own name, and that is
// the documented way to install the CLI. Registering ours beside it runs
// the command twice per prompt: injected twice, captured twice against the
// user's own allowance.
const f = fixture();
const theirs = `"${join(f.home, '.memorable', 'bin', 'memorable')}" hook user-prompt`;
writeFileSync(f.settings, JSON.stringify({
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: theirs }] }] },
}));
const enabled = spawnSync(COMMAND, ['enable'], { env: envFor(f), encoding: 'utf8' });
expect(enabled.status).not.toBe(0);
expect(enabled.stderr).toContain('already registers this hook itself');
// It refused before doing anything: no consent recorded, settings untouched.
expect(existsSync(f.log)).toBe(false);
const after = JSON.parse(readFileSync(f.settings, 'utf8'));
const commands = after.hooks.UserPromptSubmit.flatMap((e: any) => e.hooks.map((h: any) => h.command));
expect(commands).toEqual([theirs]);
});
test('status names Memorable\'s own registration rather than reporting none', () => {
const f = fixture();
const theirs = `"${join(f.home, '.memorable', 'bin', 'memorable')}" hook user-prompt`;
writeFileSync(f.settings, JSON.stringify({
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: theirs }] }] },
}));
const status = spawnSync(COMMAND, ['status'], { env: envFor(f), encoding: 'utf8' });
expect(status.status).toBe(0);
expect(status.stdout).toContain('registered by Memorable itself');
expect(status.stdout).not.toContain('not registered');
});
test('a foreign UserPromptSubmit hook is not mistaken for Memorable\'s', () => {
const f = fixture();
writeFileSync(f.settings, JSON.stringify({
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: '/foreign/hook' }] }] },
}));
const enabled = spawnSync(COMMAND, ['enable'], { env: envFor(f), encoding: 'utf8' });
expect(enabled.status).toBe(0);
});
test('status is read-only and reports both dependencies', () => {
const f = fixture();
const status = spawnSync(COMMAND, ['status'], { env: envFor(f), encoding: 'utf8' });