feat: add optional Memorable workflow memory

This commit is contained in:
AdvaiytSane
2026-09-08 03:17:38 -04:00
parent 0530392821
commit 6bd5d477b2
7 changed files with 337 additions and 0 deletions
+8
View File
@@ -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.**
+19
View File
@@ -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.
+126
View File
@@ -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 <<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
}
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
+1
View File
@@ -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" }
};
+25
View File
@@ -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
+84
View File
@@ -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<typeof fixture>) {
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);
});
});
@@ -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);