diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync
index 3c64634c0..c701f08c6 100755
--- a/bin/gstack-brain-sync
+++ b/bin/gstack-brain-sync
@@ -393,15 +393,38 @@ 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.
+# Transcript consent comes from the one shared policy reader
+# (lib/transcript-consent.ts): every merged state root, the new@ cutoff and the
+# transcript_repos allowlist. Without bun nothing transcript-related can be
+# checked, so transcript pages are held (fail-closed).
+CONSENT_TS="$SCRIPT_DIR/../lib/transcript-consent.ts"
+transcript_policy() {
+ command -v bun >/dev/null 2>&1 || return 2
+ GSTACK_STATE_ROOT="$GSTACK_HOME" bun run "$CONSENT_TS" "$@"
+}
+
+# 0 when staged transcript pages may sync at all.
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" ]
+ transcript_policy --check 2>/dev/null
+}
+
+# Before every push: scan the unpublished range for transcript pages outside
+# the current consent; when some exist, the helper rewrites gstack-brain-sync's
+# own unpublished commits without them, or refuses (exit 3) when it cannot.
+# Returns 0 when the push may go ahead. A range that touches no transcript
+# page needs no scan (and no bun).
+guard_unpublished_range() {
+ local branch range="HEAD" rc=0
+ branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
+ if [ -n "$branch" ] && [ "$branch" != "HEAD" ] && git -C "$GSTACK_HOME" rev-parse --verify --quiet "origin/$branch" >/dev/null 2>&1; then
+ range="origin/$branch..HEAD"
+ fi
+ [ -n "$(git -C "$GSTACK_HOME" log --format= --name-only "$range" -- transcripts 2>/dev/null | head -1)" ] || return 0
+ transcript_policy --guard-unpublished || rc=$?
+ if [ "$rc" -eq 2 ]; then
+ echo "BRAIN_SYNC: push blocked: unpushed commits hold transcript pages and bun is not on PATH to check them against your transcript consent" >&2
+ fi
+ [ "$rc" -eq 0 ]
}
# Finalize the drain: delete exactly the spool record files this drain
@@ -637,7 +660,7 @@ subcmd_once() {
det_now=$(date +%s)
det_last=$(cat "$GSTACK_HOME/.brain-last-push-attempt" 2>/dev/null || echo 0)
case "$det_last" in ''|*[!0-9]*) det_last=0 ;; esac
- if [ "$det_unpushed" -gt 0 ] && [ "$det_unpushed" -eq "$det_total" ] && [ $(( det_now - det_last )) -ge 600 ]; then
+ if [ "$det_unpushed" -gt 0 ] && [ "$det_unpushed" -eq "$det_total" ] && [ $(( det_now - det_last )) -ge 600 ] && guard_unpublished_range; then
echo "$det_now" > "$GSTACK_HOME/.brain-last-push-attempt" 2>/dev/null || true
local det_host
det_host=$(remote_host)
@@ -676,6 +699,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.
+ # Tightened consent (new@ cutoff, narrower transcript_repos): remove the
+ # unpublished staged pages it now excludes before anything is staged.
+ [ -d "$GSTACK_HOME/transcripts" ] && { transcript_policy --purge || true; }
local transcripts_ok=0
transcripts_consented && transcripts_ok=1
if ! compute_paths_to_stage "$mode" "$class_file" "$snapshot_file" "$transcripts_ok" > "$paths_file"; then
@@ -743,6 +769,8 @@ subcmd_once() {
exit 0
}
+ # Nothing outside the transcript consent is pushed; a refused rewrite keeps the commit local.
+ guard_unpublished_range || { write_status "blocked" "push blocked: unpushed commits hold transcript pages outside your transcript consent; commit retained locally"; finalize_queue "$snapshot_file" "$class_file" "$paths_file"; exit 0; }
# Push. On reject, fetch + merge (merge driver handles JSONL) + retry once.
local push_err
push_err=$(git -C "$GSTACK_HOME" push origin HEAD 2>&1 >/dev/null) || {
@@ -766,7 +794,7 @@ subcmd_once() {
bash -c 'git -C "$1" fetch origin 2>/dev/null' _ "$GSTACK_HOME"; then
local branch
branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)
- if git -C "$GSTACK_HOME" merge --no-edit "origin/$branch" >/dev/null 2>&1; then
+ if git -C "$GSTACK_HOME" merge --no-edit "origin/$branch" >/dev/null 2>&1 && guard_unpublished_range; then
if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$push_host" curated-memory-git-push "artifacts_sync_mode!=off" \
bash -c 'git -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
diff --git a/test/transcript-scope-brain-sync.test.ts b/test/transcript-scope-brain-sync.test.ts
new file mode 100644
index 000000000..d4238cde0
--- /dev/null
+++ b/test/transcript-scope-brain-sync.test.ts
@@ -0,0 +1,180 @@
+/**
+ * Scoped transcript consent across the publication path: the ingest stages
+ * transcript pages under ~/.gstack/transcripts/ (remote-http mode), and
+ * gstack-brain-sync purges out-of-scope staged pages and scans its unpushed
+ * commits before every push, so a page outside the current consent is never
+ * published. Local bare repo as the remote; stub gbrain; no network.
+ */
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import { spawnSync } from 'child_process';
+import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
+import { tmpdir } from 'os';
+import { join, relative } from 'path';
+
+const ROOT = join(import.meta.dir, '..');
+const BIN = join(ROOT, 'bin');
+const DAY = 24 * 60 * 60 * 1000;
+const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
+
+let home: string;
+let gstackHome: string;
+let remote: string;
+
+beforeEach(() => {
+ home = mkdtempSync(join(tmpdir(), 'gstack-bscope-'));
+ gstackHome = join(home, '.gstack');
+ remote = join(home, 'remote.git');
+ mkdirSync(gstackHome, { recursive: true });
+ spawnSync('git', ['init', '--bare', '-q', '-b', 'main', remote], { timeout: 30_000 });
+ const bindir = join(home, 'bin');
+ mkdirSync(bindir);
+ writeFileSync(join(bindir, 'gbrain'), `#!/bin/sh\ncase "$1" in\n --help) printf 'Commands:\\n import
Import markdown directory\\n' ;;\n *) echo '{}' ;;\nesac\n`);
+ chmodSync(join(bindir, 'gbrain'), 0o755);
+ // Remote-http MCP mode: the ingest stages to ~/.gstack/transcripts/ for brain-sync to push.
+ writeFileSync(join(home, '.claude.json'), JSON.stringify({ mcpServers: { gbrain: { type: 'url', url: 'https://brain.example/mcp' } } }));
+});
+afterEach(() => {
+ rmSync(home, { recursive: true, force: true });
+});
+
+const env = () => ({
+ ...process.env,
+ HOME: home,
+ GSTACK_HOME: gstackHome,
+ GIT_CONFIG_NOSYSTEM: '1',
+ GIT_CONFIG_GLOBAL: join(home, '.gitconfig'),
+ PATH: `${join(home, 'bin')}:${process.env.PATH || ''}`,
+});
+const run = (cmd: string, ...args: string[]) => {
+ const r = spawnSync(join(BIN, cmd), args, { encoding: 'utf-8', timeout: 60_000, cwd: home, env: env() });
+ return { status: r.status, out: r.stdout ?? '', err: r.stderr ?? '' };
+};
+const ingest = () => {
+ const r = spawnSync('bun', [join(BIN, 'gstack-memory-ingest.ts'), '--bulk', '--quiet', '--sources', 'transcript'], { encoding: 'utf-8', timeout: 60_000, cwd: home, env: env() });
+ return { status: r.status, err: r.stderr ?? '' };
+};
+const remoteFiles = () =>
+ spawnSync('git', ['--git-dir=' + remote, 'ls-tree', '-r', '--name-only', 'main'], { encoding: 'utf-8', timeout: 30_000 }).stdout.split('\n').filter((l) => l.startsWith('transcripts/'));
+const stagedOnDisk = (): string[] => {
+ const out: string[] = [];
+ const walk = (dir: string) => {
+ if (!existsSync(dir)) return;
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
+ if (e.isDirectory()) walk(join(dir, e.name));
+ else if (e.name.endsWith('.md')) out.push(relative(gstackHome, join(dir, e.name)));
+ }
+ };
+ walk(join(gstackHome, 'transcripts'));
+ return out;
+};
+
+function repo(slug: string): string {
+ const dir = join(home, 'work', slug.replace('/', '-'));
+ mkdirSync(dir, { recursive: true });
+ spawnSync('git', ['-C', dir, 'init', '-q'], { timeout: 30_000 });
+ spawnSync('git', ['-C', dir, 'remote', 'add', 'origin', `https://github.com/${slug}.git`], { timeout: 30_000 });
+ return dir;
+}
+
+function session(id: string, cwd: string, start: string, appendNow = false): void {
+ const dir = join(home, '.claude', 'projects', 'p');
+ mkdirSync(dir, { recursive: true });
+ let body = JSON.stringify({ type: 'user', message: { role: 'user', content: `hello ${id}` }, cwd, timestamp: start }) + '\n';
+ if (appendNow) body += JSON.stringify({ type: 'user', message: { role: 'user', content: 'later' }, timestamp: iso(Date.now()) }) + '\n';
+ writeFileSync(join(dir, `${id}.jsonl`), body);
+}
+
+function initArtifacts(): void {
+ expect(run('gstack-artifacts-init', '--remote', remote).status).toBe(0);
+ expect(run('gstack-config', 'set', 'artifacts_sync_mode', 'full').status).toBe(0);
+}
+
+/** A staged transcript page as the ingest renders it. */
+function stagePage(rel: string, remoteName: string, start: string): void {
+ mkdirSync(join(gstackHome, rel, '..'), { recursive: true });
+ writeFileSync(join(gstackHome, rel), `---\nagent: claude-code\nsession_id: ${rel}\ngit_remote: ${remoteName}\nstart_time: ${start}\nsession_started_at: ${start}\nsource_path: /src/${rel}\ntitle: "x"\ntype: transcript\n---\n\nbody\n`);
+}
+
+describe.skipIf(process.platform === 'win32')('scoped consent end to end', () => {
+ test('new@: a pre-cutoff session appended later is never staged or pushed; a new one is', () => {
+ initArtifacts();
+ const now = Date.now();
+ expect(run('gstack-config', 'set', 'transcript_ingest_mode', `new@${iso(now - DAY)}`).status).toBe(0);
+ const app = repo('acme/app');
+ session('preappend0001', app, iso(now - 2 * DAY), true);
+ session('postcutoff001', app, iso(now - 60_000));
+ expect(ingest().status).toBe(0);
+ expect(stagedOnDisk().join('\n')).not.toMatch(/preappend000/);
+ expect(stagedOnDisk().join('\n')).toMatch(/postcutoff00/);
+ expect(run('gstack-brain-sync', '--discover-new').status).toBe(0);
+ expect(run('gstack-brain-sync', '--once').status).toBe(0);
+ expect(remoteFiles().join('\n')).toMatch(/postcutoff00/);
+ expect(remoteFiles().join('\n')).not.toMatch(/preappend000/);
+ });
+
+ test('allow, stage, revoke, allow: the purge removes the page and its fingerprint, so an unchanged source re-stages', () => {
+ const app = repo('acme/app');
+ repo('acme/web');
+ session('appsession001', app, iso(Date.now()));
+ expect(run('gstack-config', 'set', 'transcript_ingest_mode', 'all').status).toBe(0);
+ expect(run('gstack-config', 'set', 'transcript_repos', 'github.com/acme/app').status).toBe(0);
+ expect(ingest().status).toBe(0);
+ expect(stagedOnDisk()).toHaveLength(1);
+ const statePath = join(gstackHome, '.transcript-ingest-state.json');
+ expect(Object.keys(JSON.parse(readFileSync(statePath, 'utf-8')).sessions)).toHaveLength(1);
+
+ expect(run('gstack-config', 'set', 'transcript_repos', 'github.com/acme/web').status).toBe(0);
+ const revoked = ingest();
+ expect(revoked.err).toContain('[memory-ingest] removed 1 staged transcript pages outside the new scope');
+ expect(stagedOnDisk()).toEqual([]);
+ expect(Object.keys(JSON.parse(readFileSync(statePath, 'utf-8')).sessions)).toEqual([]);
+
+ expect(run('gstack-config', 'set', 'transcript_repos', 'github.com/acme/app').status).toBe(0);
+ expect(ingest().status).toBe(0);
+ expect(stagedOnDisk()).toHaveLength(1);
+ });
+});
+
+describe.skipIf(process.platform === 'win32')('the unpublished range is scanned before every push', () => {
+ function strandCommit(): void {
+ initArtifacts();
+ expect(run('gstack-config', 'set', 'transcript_ingest_mode', 'all').status).toBe(0);
+ stagePage('transcripts/run-1-1/app.md', 'github.com/acme/app', iso(Date.now()));
+ stagePage('transcripts/run-1-1/web.md', 'github.com/acme/web', iso(Date.now()));
+ run('gstack-brain-enqueue', 'transcripts/run-1-1/app.md');
+ run('gstack-brain-enqueue', 'transcripts/run-1-1/web.md');
+ const hook = join(remote, 'hooks', 'pre-receive');
+ writeFileSync(hook, '#!/bin/sh\nexit 1\n');
+ chmodSync(hook, 0o755);
+ expect(run('gstack-brain-sync', '--once').status).toBe(0);
+ expect(remoteFiles()).toEqual([]);
+ rmSync(hook);
+ writeFileSync(join(gstackHome, '.brain-last-push-attempt'), '0');
+ }
+
+ test('failed push, narrower allowlist, retry: the excluded page is rewritten out and purged; the rest pushes', () => {
+ strandCommit();
+ expect(run('gstack-config', 'set', 'transcript_repos', 'github.com/acme/app').status).toBe(0);
+ const retry = run('gstack-brain-sync', '--once');
+ expect(retry.status).toBe(0);
+ expect(retry.err).toContain('removed 1 excluded transcript pages from unpublished sync commits');
+ expect(remoteFiles()).toEqual(['transcripts/run-1-1/app.md']);
+ expect(existsSync(join(gstackHome, 'transcripts/run-1-1/web.md'))).toBe(false);
+ const log = spawnSync('git', ['--git-dir=' + remote, 'log', '--format=%an %s', 'main'], { encoding: 'utf-8', timeout: 30_000 }).stdout;
+ expect(log).toContain('rewritten without 1 excluded transcript page(s)');
+ });
+
+ test('failed push, then off: nothing transcript is pushed; pages wait on disk and in the queue until consent returns', () => {
+ strandCommit();
+ expect(run('gstack-config', 'set', 'transcript_ingest_mode', 'off').status).toBe(0);
+ const retry = run('gstack-brain-sync', '--once');
+ expect(retry.status).toBe(0);
+ expect(retry.err).toContain('removed 2 excluded transcript pages from unpublished sync commits');
+ expect(remoteFiles()).toEqual([]);
+ expect(stagedOnDisk().sort()).toEqual(['transcripts/run-1-1/app.md', 'transcripts/run-1-1/web.md']);
+
+ expect(run('gstack-config', 'set', 'transcript_ingest_mode', 'all').status).toBe(0);
+ expect(run('gstack-brain-sync', '--once').status).toBe(0);
+ expect(remoteFiles().sort()).toEqual(['transcripts/run-1-1/app.md', 'transcripts/run-1-1/web.md']);
+ });
+});