mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 00:44:07 +01:00
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:
1 parent
1576c23403
commit
b730e63a9c
4 files changed
+176
-31
No files matched your search
+58
-15
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user