From 645a870c978d951937adaa947b6e56d5d3f325ce Mon Sep 17 00:00:00 2001 From: AdvaiytSane Date: Tue, 8 Sep 2026 03:17:38 -0400 Subject: [PATCH] feat: add optional Memorable workflow memory (cherry picked from commit 6bd5d477b2ffd22058c476e9d875831cd8193ace) --- CHANGELOG.md | 8 ++ README.md | 19 +++ bin/gstack-memorable | 126 ++++++++++++++++++ bin/gstack-settings-hook | 1 + hosts/claude/hooks/memorable-user-prompt-hook | 25 ++++ test/gstack-memorable.test.ts | 84 ++++++++++++ .../gstack-settings-hook-schema-aware.test.ts | 74 ++++++++++ 7 files changed, 337 insertions(+) create mode 100755 bin/gstack-memorable create mode 100755 hosts/claude/hooks/memorable-user-prompt-hook create mode 100644 test/gstack-memorable.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ce0dcfec..a106ce873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 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 31f413706..3ace725cc 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,25 @@ prune-stale --repoint` removes dead gstack hook entries, re-points stale ones at the stable install, and collapses duplicates, printing one line (and writing a backup beside the file) only when it changed something. +### Optional Memorable workflow memory (Claude Code only) + +gstack can connect Claude Code to the external `memorable` CLI for workflow +capture and injection. It is off by default, and gstack does not install or +bundle Memorable. When enabled, its hooks capture all Claude Code prompts, not +only gstack commands, and inject relevant workflow guidance into later prompts. +Review Memorable's storage and privacy settings before enabling it; Memorable, +not gstack, owns the captured data and any network access. + +```bash +bin/gstack-memorable enable +bin/gstack-memorable status +bin/gstack-memorable disable +``` + +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. + ### 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 new file mode 100755 index 000000000..28c0b07df --- /dev/null +++ b/bin/gstack-memorable @@ -0,0 +1,126 @@ +#!/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" +MEMORABLE_HOOK="$ROOT_DIR/hosts/claude/hooks/memorable-user-prompt-hook" +HOOK_SOURCE="gstack-memorable" + +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 +} + +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 +} + +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() { + 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 + } + + "$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() { + 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" + 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 diff --git a/bin/gstack-settings-hook b/bin/gstack-settings-hook index cfb9feef6..d850d31bb 100755 --- a/bin/gstack-settings-hook +++ b/bin/gstack-settings-hook @@ -115,6 +115,7 @@ var KNOWN_HOOKS = { "question-preference-hook": { source: "plan-tune-cathedral", event: "PreToolUse", matcher: "(AskUserQuestion|mcp__.*__AskUserQuestion)", relpath: "hosts/claude/hooks/question-preference-hook" }, "auq-error-fallback-hook": { source: "auq-error-fallback", event: "PostToolUse", matcher: "(AskUserQuestion|mcp__.*__AskUserQuestion)", relpath: "hosts/claude/hooks/auq-error-fallback-hook" }, "timeline-stop-hook": { source: "gstack-timeline-stop", event: "Stop", matcher: "", relpath: "hosts/claude/hooks/timeline-stop-hook" }, + "memorable-user-prompt-hook": { source: "gstack-memorable", event: "UserPromptSubmit", matcher: "", relpath: "hosts/claude/hooks/memorable-user-prompt-hook" }, "gstack-session-update": { source: "gstack-session-update", event: "SessionStart", matcher: "", relpath: "bin/gstack-session-update" }, "gstack-verify-gate": { source: "verify-gate", event: "Stop", matcher: "", relpath: "bin/gstack-verify-gate" } }; diff --git a/hosts/claude/hooks/memorable-user-prompt-hook b/hosts/claude/hooks/memorable-user-prompt-hook new file mode 100755 index 000000000..8b8400d8d --- /dev/null +++ b/hosts/claude/hooks/memorable-user-prompt-hook @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Optional Memorable UserPromptSubmit hook. Missing or failing integrations +# must never interrupt Claude Code. +set -u + +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 +} + +MEMORABLE_CLI="$(resolve_memorable 2>/dev/null)" || exit 0 +[ -n "$MEMORABLE_CLI" ] || exit 0 + +(exec "$MEMORABLE_CLI" hook user-prompt) || exit 0 +exit 0 diff --git a/test/gstack-memorable.test.ts b/test/gstack-memorable.test.ts new file mode 100644 index 000000000..76cb5d2f4 --- /dev/null +++ b/test/gstack-memorable.test.ts @@ -0,0 +1,84 @@ +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'; +import { spawnSync } from 'child_process'; + +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[] = []; + +afterEach(() => { + for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }); +}); + +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, + }; +} + +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']); + }); + + test('hook delegates stdin/stdout and fails open when Memorable is unavailable', () => { + const f = fixture(); + const payload = '{"session_id":"s1","prompt":"repeat the task"}'; + const delegated = spawnSync(HOOK, [], { env: envFor(f), input: payload, encoding: 'utf8' }); + expect(delegated.status).toBe(0); + expect(delegated.stdout).toContain('"additionalContext":"remembered"'); + expect(readFileSync(f.log, 'utf8')).toContain('hook user-prompt'); + + const missingEnv = { ...process.env, HOME: f.home, MEMORABLE_BIN: join(f.home, 'missing') }; + const missing = spawnSync(HOOK, [], { env: missingEnv, input: payload, encoding: 'utf8' }); + expect(missing.status).toBe(0); + expect(missing.stdout).toBe(''); + expect(missing.stderr).toBe(''); + }); + + 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); + }); +}); diff --git a/test/gstack-settings-hook-schema-aware.test.ts b/test/gstack-settings-hook-schema-aware.test.ts index 7a0ce7b83..73b732de8 100644 --- a/test/gstack-settings-hook-schema-aware.test.ts +++ b/test/gstack-settings-hook-schema-aware.test.ts @@ -772,6 +772,80 @@ describe('remove-source: per-item', () => { }); }); +describe('Memorable UserPromptSubmit hook ownership', () => { + const source = 'gstack-memorable'; + const stale = '/old/worktree/hosts/claude/hooks/memorable-user-prompt-hook'; + const canonical = '/stable/gstack/hosts/claude/hooks/memorable-user-prompt-hook'; + const foreign = '/Users/me/my-user-prompt-hook'; + + test('ensure-event is idempotent once the canonical wrapper is registered', () => { + const args = [ + 'ensure-event', '--event', 'UserPromptSubmit', + '--command', canonical, '--source', source, + ]; + const first = runIso(args); + expect(first.exitCode).toBe(0); + expect(first.stdout).toContain('hook registered'); + const afterFirst = fs.readFileSync(settingsFile, 'utf-8'); + const backupsAfterFirst = backups(); + + const second = runIso(args); + expect(second.exitCode).toBe(0); + expect(second.stdout).toContain('hook unchanged'); + expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(afterFirst); + expect(backups()).toEqual(backupsAfterFirst); + expect(settings().hooks.UserPromptSubmit).toHaveLength(1); + }); + + test('ensure-event re-points only the wrapper in a mixed entry and preserves the foreign hook', () => { + fs.writeFileSync(settingsFile, JSON.stringify({ + hooks: { + UserPromptSubmit: [{ + hooks: [ + { type: 'command', command: foreign }, + { type: 'command', command: stale }, + ], + }], + }, + }, null, 2)); + + const r = runIso([ + 'ensure-event', '--event', 'UserPromptSubmit', + '--command', canonical, '--source', source, + ]); + expect(r.exitCode).toBe(0); + const entries = settings().hooks.UserPromptSubmit; + expect(entries).toHaveLength(1); + expect(entries[0].hooks).toEqual([ + { type: 'command', command: foreign }, + { type: 'command', command: canonical }, + ]); + expect(entries[0]._gstack_source).toBeUndefined(); + }); + + test('remove-source removes only the Memorable wrapper from a tagged mixed entry', () => { + fs.writeFileSync(settingsFile, JSON.stringify({ + hooks: { + UserPromptSubmit: [{ + _gstack_source: source, + hooks: [ + { type: 'command', command: foreign }, + { type: 'command', command: stale }, + ], + }], + }, + }, null, 2)); + + const r = runIso(['remove-source', '--source', source]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/removed 1 hook/); + const entries = settings().hooks.UserPromptSubmit; + expect(entries).toHaveLength(1); + expect(entries[0].hooks).toEqual([{ type: 'command', command: foreign }]); + expect(entries[0]._gstack_source).toBeUndefined(); + }); +}); + describe('prune-stale', () => { test('prunes dead gstack items; keeps live gstack and dead non-gstack', () => { const canon = mkCanon(tmpDir);