mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
feat: add optional Memorable workflow memory
This commit is contained in:
@@ -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.**
|
||||
|
||||
@@ -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.
|
||||
|
||||
Executable
+126
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user