feat: add optional Memorable workflow memory

(cherry picked from commit 6bd5d477b2)
This commit is contained in:
AdvaiytSane
2026-09-08 17:28:40 +00:00
committed by Garry Tan
parent 0530392821
commit 645a870c97
7 changed files with 337 additions and 0 deletions
+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);