diff --git a/bin/gstack-config b/bin/gstack-config index c394718e7..1f2f71391 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -89,6 +89,20 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # # Set to true once the privacy gate has asked the user. # # Flip back to false to be re-prompted. # +# ─── Transcript ingest (gbrain memory) ─────────────────────────────── +# transcript_ingest_mode: off # recent | all | off — consent for /sync-gbrain +# # to ingest Claude Code and Codex session +# # transcripts (every project repo policy allows) +# # into your brain (local or Supabase). +# # recent — last 90 days +# # all — all history +# # off — never (other memory still syncs) +# # Absent, legacy (A-E, incremental) or unknown +# # values are not consent: transcripts are skipped +# # and /sync-gbrain asks once. An invalid value is +# # REJECTED and the stored value kept. +# # Docs: setup-gbrain/memory.md#transcripts +# # ─── Timeline Stop hook ────────────────────────────────────────────── # timeline_stop_hook: yes # Controls whether ./setup registers the timeline # # Stop hook (closes dangling session entries). @@ -195,7 +209,7 @@ lookup_default() { # the callers already assume in their own `|| echo ""` fallback. question_tuning) echo "false" ;; team_mode) echo "false" ;; - transcript_ingest_mode) echo "off" ;; + transcript_ingest_mode) echo "off" ;; # recent | all | off — transcript consent; absent = not consented (use `has`) # repo_mode: EMPTY is load-bearing — gstack-repo-mode treats any non-empty # answer as a user override and skips its own classification entirely, so # a synthesized "unknown" default turns the classifier into dead code. @@ -390,7 +404,23 @@ case "${1:-}" in ;; set) KEY="${2:?Usage: gstack-config set }" - VALUE="${3:?Usage: gstack-config set }" + if [ "$#" -lt 3 ]; then + echo "Usage: gstack-config set " >&2 + exit 1 + fi + VALUE="$3" + # A config value is one non-empty line. Reject rather than truncate, so a + # bad value never replaces the stored one. + if [ -z "$VALUE" ]; then + echo "Error: $KEY value is empty. Existing value left unchanged." >&2 + exit 1 + fi + case "$VALUE" in + *$'\n'*) + echo "Error: $KEY value contains a newline. Existing value left unchanged." >&2 + exit 1 + ;; + esac # Validate key (alphanumeric + underscore + optional @ suffix). # Accepts hex hashes and the literal "local" from endpoint_hash. if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then @@ -467,6 +497,12 @@ case "${1:-}" in echo "Error: cross_project_learnings '$VALUE' not recognized. Valid values: true, false. Existing value left unchanged." >&2 exit 1 fi + # transcript_ingest_mode is a CONSENT key: recent/all let /sync-gbrain + # ingest coding-agent transcripts. Reject a typo; never coerce or store it. + if [ "$KEY" = "transcript_ingest_mode" ] && [ "$VALUE" != "recent" ] && [ "$VALUE" != "all" ] && [ "$VALUE" != "off" ]; then + echo "Error: transcript_ingest_mode '$VALUE' not recognized. Valid values: recent, all, off. 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. @@ -479,8 +515,8 @@ case "${1:-}" in if [ ! -f "$CONFIG_FILE" ]; then printf '%s' "$CONFIG_HEADER" > "$CONFIG_FILE" fi - # Drop embedded newlines, then escape sed replacement metacharacters. - SAFE_VALUE="$(printf '%s' "$VALUE" | head -1)" + # Escape sed replacement metacharacters (newlines were rejected above). + SAFE_VALUE="$VALUE" ESC_VALUE="$(printf '%s' "$SAFE_VALUE" | sed 's/[&/\]/\\&/g')" if grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null; then # Portable in-place edit (BSD sed uses -i '', GNU sed uses -i without arg) diff --git a/test/gstack-config-defaults.test.ts b/test/gstack-config-defaults.test.ts index c0c3a6af8..1a1fbb527 100644 --- a/test/gstack-config-defaults.test.ts +++ b/test/gstack-config-defaults.test.ts @@ -223,3 +223,60 @@ describe('design_detector_install_prompted (true|false, rejecting validator)', ( } }); }); + +describe('transcript_ingest_mode (recent|all|off, rejecting validator)', () => { + const env = { ...process.env, GSTACK_STATE_ROOT: STATE }; + const set = (key: string, value: string) => + spawnSync('bash', [CONFIG_BIN, 'set', key, value], { encoding: 'utf-8', timeout: 30_000, env }); + const has = (key: string) => spawnSync('bash', [CONFIG_BIN, 'has', key], { encoding: 'utf-8', timeout: 30_000, env }).status; + const file = path.join(STATE, 'config.yaml'); + const snapshot = () => (fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null); + + test('accepts recent, all and off', () => { + for (const value of ['recent', 'all', 'off']) { + expect(set('transcript_ingest_mode', value).status).toBe(0); + expect(get('transcript_ingest_mode').out).toBe(value); + } + expect(has('transcript_ingest_mode')).toBe(0); + }); + + test('rejects legacy letters and unknown values with the file unchanged', () => { + set('transcript_ingest_mode', 'recent'); + for (const value of ['A', 'incremental', 'new-only', 'yes', 'Recent']) { + const before = snapshot(); + const r = set('transcript_ingest_mode', value); + expect(r.status).toBe(1); + expect(r.stderr).toContain( + `Error: transcript_ingest_mode '${value}' not recognized. Valid values: recent, all, off. Existing value left unchanged.`, + ); + expect(snapshot()).toBe(before); + } + expect(get('transcript_ingest_mode').out).toBe('recent'); + }); +}); + +describe('set rejects empty and multi-line values for every key', () => { + const env = { ...process.env, GSTACK_STATE_ROOT: STATE }; + const file = path.join(STATE, 'config.yaml'); + const snapshot = () => (fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null); + + test('an empty value exits 1 and leaves the file unchanged', () => { + for (const key of ['workspace_root', 'transcript_ingest_mode', 'some_free_form_key']) { + const before = snapshot(); + const r = spawnSync('bash', [CONFIG_BIN, 'set', key, ''], { encoding: 'utf-8', timeout: 30_000, env }); + expect(r.status).toBe(1); + expect(r.stderr).toMatch(/empty/i); + expect(snapshot()).toBe(before); + } + }); + + test('a value containing a newline exits 1 and leaves the file unchanged', () => { + for (const key of ['workspace_root', 'transcript_ingest_mode', 'some_free_form_key']) { + const before = snapshot(); + const r = spawnSync('bash', [CONFIG_BIN, 'set', key, 'recent\nall'], { encoding: 'utf-8', timeout: 30_000, env }); + expect(r.status).toBe(1); + expect(r.stderr).toMatch(/newline/i); + expect(snapshot()).toBe(before); + } + }); +});