diff --git a/bin/gstack-memorable b/bin/gstack-memorable index da945059b..eef7027a8 100755 --- a/bin/gstack-memorable +++ b/bin/gstack-memorable @@ -1,177 +1,347 @@ #!/usr/bin/env bash -# Opt-in wiring between gstack's Claude hook manager and Memorable. -set -u +# 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)" -SETTINGS_HOOK="$SCRIPT_DIR/gstack-settings-hook" +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}" -MEMORABLE_HOOK="$ROOT_DIR/hosts/claude/hooks/memorable-user-prompt-hook" + 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: "/.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 < - 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 + 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() { - if [ -n "${MEMORABLE_BIN:-}" ]; then - [ -f "$MEMORABLE_BIN" ] && [ -x "$MEMORABLE_BIN" ] || return 1 - printf '%s\n' "$MEMORABLE_BIN" - return 0 + 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 + 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 +# 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 - } - [ -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) } - ' + 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_memorable() { - local existing - require_memorable || return 1 - [ -x "$SETTINGS_HOOK" ] || { - echo "gstack-memorable: missing hook manager: $SETTINGS_HOOK" >&2 +# ─── 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 - } - [ -x "$MEMORABLE_HOOK" ] || { - echo "gstack-memorable: missing executable hook: $MEMORABLE_HOOK" >&2 - 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 - existing="$(GSTACK_MEMORABLE_HOOK="$MEMORABLE_HOOK" memorable_own_hook)" && { - cat >&2 <&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 - "$MEMORABLE_CLI" enable || return $? - "$SETTINGS_HOOK" ensure-event \ - --event UserPromptSubmit \ - --command "$MEMORABLE_HOOK" \ - --source "$HOOK_SOURCE" + cat </dev/null 2>&1 || gate_rc=$? if [ -x "$SETTINGS_HOOK" ]; then - "$SETTINGS_HOOK" remove-source --source "$HOOK_SOURCE" || hook_rc=$? + remove_out="$("$SETTINGS_HOOK" remove-source --source "$HOOK_SOURCE" 2>&1)" || remove_rc=$? else - echo "gstack-memorable: missing hook manager: $SETTINGS_HOOK" >&2 - hook_rc=1 + _err "missing hook manager: $SETTINGS_HOOK"; remove_rc=1 fi - - if require_memorable; then - "$MEMORABLE_CLI" disable || cli_rc=$? + # 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 - cli_rc=1 + _err "consent: could not set $CONFIG_KEY=off (gstack-config exit $gate_rc, value now '$gate_after')"; ok=1 fi - - [ "$hook_rc" -eq 0 ] && [ "$cli_rc" -eq 0 ] + 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_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" +# ─── 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 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." + if vendor="$(resolve_memorable)"; then + echo "Memorable CLI: available ($vendor); tested against the memorable-cli 0.5.18 hook contract" else - echo "Claude UserPromptSubmit hook: not registered" + 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_memorable ;; - disable) disable_memorable ;; - status) status_memorable ;; + enable) enable_bridge ;; + disable) disable_bridge ;; + status) status_bridge ;; -h|--help|help) usage ;; *) usage >&2; exit 1 ;; esac diff --git a/test/gstack-memorable.test.ts b/test/gstack-memorable.test.ts index ea557eba9..a8f4745c2 100644 --- a/test/gstack-memorable.test.ts +++ b/test/gstack-memorable.test.ts @@ -1,113 +1,332 @@ -import { afterEach, describe, expect, test } from 'bun:test'; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; -import { tmpdir } from 'os'; -import { join, resolve } from 'path'; +/** + * bin/gstack-memorable — the enable/disable/status CLI of the Memorable + * recall bridge. Free tier; the vendor is a fake sh script that only logs + * its argv (these verbs must never execute it). + * + * Isolation per test: HOME, GSTACK_HOME/STATE_ROOT/STATE_DIR (config + + * lock), GSTACK_SETTINGS_FILE, and CLAUDE_CONFIG_DIR whose skills/gstack is + * a symlink to this repo, so the canonical resolver finds THIS tree's hook + * (and VERSION matches). GSTACK_MEMORABLE_BIN names the fake. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; -const ROOT = resolve(import.meta.dir, '..'); -const COMMAND = join(ROOT, 'bin', 'gstack-memorable'); -const HOOK = join(ROOT, 'hosts', 'claude', 'hooks', 'memorable-user-prompt-hook'); -const homes: string[] = []; +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin', 'gstack-memorable'); +const CONFIG = path.join(ROOT, 'bin', 'gstack-config'); +const HOOK_REL = 'hosts/claude/hooks/memorable-user-prompt-hook'; -afterEach(() => { - for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }); -}); +let home: string; +let env: Record; +let settings: string; +let canonical: string; -function fixture() { - const home = mkdtempSync(join(tmpdir(), 'gstack-memorable-')); - homes.push(home); - const claude = join(home, '.claude'); - mkdirSync(claude, { recursive: true }); - const settings = join(claude, 'settings.json'); - const log = join(home, 'calls.log'); - const fake = join(home, 'memorable'); - writeFileSync(fake, `#!/bin/sh\nprintf '%s\\n' "$*" >> "${log}"\nif [ "$1" = hook ]; then printf '%s' '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"remembered"}}'; fi\n`); - chmodSync(fake, 0o700); - return { home, settings, log, fake }; -} - -function envFor(f: ReturnType) { - return { - ...process.env, - HOME: f.home, - GSTACK_SETTINGS_FILE: f.settings, - MEMORABLE_BIN: f.fake, +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-memorable-bin-')); + const claude = path.join(home, '.claude'); + fs.mkdirSync(path.join(claude, 'skills'), { recursive: true }); + canonical = path.join(claude, 'skills', 'gstack'); + fs.symlinkSync(ROOT, canonical); + settings = path.join(claude, 'settings.json'); + const fake = path.join(home, 'memorable'); + fs.writeFileSync(fake, `#!/bin/sh\nprintf '%s\\n' "$*" >> "$HOME/calls.log"\n`, { mode: 0o755 }); + env = { + PATH: process.env.PATH ?? '', + HOME: home, + CLAUDE_CONFIG_DIR: claude, + GSTACK_SETTINGS_FILE: settings, + GSTACK_HOME: path.join(home, '.gstack'), + GSTACK_STATE_ROOT: path.join(home, '.gstack'), + GSTACK_STATE_DIR: path.join(home, '.gstack'), + GSTACK_MEMORABLE_BIN: fake, }; +}); +afterEach(() => { fs.rmSync(home, { recursive: true, force: true }); }); + +function run(args: string[], extra: Record = {}) { + const r = spawnSync('bash', [BIN, ...args], { env: { ...env, ...extra }, encoding: 'utf8', timeout: 30_000 }); + return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; } +const readSettings = (): any => JSON.parse(fs.readFileSync(settings, 'utf8')); +const gate = () => spawnSync('bash', [CONFIG, 'get', 'memorable_recall'], { env, encoding: 'utf8', timeout: 20_000 }).stdout.trim(); +const setGate = (v: string) => spawnSync('bash', [CONFIG, 'set', 'memorable_recall', v], { env, encoding: 'utf8', timeout: 20_000 }); +const vendorCalled = () => fs.existsSync(path.join(home, 'calls.log')); +const vendorOwn = () => `"${path.join(home, '.memorable', 'bin', 'memorable')}" hook user-prompt`; +const writeSettings = (obj: unknown) => fs.writeFileSync(settings, JSON.stringify(obj, null, 2)); +const commands = () => readSettings().hooks.UserPromptSubmit.flatMap((e: any) => e.hooks.map((h: any) => h.command)); -describe('gstack-memorable', () => { - test('enable registers the hook; disable removes it without deleting foreign hooks', () => { - 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); - expect(readFileSync(f.log, 'utf8')).toContain('enable'); - let settings = JSON.parse(readFileSync(f.settings, 'utf8')); - const commands = settings.hooks.UserPromptSubmit.flatMap((e: any) => e.hooks.map((h: any) => h.command)); - expect(commands).toContain('/foreign/hook'); - expect(commands).toContain(HOOK); - - const disabled = spawnSync(COMMAND, ['disable'], { env: envFor(f), encoding: 'utf8' }); - expect(disabled.status).toBe(0); - expect(readFileSync(f.log, 'utf8')).toContain('disable'); - settings = JSON.parse(readFileSync(f.settings, 'utf8')); - const remaining = settings.hooks.UserPromptSubmit.flatMap((e: any) => e.hooks.map((h: any) => h.command)); - expect(remaining).toEqual(['/foreign/hook']); +describe('enable', () => { + test('registers the CANONICAL hook path with timeout 5, sets the gate on, never runs the vendor, explains the hand-off', () => { + const r = run(['enable']); + expect(r.status).toBe(0); + expect(r.stderr).toBe(''); + const entries = readSettings().hooks.UserPromptSubmit; + expect(entries).toHaveLength(1); + expect(entries[0]._gstack_source).toBe('gstack-memorable'); + expect(entries[0].hooks).toEqual([{ type: 'command', command: `${canonical}/${HOOK_REL}`, timeout: 5 }]); + expect(entries[0].hooks[0].command.startsWith(env.CLAUDE_CONFIG_DIR)).toBe(true); // canonical, not ROOT + expect(gate()).toBe('on'); + expect(vendorCalled()).toBe(false); + for (const s of ['registered', 'memorable_recall=on', 'gstack-egress list --sink memorable-recall', 'gstack-memorable disable', + 'within a few seconds', 'gstack-memorable status', 'memorable enable', 'memorable forget', 'HIGH-tier'] ) { + expect(r.stdout).toContain(s); + } }); - 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('twice: unchanged, one entry; over a stale worktree path: re-pointed', () => { + expect(run(['enable']).stdout).toContain('registered'); + const again = run(['enable']); + expect(again.status).toBe(0); + expect(again.stdout).toContain('unchanged'); + expect(readSettings().hooks.UserPromptSubmit).toHaveLength(1); + writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: `/dead/worktree/${HOOK_REL}`, timeout: 5 }] }] } }); + const rp = run(['enable']); + expect(rp.status).toBe(0); + expect(rp.stdout).toContain('re-pointed'); + expect(commands()).toEqual([`${canonical}/${HOOK_REL}`]); }); - 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('refuses without a stable install (no canonical tree), writes nothing', () => { + fs.rmSync(canonical); + const r = run(['enable']); + expect(r.status).toBe(1); + expect(r.stderr).toContain('run ./setup'); + expect(fs.existsSync(settings)).toBe(false); + expect(gate()).toBe('off'); }); - 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('refuses a mixed-version stable install (old hook without the .ts twin, different VERSION)', () => { + fs.rmSync(canonical); + fs.mkdirSync(path.join(canonical, 'hosts', 'claude', 'hooks'), { recursive: true }); + fs.mkdirSync(path.join(canonical, 'bin'), { recursive: true }); + for (const rel of ['bin/gstack-session-update', HOOK_REL]) fs.writeFileSync(path.join(canonical, rel), '#!/bin/sh\n', { mode: 0o755 }); + fs.copyFileSync(path.join(ROOT, 'bin', 'gstack-settings-hook'), path.join(canonical, 'bin', 'gstack-settings-hook')); + fs.chmodSync(path.join(canonical, 'bin', 'gstack-settings-hook'), 0o755); + fs.writeFileSync(path.join(canonical, 'VERSION'), '0.0.0.0\n'); + const r = run(['enable']); + expect(r.status).toBe(1); + expect(r.stderr).toMatch(/predates this bridge|is version '0.0.0.0'/); + expect(fs.existsSync(settings)).toBe(false); + expect(gate()).toBe('off'); }); - test('status is read-only and reports both dependencies', () => { - const f = fixture(); - const status = spawnSync(COMMAND, ['status'], { env: envFor(f), encoding: 'utf8' }); - expect(status.status).toBe(0); - expect(status.stdout).toContain('Memorable CLI: available'); - expect(status.stdout).toContain('Claude UserPromptSubmit hook: not registered'); - expect(existsSync(f.log)).toBe(false); + test('refuses when the vendor CLI is absent; never installs anything', () => { + const r = run(['enable'], { GSTACK_MEMORABLE_BIN: path.join(home, 'nope') }); + expect(r.status).toBe(1); + expect(r.stderr).toContain('npm i -g memorable-cli'); + expect(fs.existsSync(settings)).toBe(false); + expect(gate()).toBe('off'); + }); + + test("refuses when Memorable registered the hook itself (the real 0.5.18 installer string); settings and gate untouched", () => { + writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: vendorOwn() }] }] } }); + const before = fs.readFileSync(settings, 'utf8'); + const r = run(['enable']); + expect(r.status).toBe(1); + expect(r.stderr).toContain('already registers this hook itself'); + expect(r.stderr).toContain(settings); + expect(fs.readFileSync(settings, 'utf8')).toBe(before); + expect(gate()).toBe('off'); + expect(vendorCalled()).toBe(false); + }); + + test('a foreign UserPromptSubmit hook is not mistaken for the vendor: enable proceeds beside it', () => { + writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: '/foreign/hook' }] }] } }); + expect(run(['enable']).status).toBe(0); + expect(commands()).toEqual(['/foreign/hook', `${canonical}/${HOOK_REL}`]); + }); + + test('corrupt settings.json: exit 3, gate stays off; unexpected shape: exit 4', () => { + fs.writeFileSync(settings, '{not json'); + let r = run(['enable']); + expect(r.status).toBe(3); + expect(r.stderr).toContain('not valid JSON'); + expect(gate()).toBe('off'); + writeSettings({ hooks: { UserPromptSubmit: {} } }); + r = run(['enable']); + expect(r.status).toBe(4); + expect(gate()).toBe('off'); + }); + + test('refuses on Windows (deferred whole, D21) without touching anything', () => { + const r = run(['enable'], { GSTACK_MEMORABLE_TEST_UNAME: 'MINGW64_NT-10.0' }); + expect(r.status).toBe(1); + expect(r.stderr).toContain('Windows is not supported'); + expect(fs.existsSync(settings)).toBe(false); + }); + + test('when recording consent fails, prior state is restored: a fresh registration is removed, a pre-existing one kept', () => { + // make config.yaml unwritable AFTER the gate was read: point the state dir at a read-only file + const roState = path.join(home, 'ro-state'); + fs.mkdirSync(roState); + fs.writeFileSync(path.join(roState, 'config.yaml'), 'telemetry: off\n', { mode: 0o444 }); + fs.chmodSync(roState, 0o555); + const ro = { GSTACK_HOME: roState, GSTACK_STATE_ROOT: roState, GSTACK_STATE_DIR: roState }; + const r = run(['enable'], ro); + fs.chmodSync(roState, 0o755); + if (r.status === 0) { + // running as a user that ignores file modes (root in CI): the write succeeded, nothing to assert on rollback + expect(r.stdout).toContain('enabled'); + return; + } + expect(r.status).toBe(1); + expect(r.stderr).toContain('could not record consent'); + expect(fs.existsSync(settings) ? (readSettings().hooks ?? {}).UserPromptSubmit : undefined).toBeUndefined(); // fresh registration rolled back + }); +}); + +describe('disable', () => { + test('removes a TAG-STRIPPED registration by identity, sets the gate off, keeps the foreign sibling, exit 0', () => { + setGate('on'); + writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [ + { type: 'command', command: '/foreign/hook' }, + { type: 'command', command: `${canonical}/${HOOK_REL}`, timeout: 5 }, + ] }] } }); + const r = run(['disable']); + expect(r.status).toBe(0); + expect(r.stdout).toContain('consent: memorable_recall=off'); + expect(r.stdout).toContain('hook: removed'); + expect(r.stdout).toContain('In-flight prompts'); + expect(commands()).toEqual(['/foreign/hook']); + expect(gate()).toBe('off'); + expect(vendorCalled()).toBe(false); + }); + + test('vendor CLI absent: still exit 0, gate off, says there is nothing of the vendor to revoke', () => { + run(['enable']); + const r = run(['disable'], { GSTACK_MEMORABLE_BIN: path.join(home, 'nope') }); + expect(r.status).toBe(0); + expect(r.stdout).toContain('nothing of the vendor'); + expect(gate()).toBe('off'); + expect(readSettings().hooks).toBeUndefined(); + }); + + test('never runs memorable disable; tells the user the vendor consent is separate', () => { + run(['enable']); + const r = run(['disable']); + expect(r.stdout).toContain('memorable disable | memorable forget'); + expect(vendorCalled()).toBe(false); + }); + + test('corrupt settings.json: the gate goes off FIRST, the failure is reported, exit non-zero', () => { + setGate('on'); + fs.writeFileSync(settings, '{not json'); + const r = run(['disable']); + expect(r.status).toBe(3); + expect(gate()).toBe('off'); + expect(r.stdout).toContain('consent: memorable_recall=off'); + expect(r.stderr).toContain('not valid JSON'); + }); + + test('nothing registered: idempotent, exit 0', () => { + const r = run(['disable']); + expect(r.status).toBe(0); + expect(r.stdout).toContain('hook: removed'); + }); +}); + +describe('status (read-only)', () => { + test('fresh: vendor found, gate off, not registered; writes nothing, never runs the vendor', () => { + const r = run(['status']); + expect(r.status).toBe(0); + expect(r.stdout).toContain('Memorable CLI: available'); + expect(r.stdout).toContain('memorable_recall: off'); + expect(r.stdout).toContain('not registered'); + expect(fs.existsSync(settings)).toBe(false); + expect(vendorCalled()).toBe(false); + }); + + test('tag-stripped gstack registration, plain and bash-prefixed quoted: "registered by gstack"', () => { + setGate('on'); + for (const cmd of [`${canonical}/${HOOK_REL}`, `bash "${canonical}/${HOOK_REL}"`]) { + writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: cmd }] }] } }); + const r = run(['status']); + expect(r.stdout).toContain('registered by gstack'); + expect(r.stdout).not.toContain('not registered'); + expect(r.stdout).not.toContain('mismatch'); + } + }); + + test("vendor-own registration: 'registered by Memorable itself'", () => { + writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: vendorOwn() }] }] } }); + const r = run(['status']); + expect(r.stdout).toContain('registered by Memorable itself'); + expect(r.stdout).toContain('would refuse'); + }); + + test('mismatch lines: gate on with no hook; hook present with gate off; both registered', () => { + setGate('on'); + expect(run(['status']).stdout).toContain('mismatch: gate on, no hook'); + setGate('off'); + writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: `${canonical}/${HOOK_REL}` }] }, { hooks: [{ type: 'command', command: vendorOwn() }] }] } }); + const r = run(['status']); + expect(r.stdout).toContain('registered by BOTH'); + expect(r.stdout).toContain('hook is inert'); + }); + + test('unparseable settings and bun missing are named, exit 0', () => { + fs.writeFileSync(settings, '{bad'); + expect(run(['status']).stdout).toContain('unknown ('); + const r = run(['status'], { PATH: '/usr/bin:/bin' }); + expect(r.status).toBe(0); + expect(r.stdout).toContain('bun: missing'); + }); + + test('tails recent hook errors and counts receipts for the sink', () => { + fs.mkdirSync(env.GSTACK_HOME, { recursive: true }); + fs.writeFileSync(path.join(env.GSTACK_HOME, 'hook-errors.log'), '2026-09-08T00:00:00Z memorable-user-prompt-hook: vendor timeout\n'); + const r = run(['status']); + expect(r.stdout).toContain('recent hook errors'); + expect(r.stdout).toContain('vendor timeout'); + expect(r.stdout).toMatch(/receipts: \d+ for sink memorable-recall/); + }); + + test('vendor resolution precedence: GSTACK_MEMORABLE_BIN > MEMORABLE_BIN > ~/.memorable/bin/memorable > PATH; an unresolvable override is an error', () => { + const mk = (p: string) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, '#!/bin/sh\n', { mode: 0o755 }); return p; }; + const a = mk(path.join(home, 'a', 'memorable')); + const b = mk(path.join(home, 'b', 'memorable')); + const pinned = mk(path.join(home, '.memorable', 'bin', 'memorable')); + const onPath = mk(path.join(home, 'pathdir', 'memorable')); + const base = { GSTACK_MEMORABLE_BIN: '', MEMORABLE_BIN: '', PATH: `${path.join(home, 'pathdir')}:${env.PATH}` }; + expect(run(['status'], { ...base, GSTACK_MEMORABLE_BIN: a, MEMORABLE_BIN: b }).stdout).toContain(`available (${a})`); + expect(run(['status'], { ...base, MEMORABLE_BIN: b }).stdout).toContain(`available (${b})`); + expect(run(['status'], base).stdout).toContain(`available (${pinned})`); + fs.rmSync(pinned); + expect(run(['status'], base).stdout).toContain(`available (${onPath})`); + expect(run(['status'], { ...base, GSTACK_MEMORABLE_BIN: path.join(home, 'missing') }).stdout).toContain('not found'); + }); +}); + +describe('lifecycle lock and static pins', () => { + test('two concurrent enables serialise: one entry, gate on, both exit 0', async () => { + const kids = [0, 1].map(() => Bun.spawn(['bash', BIN, 'enable'], { env, stdout: 'pipe', stderr: 'pipe' })); + const codes = await Promise.all(kids.map((k) => k.exited)); + expect(codes).toEqual([0, 0]); + expect(readSettings().hooks.UserPromptSubmit).toHaveLength(1); + expect(gate()).toBe('on'); + expect(fs.existsSync(path.join(env.GSTACK_HOME, 'locks', 'memorable-bridge.lock'))).toBe(false); + }, 30_000); + + test('a stale lock (older than 30 s) is taken over, a fresh one is waited on', () => { + const lock = path.join(env.GSTACK_HOME, 'locks', 'memorable-bridge.lock'); + fs.mkdirSync(lock, { recursive: true }); + fs.writeFileSync(path.join(lock, 'ts'), String(Math.floor(Date.now() / 1000) - 120)); + fs.writeFileSync(path.join(lock, 'owner'), '999999'); + expect(run(['enable']).status).toBe(0); + expect(fs.existsSync(lock)).toBe(false); + }); + + test('source pins: canonical-only command, Windows refusal, no vendor invocation, explicit-status style', () => { + const src = fs.readFileSync(BIN, 'utf8'); + expect(src).toContain('IS_WINDOWS'); + expect(src).not.toMatch(/--command "\$ROOT_DIR/); + expect(src).toContain('CANONICAL_GSTACK_ROOT'); + expect(src).not.toMatch(/"\$vendor" (enable|disable|status)/); + expect(src).toContain('set -uo pipefail'); + expect(src).not.toContain('set -euo'); + expect(src).toContain('BASH_COMPAT=50'); }); });