From 30347698137cd387f924272a6550fed0cae341f8 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 8 Sep 2026 17:34:13 +0000 Subject: [PATCH] feat(config): memorable_recall consent key (on|off, default off, reject-and-preserve) The gstack-side gate for the Memorable UserPromptSubmit bridge. `on` lets a Claude Code hook hand every prompt to a third-party binary, so the key follows the codex_reviews rule: an invalid value is rejected and the stored value kept, never coerced in either direction. Registered in all four places gstack-config keeps in sync (annotated header, DEFAULTS table, the set validator, and both enumeration loops). Memorable's own capture consent (`memorable enable`) is a separate thing gstack never sets. Co-Authored-By: Claude Fable 5.1 --- bin/gstack-config | 24 ++++++++- test/gstack-config-memorable-key.test.ts | 68 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 test/gstack-config-memorable-key.test.ts diff --git a/bin/gstack-config b/bin/gstack-config index d8e005753..7dcd0eb91 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -111,6 +111,18 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # # Override per-run: ./setup --plan-tune-hooks / # # --no-plan-tune-hooks, or env GSTACK_PLAN_TUNE_HOOKS. # +# ─── Memorable recall bridge (opt-in, third party) ────────────────── +# memorable_recall: off # The gstack-side consent gate for the Memorable +# # UserPromptSubmit bridge (bin/gstack-memorable). +# # off — the hook does nothing, spawns nothing (default) +# # on — the hook hands each prompt to the local +# # `memorable` CLI, receipted as memorable-recall +# # Written by `gstack-memorable enable|disable`. An +# # invalid value is REJECTED and the stored value kept: +# # a typo must never flip a third-party consent. +# # The vendor capture consent (`memorable enable`) +# # is separate; gstack never sets it. +# # ─── Advanced ──────────────────────────────────────────────────────── # codex_reviews: enabled # Master switch for Codex cross-model review. enabled = # # Codex runs as a standard step in /review, /ship, @@ -161,6 +173,7 @@ lookup_default() { redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection redact_prepush_hook) echo "false" ;; pair_agent) echo "off" ;; # remote tunnel consent — fail-closed until /pair-agent asks + memorable_recall) echo "off" ;; # on | off — Memorable bridge gate, fail-closed until `gstack-memorable enable` founder_resources) echo "true" ;; # office-hours resource pitch — #538 permanent opt-out sets false # Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline: # brain_trust_policy@ — unset on fresh install; setup-gbrain @@ -424,6 +437,13 @@ case "${1:-}" in echo "Error: cross_project_learnings '$VALUE' not recognized. Valid values: true, false. Existing value left unchanged." >&2 exit 1 fi + # memorable_recall is a CONSENT key: `on` lets a Claude Code hook hand every + # prompt to a third-party binary. Reject like codex_reviews -- a typo must + # never flip consent in either direction, so nothing is coerced or stored. + if [ "$KEY" = "memorable_recall" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then + echo "Error: memorable_recall '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged." >&2 + exit 1 + fi mkdir -p "$STATE_DIR" # Write annotated header on first creation if [ ! -f "$CONFIG_FILE" ]; then @@ -455,7 +475,7 @@ case "${1:-}" in skill_prefix checkpoint_mode checkpoint_push explain_level \ codex_reviews gstack_contributor skip_eng_review workspace_root \ artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks \ - timeline_stop_hook; do + timeline_stop_hook memorable_recall; do VALUE=$(read_config_value "$KEY" || true) SOURCE="default" if [ -n "$VALUE" ]; then @@ -472,7 +492,7 @@ case "${1:-}" in skill_prefix checkpoint_mode checkpoint_push explain_level \ codex_reviews gstack_contributor skip_eng_review workspace_root \ artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks \ - timeline_stop_hook; do + timeline_stop_hook memorable_recall; do printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")" done ;; diff --git a/test/gstack-config-memorable-key.test.ts b/test/gstack-config-memorable-key.test.ts new file mode 100644 index 000000000..9ee3fdd21 --- /dev/null +++ b/test/gstack-config-memorable-key.test.ts @@ -0,0 +1,68 @@ +/** + * memorable_recall — gstack's own consent gate for the Memorable + * UserPromptSubmit bridge (bin/gstack-memorable, hosts/claude/hooks/ + * memorable-user-prompt-hook). `on` lets a hook hand every prompt to a + * third-party binary, so the key follows the codex_reviews rule: an invalid + * value is REJECTED and the stored value left alone. A consent key that + * coerces a typo into a default is a consent key that lies in one direction + * or the other. + */ +import { describe, test, expect, beforeEach, afterEach } 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 = path.resolve(import.meta.dir, '..'); +const CONFIG_BIN = path.join(ROOT, 'bin', 'gstack-config'); +let state: string; + +function cfg(args: string[]): { code: number; out: string; err: string } { + const r = spawnSync('bash', [CONFIG_BIN, ...args], { + encoding: 'utf-8', + timeout: 30_000, + env: { ...process.env, GSTACK_STATE_ROOT: state, GSTACK_HOME: state }, + }); + return { code: r.status ?? -1, out: (r.stdout ?? '').trim(), err: r.stderr ?? '' }; +} + +beforeEach(() => { state = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cfg-memo-')); }); +afterEach(() => { fs.rmSync(state, { recursive: true, force: true }); }); + +describe('memorable_recall config key', () => { + test('defaults to off and exits 0 (a fresh install can never recall)', () => { + const r = cfg(['get', 'memorable_recall']); + expect(r.code).toBe(0); + expect(r.out).toBe('off'); + }); + + test('set on / set off round-trip', () => { + expect(cfg(['set', 'memorable_recall', 'on']).code).toBe(0); + expect(cfg(['get', 'memorable_recall']).out).toBe('on'); + expect(cfg(['set', 'memorable_recall', 'off']).code).toBe(0); + expect(cfg(['get', 'memorable_recall']).out).toBe('off'); + }); + + test('an invalid value is REJECTED (exit 1) and the stored value is preserved, in both directions', () => { + let r = cfg(['set', 'memorable_recall', 'yes']); + expect(r.code).toBe(1); + expect(r.err).toContain('Existing value left unchanged'); + expect(cfg(['get', 'memorable_recall']).out).toBe('off'); // never coerced to on + cfg(['set', 'memorable_recall', 'on']); + r = cfg(['set', 'memorable_recall', 'maybe']); + expect(r.code).toBe(1); + expect(cfg(['get', 'memorable_recall']).out).toBe('on'); // never coerced to off either + }); + + test('appears in `list` and `defaults` (the two hand-synced enumerations)', () => { + expect(cfg(['list']).out).toMatch(/memorable_recall:\s+off \(default\)/); + expect(cfg(['defaults']).out).toMatch(/memorable_recall:\s+off/); + }); + + test('the annotated header documents the key next to the other consent keys', () => { + cfg(['set', 'telemetry', 'off']); // first set writes the header + const yaml = fs.readFileSync(path.join(state, 'config.yaml'), 'utf-8'); + expect(yaml).toContain('memorable_recall: off'); + expect(yaml).toContain('gstack never sets it'); + }); +});