diff --git a/CHANGELOG.md b/CHANGELOG.md index a106ce873..4ce0dcfec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.** diff --git a/README.md b/README.md index 3ace725cc..3cfc7244a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bin/gstack-memorable b/bin/gstack-memorable index 28c0b07df..da945059b 100755 --- a/bin/gstack-memorable +++ b/bin/gstack-memorable @@ -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 < { 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' });