diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync index c203937f6..3c64634c0 100755 --- a/bin/gstack-brain-sync +++ b/bin/gstack-brain-sync @@ -245,10 +245,11 @@ compute_paths_to_stage() { local mode="$1" local class_file="${2:-}" local snapshot_file="${3:-}" - python3 - "$GSTACK_HOME" "$QUEUE_DIR" "$ALLOWLIST" "$PRIVACY_MAP" "$SKIP_FILE" "$mode" "$class_file" "$snapshot_file" <<'PYEOF' + local transcripts_ok="${4:-0}" + python3 - "$GSTACK_HOME" "$QUEUE_DIR" "$ALLOWLIST" "$PRIVACY_MAP" "$SKIP_FILE" "$mode" "$class_file" "$snapshot_file" "$transcripts_ok" <<'PYEOF' import sys, json, os, fnmatch, glob -gstack_home, spool_dir, allowlist_path, privacy_path, skip_path, mode, class_file, snapshot_file = sys.argv[1:9] +gstack_home, spool_dir, allowlist_path, privacy_path, skip_path, mode, class_file, snapshot_file, transcripts_ok = sys.argv[1:10] def load_lines(path): try: @@ -365,6 +366,11 @@ for p in sorted(queue_paths): if not path_matches_any(p, allowlist_globs): classified["dropped"]["unmatched"].append(p) continue + # Staged transcript pages need transcript consent; without it they stay + # queued (retained) and on disk, and push once the user consents. + if transcripts_ok != "1" and p.split("/")[0] == "transcripts": + classified["retained"].append(p) + continue # Must survive privacy mode filter — held entries STAY QUEUED (retained): # they would sync under a higher artifacts_sync_mode, and reporting them # as "no allowlisted changes" was #2549's misattribution. @@ -387,6 +393,17 @@ for p in final: PYEOF } +# Transcript consent: transcript_ingest_mode is recent or all (trimmed, any +# case). An absent key, off, a legacy value or anything else is not consent. +# Same rule as normalizeTranscriptConsent in gstack-memory-ingest.ts. +transcripts_consented() { + "$CONFIG_BIN" has transcript_ingest_mode 2>/dev/null || return 1 + local v + v=$("$CONFIG_BIN" get transcript_ingest_mode 2>/dev/null || true) + v=$(printf '%s' "$v" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') + [ "$v" = "recent" ] || [ "$v" = "all" ] +} + # Finalize the drain: delete exactly the spool record files this drain # consumed (per the snapshot manifest) AND positively classified. Deletion is # EXPLICIT-DELETE-ONLY: a record is unlinked only when its path appears in @@ -659,7 +676,9 @@ subcmd_once() { # snapshot manifest but no classification. Finalizing on that state is what # used to wipe the queue — so on a nonzero exit, warn loudly, do NOT call # finalize_queue, and leave everything queued for the next drain. - if ! compute_paths_to_stage "$mode" "$class_file" "$snapshot_file" > "$paths_file"; then + local transcripts_ok=0 + transcripts_consented && transcripts_ok=1 + if ! compute_paths_to_stage "$mode" "$class_file" "$snapshot_file" "$transcripts_ok" > "$paths_file"; then echo "BRAIN_SYNC: warning: queue classification failed — no records consumed; next run re-drains" >&2 write_status "error" "classification failed; queue preserved (next run retries)" exit 0 diff --git a/test/brain-sync.test.ts b/test/brain-sync.test.ts index 96e50d01c..64c67dce1 100644 --- a/test/brain-sync.test.ts +++ b/test/brain-sync.test.ts @@ -294,6 +294,39 @@ describe('init + sync + restore round-trip', () => { expect(log.stdout).toMatch(/sync: 1 file/); }); + test('staged transcript pages stay queued without transcript consent; other classes still push', () => { + run(['gstack-artifacts-init', '--remote', bareRemote]); + run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']); + fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true }); + fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), + '{"skill":"x","insight":"y","ts":"2026-04-22T10:00:00Z"}\n'); + const page = 'transcripts/run-1-1/session.md'; + fs.mkdirSync(path.join(tmpHome, 'transcripts', 'run-1-1'), { recursive: true }); + fs.writeFileSync(path.join(tmpHome, page), '# Session\n\nhello\n'); + run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']); + run(['gstack-brain-enqueue', page]); + const remoteFiles = () => + spawnSync('git', ['--git-dir=' + bareRemote, 'ls-tree', '-r', '--name-only', 'main'], { encoding: 'utf-8', timeout: 30_000 }).stdout; + + expect(run(['gstack-brain-sync', '--once']).status).toBe(0); + expect(remoteFiles()).toContain('projects/p/learnings.jsonl'); + expect(remoteFiles()).not.toContain(page); + expect(spoolText()).toContain(page); + expect(fs.existsSync(path.join(tmpHome, page))).toBe(true); + + // A stored off or legacy value is not consent either. + for (const value of ['off', 'A']) { + fs.appendFileSync(path.join(tmpHome, 'config.yaml'), `transcript_ingest_mode: ${value}\n`); + expect(run(['gstack-brain-sync', '--once']).status).toBe(0); + expect(remoteFiles()).not.toContain(page); + } + + expect(run(['gstack-config', 'set', 'transcript_ingest_mode', 'recent']).status).toBe(0); + expect(run(['gstack-brain-sync', '--once']).status).toBe(0); + expect(remoteFiles()).toContain(page); + expect(spoolText()).not.toContain(page); + }); + test('restore round-trip: writes on machine A visible on machine B', () => { // Machine A. run(['gstack-artifacts-init', '--remote', bareRemote]);