W6 T1, T3, T4 (DX): two-question consent gates and transcript docs

Both gates recognize every stored value form (unrecognized values are asked
as not recognized by this version), ask the window (recent, all, new, off)
and then the repo scope, and store nothing until both answers are known.
setup-gbrain/memory.md#transcripts documents every value form, examples,
clearing the allowlist, multi-root merging, what the purge removes and the
downgrade steps.
This commit is contained in:
garrytan committed 2026-10-03 21:14:47 +00:00
1 parent 1576c23403
commit b730e63a9c
4 files changed
+176 -31

No files matched your search

+58 -15
View File
@@ -25,13 +25,15 @@ answer does.
Curated memory (learnings, timeline, plans, designs, retros, eureka, builder
profile) syncs on every `/sync-gbrain`. Session transcripts sync only after
you choose to share them. The choice is the `transcript_ingest_mode` config
key:
key, optionally narrowed by the `transcript_repos` allowlist:
| Value | What `/sync-gbrain` ingests |
|---|---|
| `recent` | Sessions from the last 90 days |
| `all` | All history (`--full` walks every session file) |
| `new@2026-10-03T17:00:00Z` | Only sessions whose first record is at or after that UTC time. A session that started earlier stays out even when it is appended to later. A session with no start timestamp stays out. |
| `off` | No transcripts; other memory still syncs |
| `recent+repos`, `all+repos`, `new@…+repos` | The same window, only for the repos in `transcript_repos`. gstack adds and removes `+repos` itself. |
Both sources are covered: Claude Code sessions (`~/.claude/projects/`) and
Codex sessions (`~/.codex/sessions/`), from every project on this machine
@@ -43,38 +45,79 @@ Set or change it:
```bash
gstack-config set transcript_ingest_mode recent # or all, or off
gstack-config set transcript_ingest_mode "new@$(date -u +%Y-%m-%dT%H:%M:%SZ)" # only sessions from now on
gstack-config set transcript_repos github.com/acme/app,github.com/acme/api # only these repos
gstack-config unset transcript_repos # every repo again
gstack-config has transcript_ingest_mode && gstack-config get transcript_ingest_mode
bun run lib/transcript-consent.ts --describe # the effective consent in words
```
`gstack-config set` rejects any other value and keeps the stored one.
`gstack-config set` rejects any other value and keeps the stored one. The
cutoff takes only the `Z` (UTC) form, to the second. `transcript_repos`
accepts any git remote spelling (`https://…`, `git@…:…`, with or without
`.git`) and stores its canonical form; an empty list is rejected. Setting
or clearing the allowlist updates the `+repos` marker in the same write.
`gstack-config set transcript_ingest_mode <base>` keeps the marker while an
allowlist exists, and `off` never carries it.
**Several config roots.** When gstack reads more than one state root (for
example `GSTACK_HOME` plus `~/.gstack`), the most restrictive answer wins:
`off` in any root means off, the latest cutoff and any 90-day window both
apply, and only repos in every root's allowlist count. The resolved root
must hold the consent itself.
**Not set.** Until you choose, transcripts are skipped. Each sync prints one
line saying so, even with `--quiet`, and the next interactive `/sync-gbrain`
asks once. Older values from previous gate versions (`A`-`E`, `incremental`)
also count as not chosen and get the question again. A stored `off` prints
`transcripts off (your choice)` without `--quiet` and is never asked again.
and values this version does not recognize also count as not chosen and get
the question again. A stored `off` prints `transcripts off (your choice)`
without `--quiet` and is never asked again.
**One-run override.** A `--sources` list (or `GSTACK_MEMORY_INGEST_SOURCES`)
that names `transcript` ingests transcripts for that run whatever the mode;
the sync still prints the mode notice. `--sources all`, an empty list, or a
list with no valid types does not override.
the sync still prints the mode notice. An override never widens a scoped
consent: a `new@` cutoff and the `transcript_repos` allowlist still apply.
`--sources all`, an empty list, or a list with no valid types does not
override.
```bash
bun run bin/gstack-gbrain-sync.ts --incremental --sources transcript
```
**What each sync tells you.** Without `--quiet` the sync prints the consent
in words (window or cutoff, up to three repos, the config roots it came
from). The ingest summary counts sessions left out by reason: before the
cutoff, no start time, not in the allowlist, and repo policy deny. A sync
under `new@` that ingests nothing says so and says new sessions will appear
on the next sync.
**Interrupted imports.** If a sync was interrupted mid-import and your
transcript choice changed before the next run, the next sync restages memory
from scratch once instead of resuming, and prints a line saying so.
transcript choice changed before the next run (the mode, the cutoff or the
allowlist), the next sync restages memory from scratch once instead of
resuming, and prints a line saying so.
**Staged pages wait.** Transcript pages already staged under
`~/.gstack/transcripts/` (remote-http mode) stay on disk and are not pushed
by `gstack-brain-sync` until the mode is `recent` or `all`. Other artifacts
keep pushing.
**Staged pages.** Transcript pages already staged under
`~/.gstack/transcripts/` (remote-http mode) wait while the mode is not
`recent`, `all` or `new@…`: they stay on disk and are not pushed by
`gstack-brain-sync`. Other artifacts keep pushing. When you narrow a scoped
consent (a later cutoff, a smaller allowlist), the next sync removes the
staged pages that are now out of scope and prints `removed N staged
transcript pages outside the new scope`; widening again re-stages them from
the original sessions. Before every push, `gstack-brain-sync` also checks
its own unpushed commits and rewrites them without any page outside your
consent (`removed N excluded transcript pages from unpublished sync
commits`). The removal covers staged and unpushed pages only.
**Already-ingested transcripts stay.** Switching to `off` stops new ingests;
it does not remove pages already in the brain. Delete them there (see
"Delete a page" below).
**Already-ingested transcripts stay.** Switching to `off` or narrowing the
scope stops new ingests; it does not remove pages already in the brain or
already pushed. Delete them there (see "Delete a page" below).
**Downgrading gstack.** A gstack that predates `new@` and `transcript_repos`
reads those values as unrecognized and ingests no transcripts
automatically. It does honour an explicit transcript override without any
scope, so before downgrading: remove `--sources transcript` and
`GSTACK_MEMORY_INGEST_SOURCES=transcript` from scripts, and let pending sync
publish, or run `gstack-config set transcript_ingest_mode off`.
## What stays local
+22 -9
View File
@@ -3,24 +3,27 @@ config (Step 8), ask whether this machine's coding-agent session
transcripts may go into gbrain. Curated `~/.gstack/` artifacts (learnings,
timeline, plans, designs, retros) sync whatever the answer; this question
covers transcripts only. Transcripts are ingested only after the user picks
`recent` or `all`.
`recent`, `all` or `new` (only sessions that start from now on).
Check whether the user already chose. The gate keys on `has`, because `get`
prints the `off` default for a key that was never set:
```bash
_TIM=$(~/.claude/skills/gstack/bin/gstack-config get transcript_ingest_mode 2>/dev/null || true)
if ~/.claude/skills/gstack/bin/gstack-config has transcript_ingest_mode; then
_T='[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]t[0-9][0-9]:[0-9][0-9]:[0-9][0-9]z'
case "$(printf '%s' "$_TIM" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')" in
recent|all|off) echo "TRANSCRIPT_MODE: $_TIM" ;;
*) echo "TRANSCRIPT_MODE: ask (stored value '$_TIM' is from an older version)" ;;
recent|all|off|recent+repos|all+repos|new@$_T|new@$_T+repos)
echo "TRANSCRIPT_MODE: $_TIM (repos: $(~/.claude/skills/gstack/bin/gstack-config get transcript_repos 2>/dev/null || true))" ;;
*) echo "TRANSCRIPT_MODE: ask (stored value '$_TIM' is not recognized by this version)" ;;
esac
else
echo "TRANSCRIPT_MODE: ask (not set)"
fi
```
- `TRANSCRIPT_MODE: recent`, `all` or `off`: the user already chose. Do not
ask again; continue to Step 8.
- `TRANSCRIPT_MODE:` with a value (`recent`, `all`, `off`, `new@<time>`,
any of them with `+repos`): the user already chose. Do not ask again;
continue to Step 8.
- `TRANSCRIPT_MODE: ask`: ask once, below. With `SESSION_KIND: spawned` or
`headless`, do not ask and do not store a value. Consent is never
auto-chosen; leave the key unset (transcripts stay skipped) and report
@@ -38,7 +41,7 @@ Read `Total files in window`, `Total bytes` and the estimate from each.
> "Ingest coding-agent sessions as they appear?"
Yes stores `recent`; No stores `off`.
Yes means `recent` (then ask the scope question below); No stores `off`.
**Otherwise**, AskUserQuestion with the counts. Name both session sources
and the destination brain from Step 4: the user's Supabase brain (Paths 1,
@@ -68,14 +71,24 @@ and the destination brain from Step 4: the user's Supabase brain (Paths 1,
Options:
- A) Yes, last 90 days (`recent`)
- B) Yes, all history (`all`)
- C) Yes, only new sessions starting now (`new`)
- E) No, never ingest transcripts (`off`)
Store the mode value, never the letter:
On any yes, ask a second, separate question: "Which repos' sessions?"
A) every project repo your repo policy allows, or B) only this repo (offer
B only when `git remote get-url origin` succeeds here). Store nothing until
both answers are known; if the user cancels in between, store nothing.
Then store the scope, then the mode value (never the letter; `new` stores
`new@` plus the current UTC time):
```bash
~/.claude/skills/gstack/bin/gstack-config set transcript_ingest_mode <recent|all|off>
~/.claude/skills/gstack/bin/gstack-config set transcript_repos "$(git remote get-url origin)" # only this repo
~/.claude/skills/gstack/bin/gstack-config unset transcript_repos # every repo
~/.claude/skills/gstack/bin/gstack-config set transcript_ingest_mode <recent|all|off|new@$(date -u +%Y-%m-%dT%H:%M:%SZ)>
bun run ~/.claude/skills/gstack/lib/transcript-consent.ts --describe
```
Tell the user the `--describe` line: what will be ingested, in words.
For `recent` or `all`, run the first sync now:
For `recent`, `all` or `new@…`, run the first sync now:
```bash
bun run ~/.claude/skills/gstack/bin/gstack-gbrain-sync.ts --full --no-brain-sync
```
+22 -7
View File
@@ -190,7 +190,9 @@ gets the actionable remediation message.
## Step 1.6: Transcript consent
Claude Code and Codex session transcripts are ingested only when
`transcript_ingest_mode` is `recent` (last 90 days) or `all` (all history).
`transcript_ingest_mode` is `recent` (last 90 days), `all` (all history) or
`new@<UTC time>` (only sessions that start after it), optionally narrowed to
the repos in `transcript_repos` (the value then ends in `+repos`).
Skip this step for `--code-only`, `--no-memory`, `--dry-run`,
`--refresh-cache` and `--audit`. Otherwise check whether the user chose;
`has` tells an absent key from the `off` default that `get` prints:
@@ -198,16 +200,19 @@ Skip this step for `--code-only`, `--no-memory`, `--dry-run`,
```bash
_TIM=$(~/.claude/skills/gstack/bin/gstack-config get transcript_ingest_mode 2>/dev/null || true)
if ~/.claude/skills/gstack/bin/gstack-config has transcript_ingest_mode; then
_T='[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]t[0-9][0-9]:[0-9][0-9]:[0-9][0-9]z'
case "$(printf '%s' "$_TIM" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')" in
recent|all|off) echo "TRANSCRIPT_MODE: $_TIM" ;;
*) echo "TRANSCRIPT_MODE: ask (stored value '$_TIM' is from an older version)" ;;
recent|all|off|recent+repos|all+repos|new@$_T|new@$_T+repos)
echo "TRANSCRIPT_MODE: $_TIM (repos: $(~/.claude/skills/gstack/bin/gstack-config get transcript_repos 2>/dev/null || true))" ;;
*) echo "TRANSCRIPT_MODE: ask (stored value '$_TIM' is not recognized by this version)" ;;
esac
else
echo "TRANSCRIPT_MODE: ask (not set)"
fi
```
`recent`, `all` or `off`: continue to Step 2 without asking. On `ask`, with
A stored value (`recent`, `all`, `off`, `new@<time>`, any with `+repos`):
continue to Step 2 without asking. On `ask`, with
`SESSION_KIND: spawned` or `headless`, do not ask and store nothing: the sync
skips transcripts and prints how to choose. In an interactive session, ask
once. Count first (`--sources transcript` counts sessions that are not
@@ -219,7 +224,7 @@ bun run ~/.claude/skills/gstack/bin/gstack-memory-ingest.ts --probe --sources tr
```
If both report `Total files in window: 0`, ask yes/no: "Ingest coding-agent
sessions as they appear?" Yes stores `recent`, No stores `off`. Otherwise
sessions as they appear?" Yes means `recent`, No stores `off`. Otherwise
AskUserQuestion: name both sources (Claude Code and Codex sessions from every
project on this machine that repo policy allows), the counts from the two
probes, and the destination: the local brain (`gbrain_engine` from Step 1:
@@ -229,14 +234,24 @@ and skills stay usable meanwhile. Options:
- A) Yes, last 90 days (`recent`)
- B) Yes, all history (`all`)
- C) Yes, only new sessions starting now (`new`)
- E) No, never ingest transcripts (`off`)
Store the value, never the letter, then continue to Step 2:
On any yes (the yes/no form included), ask a second, separate question:
"Which repos' sessions?" A) every project repo your repo policy allows, or
B) only this repo (offer B only when `git remote get-url origin` succeeds).
Store nothing until both answers are known; a cancel in between stores
nothing. Then store the scope, then the value (never the letter; `new`
stores `new@` plus the current UTC time), and continue to Step 2:
```bash
~/.claude/skills/gstack/bin/gstack-config set transcript_ingest_mode <recent|all|off>
~/.claude/skills/gstack/bin/gstack-config set transcript_repos "$(git remote get-url origin)" # only this repo
~/.claude/skills/gstack/bin/gstack-config unset transcript_repos # every repo
~/.claude/skills/gstack/bin/gstack-config set transcript_ingest_mode <recent|all|off|new@$(date -u +%Y-%m-%dT%H:%M:%SZ)>
```
The sync prints what it will ingest, in words; repeat that line to the user.
Other memory types sync whatever the answer. Details:
`setup-gbrain/memory.md#transcripts`.
+74
View File
@@ -0,0 +1,74 @@
/**
* Both transcript consent gates (setup-gbrain's transcript-gate section and
* /sync-gbrain Step 1.6) run their rendered check block against every stored
* value form: each valid form is recognized so a stored choice is never asked
* again, and anything else is asked as "not recognized by this version".
*/
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
const ROOT = join(import.meta.dir, '..');
const CONFIG_BIN = join(ROOT, 'bin', 'gstack-config');
const GATES = {
'setup-gbrain transcript gate': join(ROOT, 'setup-gbrain', 'sections', 'transcript-gate.md'),
'sync-gbrain Step 1.6': join(ROOT, 'sync-gbrain', 'SKILL.md'),
};
const TS = '2026-10-03T17:00:00Z';
const RECOGNIZED = ['recent', 'all', 'off', 'recent+repos', 'all+repos', `new@${TS}`, `new@${TS}+repos`, ' Recent ', `NEW@${TS.toLowerCase()}+REPOS`];
const UNRECOGNIZED = ['A', 'incremental', 'new', 'off+repos', `recent@${TS}`, 'new@2026-10-03', `new@${TS}x`, 'yes'];
function gateBlock(file: string): string {
const text = readFileSync(file, 'utf-8');
const block = text.split('```bash\n').map((b) => b.split('\n```')[0]).find((b) => b.includes('has transcript_ingest_mode'));
if (!block) throw new Error(`no transcript gate check block in ${file}`);
return block.replaceAll('~/.claude/skills/gstack/bin/gstack-config', CONFIG_BIN);
}
function runGate(block: string, yaml: string | null): string {
const root = mkdtempSync(join(tmpdir(), 'gstack-gate-'));
try {
if (yaml !== null) writeFileSync(join(root, 'config.yaml'), yaml);
const r = spawnSync('bash', ['-c', block], { encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: root } });
expect(r.status).toBe(0);
return r.stdout.trim();
} finally {
rmSync(root, { recursive: true, force: true });
}
}
describe('transcript consent gates recognize every value form', () => {
for (const [name, file] of Object.entries(GATES)) {
const block = gateBlock(file);
test(`${name}: every stored grammar form is a choice, never re-asked`, () => {
for (const value of RECOGNIZED) {
const out = runGate(block, `transcript_ingest_mode: ${value}\ntranscript_repos: github.com/acme/app\n`);
expect({ value, out }).toEqual({ value, out: `TRANSCRIPT_MODE: ${value.trim()} (repos: github.com/acme/app)` });
}
});
test(`${name}: anything else is asked as not recognized by this version; absent is asked as not set`, () => {
for (const value of UNRECOGNIZED) {
const out = runGate(block, `transcript_ingest_mode: ${value}\n`);
expect({ value, out }).toEqual({ value, out: `TRANSCRIPT_MODE: ask (stored value '${value}' is not recognized by this version)` });
}
expect(runGate(block, null)).toBe('TRANSCRIPT_MODE: ask (not set)');
});
test(`${name}: two questions, nothing stored until both are answered, scope before mode`, () => {
const text = readFileSync(file, 'utf-8');
expect(text).toContain('`new`');
expect(text).toMatch(/second, separate question/i);
expect(text).toMatch(/only this repo/i);
expect(text).toMatch(/store nothing until\s+both answers are known/i);
const scopeAt = text.indexOf('gstack-config set transcript_repos');
const modeAt = text.indexOf('gstack-config set transcript_ingest_mode', scopeAt);
expect(scopeAt).toBeGreaterThan(0);
expect(modeAt).toBeGreaterThan(scopeAt);
expect(text).toContain('gstack-config unset transcript_repos');
});
}
});