Merge remote-tracking branch 'origin/garrytan/opus55-wip-B' into garrytan/opus-5-5-prompt-audit

This commit is contained in:
garrytan committed 2026-10-03 00:46:40 +00:00
commit 522e300d4e
21 files changed
+1352 -259

No files matched your search

+22 -3
View File
@@ -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
+40 -4
View File
@@ -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 "<default>"` 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 <key> <value>}"
VALUE="${3:?Usage: gstack-config set <key> <value>}"
if [ "$#" -lt 3 ]; then
echo "Usage: gstack-config set <key> <value>" >&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 @<endpoint-id> 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)
+105 -10
View File
@@ -45,6 +45,7 @@ import { buildGbrainEnv, spawnGbrain, spawnGbrainAsync, execGbrainJson, NEEDS_SH
import { repoPolicyTier as sharedRepoPolicyTier } from "../lib/gbrain-repo-policy-client";
import { checkOwnedStagingDir } from "../lib/staging-guard";
import { resolveStateRoot } from "../lib/state-root";
import { normalizeTranscriptConsent, type TranscriptConsent } from "./gstack-memory-ingest";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -101,6 +102,16 @@ interface StageResult {
detail?: CodeStageDetail;
/** Memory stage: the type selection its ingest staged (resume must match it). */
memory_sources?: string[];
/** Memory stage: transcript consent read at sync start (resume must match it). */
transcript_consent?: TranscriptConsentRecord;
}
interface TranscriptConsentRecord {
/** Normalized transcript_ingest_mode: recent | all | off | not-set | legacy | unrecognized. */
mode: string;
window: "recent" | "all" | null;
/** Why transcripts were not ingested; absent when they were. */
skip_reason?: string;
}
// ── Constants ──────────────────────────────────────────────────────────────
@@ -253,6 +264,59 @@ export function selectMemorySources(
};
}
export interface ConsentedSelection {
/** Value for the ingest's --sources flag; null = let the ingest walk its default. */
sources: string[] | null;
/** Types this run stages (recorded for resume). */
selected: string[];
/** Transcripts are ingested only because an explicit list names them. */
override: boolean;
why: string;
}
/**
* Apply transcript consent to a memory source selection. Without consent
* (anything but `recent` or `all`), transcript is removed and the remaining
* types are passed as an explicit list, so a config change made while the
* sync runs cannot add transcripts to this run. Only an explicit --sources or
* GSTACK_MEMORY_INGEST_SOURCES list that names transcript overrides that;
* `all`, empty and invalid-only values resolve to null and do not.
*/
export function applyTranscriptConsent(selection: MemorySourceSelection, consent: TranscriptConsent): ConsentedSelection {
const explicit = selection.why === "--sources" || selection.why === "GSTACK_MEMORY_INGEST_SOURCES";
const override = !consent.affirmative && explicit && !!selection.sources?.includes("transcript");
if (consent.affirmative || override) {
return { sources: selection.sources, selected: selection.sources ?? [...MEMORY_INGEST_TYPES], override, why: selection.why };
}
const selected = (selection.sources ?? [...MEMORY_INGEST_TYPES]).filter((t) => t !== "transcript");
return { sources: selected, selected, override: false, why: `${selection.why}; no transcript consent (${consent.reason})` };
}
export const TRANSCRIPT_CHOICE_HINT =
"To choose: run /sync-gbrain, or gstack-config set transcript_ingest_mode recent|all|off. Details: setup-gbrain/memory.md#transcripts";
/**
* The stderr notice for a run that does not ingest transcripts by consent.
* Returns null when nothing should print: consent given, or a stored `off`
* under --quiet.
*/
export function transcriptConsentNotice(consent: TranscriptConsent, quiet: boolean, override: string | null = null): string | null {
if (consent.affirmative) return null;
const value = consent.value ?? "not set";
if (override) {
return `gbrain-sync: transcripts ingested because ${override} names transcript (transcript_ingest_mode=${value}). ${TRANSCRIPT_CHOICE_HINT}`;
}
if (consent.reason === "off") return quiet ? null : "gbrain-sync: transcripts off (your choice)";
return `gbrain-sync: transcripts skipped (transcript_ingest_mode=${value}). Other memory still syncs. ${TRANSCRIPT_CHOICE_HINT}`;
}
/** The consent record a memory stage stores; resume requires mode and window to match. */
export function transcriptConsentRecord(consent: TranscriptConsent, override: boolean): TranscriptConsentRecord {
const record: TranscriptConsentRecord = { mode: consent.reason, window: consent.window };
if (!consent.affirmative && !override) record.skip_reason = consent.reason;
return record;
}
/**
* Parse a stage-timeout env value with bounds validation. Returns the bounded
* value or the default with a stderr warning if the env was malformed or
@@ -382,7 +446,13 @@ Options:
${MEMORY_INGEST_TYPES.join(",")}.
Env: GSTACK_MEMORY_INGEST_SOURCES. Default: every type
except ${FEDERATED_CURATED_TYPES.join(",")} when a federated
gstack source already indexes them (#2922).
gstack source already indexes them, and except
transcript unless transcript_ingest_mode is recent
(last 90 days) or all (all history; --full passes
--all-history). Without that consent each run prints a
notice, even with --quiet. A list naming transcript
overrides the mode for that run. See
setup-gbrain/memory.md#transcripts.
--help This text.
Stages run in order: code → memory ingest → curated git push, then (lock-free)
@@ -1400,11 +1470,18 @@ export function ensureGbrainSourceGitignored(root: string): void {
}
}
function runMemoryIngest(args: CliArgs, previous: SyncState): StageResult {
function printTranscriptNotice(args: CliArgs, consent: TranscriptConsent, selection: ConsentedSelection, why: string): void {
const notice = transcriptConsentNotice(consent, args.quiet, selection.override ? why : null);
if (notice) console.error(notice);
}
function runMemoryIngest(args: CliArgs, previous: SyncState, consent: TranscriptConsent): StageResult {
const t0 = Date.now();
if (args.mode === "dry-run") {
const preview = selectMemorySources(args.memorySources, process.env, false);
const raw = selectMemorySources(args.memorySources, process.env, false);
const preview = applyTranscriptConsent(raw, consent);
printTranscriptNotice(args, consent, preview, raw.why);
const flag = preview.sources ? ` --sources ${preview.sources.join(",")}` : "";
return { name: "memory", ran: false, ok: true, duration_ms: 0, summary: `would: gstack-memory-ingest --probe${flag} (${preview.why})` };
}
@@ -1427,20 +1504,33 @@ function runMemoryIngest(args: CliArgs, previous: SyncState): StageResult {
// staging dir. If the staging dir is gone (disk pressure cleanup, OS
// reboot, user manual cleanup), warn and fall through to a fresh restage.
const childEnv = buildGbrainEnv({ announce: false });
const selection = selectMemorySources(args.memorySources, childEnv, true);
const selected = selection.sources ?? [...MEMORY_INGEST_TYPES];
const raw = selectMemorySources(args.memorySources, childEnv, true);
const selection = applyTranscriptConsent(raw, consent);
const selected = selection.selected;
const consentRecord = transcriptConsentRecord(consent, selection.override);
printTranscriptNotice(args, consent, selection, raw.why);
let resume = decideResume();
if (resume.kind === "resume") {
// The staging dir holds whatever the checkpointed run selected; resuming
// it under a different selection would import types the user excluded.
// Runs recorded before #2922 walked every type.
const staged = previous.last_stages?.find((st) => st.name === "memory")?.memory_sources ?? [...MEMORY_INGEST_TYPES];
// The staging dir holds whatever the checkpointed run selected under its
// transcript consent; resuming it under a different selection or consent
// would import pages the user excluded. Runs recorded before #2922 walked
// every type; runs recorded before the consent record walked the 90-day
// transcript window.
const prevStage = previous.last_stages?.find((st) => st.name === "memory");
const staged = prevStage?.memory_sources ?? [...MEMORY_INGEST_TYPES];
const prevConsent = prevStage?.transcript_consent ?? { mode: "recent", window: "recent" };
if ([...staged].sort().join(",") !== [...selected].sort().join(",")) {
console.error(
`[sync:memory] memory source selection changed since the checkpointed run (${staged.join(",")} → ${selected.join(",")}); restaging from scratch.`,
);
resume = { kind: "no-checkpoint" };
}
if (prevConsent.mode !== consentRecord.mode || prevConsent.window !== consentRecord.window) {
console.error(
`gbrain-sync: transcript consent changed since the interrupted import (${prevConsent.mode} → ${consentRecord.mode}); restaging memory from scratch once.`,
);
resume = { kind: "no-checkpoint" };
}
}
if (resume.kind === "resume") {
console.error(
@@ -1464,6 +1554,7 @@ function runMemoryIngest(args: CliArgs, previous: SyncState): StageResult {
const ingestArgs = ["run", ingestPath];
if (args.mode === "full") ingestArgs.push("--bulk");
else ingestArgs.push("--incremental");
if (args.mode === "full" && consent.window === "all") ingestArgs.push("--all-history");
if (args.quiet) ingestArgs.push("--quiet");
if (selection.sources) ingestArgs.push("--sources", selection.sources.join(","));
if (!args.quiet) console.error(`[sync:memory] sources: ${selected.join(",")} (${selection.why})`);
@@ -1507,6 +1598,7 @@ function runMemoryIngest(args: CliArgs, previous: SyncState): StageResult {
? summary
: `${summary}${result.status === null ? " (killed by signal / timeout)" : ` (exit ${result.status})`}`,
memory_sources: selected,
transcript_consent: consentRecord,
};
}
@@ -1869,6 +1961,9 @@ export function formatStage(s: StageResult): string {
async function main(): Promise<void> {
const args = parseArgs();
// Read once at sync start: a config change made while the sync runs never
// adds transcripts to this run.
const consent = args.noMemory ? null : normalizeTranscriptConsent();
if (!args.quiet) {
const engine = detectEngineTier();
@@ -1904,7 +1999,7 @@ async function main(): Promise<void> {
stages.push(await withErrorContext("sync:code", () => runCodeImport(args), "gstack-gbrain-sync"));
}
if (!args.noMemory) {
stages.push(await withErrorContext("sync:memory", () => runMemoryIngest(args, state), "gstack-gbrain-sync"));
stages.push(await withErrorContext("sync:memory", () => runMemoryIngest(args, state, consent!), "gstack-gbrain-sync"));
}
if (!args.noBrainSync) {
stages.push(await withErrorContext("sync:brain-sync", () => runBrainSyncPush(args), "gstack-gbrain-sync"));
+54 -1
View File
@@ -72,7 +72,7 @@ import { execGbrainText, spawnGbrainAsync } from "../lib/gbrain-exec";
import { writeReceipt } from "../lib/egress-receipt";
import { checkOwnedStagingDir, STAGING_MARKER } from "../lib/staging-guard";
import { hasRepoPolicyStore, repoPolicyTierBatch } from "../lib/gbrain-repo-policy-client";
import { resolveStateRoot } from "../lib/state-root";
import { resolveStateRoot, readConfigKeyWithRoot, type StateRootEnv } from "../lib/state-root";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -202,6 +202,45 @@ const ALL_TYPES: MemoryType[] = [
"builder-profile-entry",
];
// ── Transcript consent ─────────────────────────────────────────────────────
export interface TranscriptConsent {
/** True only for a stored `recent` or `all`. */
affirmative: boolean;
/** Transcript walk window when affirmative: `recent` = last 90 days, `all` = all history. */
window: "recent" | "all" | null;
/**
* Normalized mode: the window when affirmative; otherwise why transcripts
* are skipped. `legacy` covers the old gate letters (A-E) and `incremental`,
* which predate the consent rule and need a new choice.
*/
reason: "recent" | "all" | "off" | "not-set" | "legacy" | "unrecognized";
/** The stored value as read (null when the key is absent). */
value: string | null;
}
let cachedConsent: TranscriptConsent | null = null;
/**
* Read `transcript_ingest_mode` once per process (same parse as
* `gstack-config has` + `get`) and decide whether transcripts may be ingested.
* Only `recent` and `all` are consent. An absent key, `off`, a legacy value
* and anything unrecognized are not. Pass `env` to bypass the per-process cache.
*/
export function normalizeTranscriptConsent(env?: StateRootEnv): TranscriptConsent {
if (!env && cachedConsent) return cachedConsent;
const value = readConfigKeyWithRoot("transcript_ingest_mode", env ?? process.env).value;
const v = (value ?? "").trim().toLowerCase();
let consent: TranscriptConsent;
if (value === null) consent = { affirmative: false, window: null, reason: "not-set", value };
else if (v === "recent" || v === "all") consent = { affirmative: true, window: v, reason: v, value };
else if (v === "off") consent = { affirmative: false, window: null, reason: "off", value };
else if (/^[a-e]$/.test(v) || v === "incremental") consent = { affirmative: false, window: null, reason: "legacy", value };
else consent = { affirmative: false, window: null, reason: "unrecognized", value };
if (!env) cachedConsent = consent;
return consent;
}
// ── CLI ────────────────────────────────────────────────────────────────────
function printUsage(): void {
@@ -218,6 +257,9 @@ Options:
--include-unattributed Ingest sessions with no resolvable git remote.
--all-history Walk transcripts older than 90 days too.
--sources <list> Comma-separated subset: ${ALL_TYPES.join(",")}
Default: every type, minus transcript unless
transcript_ingest_mode is recent (90 days) or all
(all history). A list naming transcript overrides it.
--limit <N> Stop after N pages written (smoke testing).
--no-write Skip gbrain put calls (still updates state file).
Used by tests + dry runs without actual ingest.
@@ -237,6 +279,7 @@ function parseArgs(): CliArgs {
let allHistory = false;
let limit: number | null = null;
let sources: Set<MemoryType> = new Set(ALL_TYPES);
let sourcesExplicit = false;
let noWrite = process.env.GSTACK_MEMORY_INGEST_NO_WRITE === "1";
let scanSecrets = process.env.GSTACK_MEMORY_INGEST_SCAN_SECRETS === "1";
@@ -266,6 +309,7 @@ function parseArgs(): CliArgs {
console.error(`--sources must include at least one of: ${ALL_TYPES.join(",")}`);
process.exit(1);
}
sourcesExplicit = true;
break;
}
case "--help":
@@ -279,6 +323,15 @@ function parseArgs(): CliArgs {
}
}
const consent = normalizeTranscriptConsent();
if (!sourcesExplicit && !consent.affirmative) {
sources.delete("transcript");
if (!quiet) {
console.error(`gstack-memory-ingest: transcripts skipped (transcript_ingest_mode=${consent.value ?? "not set"}); set it to recent or all, or pass --sources transcript.`);
}
}
if (consent.window === "all") allHistory = true;
return { mode, quiet, benchmark, includeUnattributed, allHistory, sources, limit, noWrite, scanSecrets };
}
+56 -19
View File
@@ -30,36 +30,73 @@ anti-bot stealth, and custom branding. You see every action in real time.
{{BROWSE_SETUP}}
## Step 0: Pre-flight cleanup
## Step 0: Check for a running browse daemon
Before connecting, kill any stale browse servers and clean up lock files that
may have persisted from a crash. This prevents "already connected" false
positives and Chromium profile lock conflicts.
A running browse daemon may hold open tabs, cookies and logged-in sessions,
and replacing it loses them. Probe without starting one
(`BROWSE_NO_AUTOSTART=1` keeps `status` from booting a daemon):
```bash
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
# Kill any existing browse server
if [ -f "$(git rev-parse --show-toplevel 2>/dev/null)/.gstack/browse.json" ]; then
_OLD_PID=$(cat "$(git rev-parse --show-toplevel)/.gstack/browse.json" 2>/dev/null | grep -o '"pid":[[:space:]]*[0-9]*' | grep -o '[0-9]*')
[ -n "$_OLD_PID" ] && kill "$_OLD_PID" 2>/dev/null || true
sleep 1
[ -n "$_OLD_PID" ] && kill -9 "$_OLD_PID" 2>/dev/null || true
rm -f "$(git rev-parse --show-toplevel)/.gstack/browse.json"
fi
# Clean Chromium profile locks (can persist after crashes)
_PROFILE_DIR="$GSTACK_STATE_ROOT/chromium-profile"
for _LF in SingletonLock SingletonSocket SingletonCookie; do
rm -f "$_PROFILE_DIR/$_LF" 2>/dev/null || true
done
echo "Pre-flight cleanup done"
_STATUS=$(BROWSE_NO_AUTOSTART=1 $B status 2>&1); _STATUS_RC=$?
printf '%s\n' "$_STATUS" | head -5
if [ "$_STATUS_RC" -ne 0 ]; then echo "DAEMON: none"
elif printf '%s' "$_STATUS" | grep -q 'Mode: headed'; then echo "DAEMON: headed"
else echo "DAEMON: live"; fi
```
- **`DAEMON: none`**: no daemon answered. Clear Chromium profile locks left
by a crash, then run Step 1's plain `$B connect`. The CLI reaps orphaned
Chromium and stale state itself, and it still refuses to replace a daemon
that is alive but too busy to answer; if it refuses, show its output and
stop.
```bash
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
_PROFILE_DIR="$GSTACK_STATE_ROOT/chromium-profile"
for _LF in SingletonLock SingletonSocket SingletonCookie; do
rm -f "$_PROFILE_DIR/$_LF" 2>/dev/null || true
done
```
- **`DAEMON: headed`**: GStack Browser is already open. Step 1's plain
`$B connect` reports that; continue to Step 2.
- **`DAEMON: live`**: a headless daemon is running. With `SESSION_KIND:
spawned` or `headless`, do not ask and do not replace it. Print this and
stop:
```bash
printf 'Live browse daemon left running. Run %s stop, then re-run /open-gstack-browser to replace it.\n' "$B"
```
Otherwise AskUserQuestion. Replacing the daemon cannot be undone:
> "A browse daemon is already running (tabs and logins may be active).
> Opening GStack Browser replaces it, and everything in that daemon is
> lost."
>
> Recommendation: B unless you are done with the running session.
Options:
- A) Replace it (runs `$B connect --force-restart`; its tabs, cookies and logins are lost)
- B) Keep it running and stop here
Only an explicit A runs Step 1 with `--force-restart`. On B, or a reply
that is not clearly A, print the "Live browse daemon left running" line
above and stop.
## Step 1: Connect
```bash
$B connect
```
After an explicit A in Step 0 only:
```bash
$B connect --force-restart
```
This launches GStack Browser (rebranded Chromium) in headed mode with:
- A visible window you can watch (not your regular Chrome — it stays untouched)
- The gstack sidebar extension auto-loaded via `launchPersistentContext`
+58 -49
View File
@@ -29,7 +29,7 @@ allowed-tools:
# /setup-gbrain — Coding-Agent Onboarding for gbrain
You are setting up gbrain (https://github.com/garrytan/gbrain), a persistent
knowledge base, on the user's local Mac so that this coding agent (typically
knowledge base, on the user's machine so that this coding agent (typically
Claude Code) can call it as both a CLI and an MCP tool.
**Scope honesty:** This skill's MCP registration step (5a) uses
@@ -37,12 +37,12 @@ Claude Code) can call it as both a CLI and an MCP tool.
(Cursor, Codex CLI, etc.) will still get the gbrain CLI on PATH — they can
register `gbrain serve` in their own MCP config manually after setup.
**Audience:** local-Mac users. openclaw/hermes agents typically run in cloud
**Audience:** local machines (macOS, Linux, Windows). openclaw/hermes agents typically run in cloud
docker containers with their own gbrain; "sharing" a brain between them and
local Claude Code is only possible through shared Postgres (Supabase).
## User-invocable
When the user types `/setup-gbrain`, run this skill. Three shortcut modes:
When the user types `/setup-gbrain`, run this skill. Invocation modes:
- `/setup-gbrain` — full flow (default)
- `/setup-gbrain --repo` — only flip the per-remote policy for the current repo
@@ -69,10 +69,10 @@ implemented as a dispatcher binary.
Capture the JSON output. It contains: `gbrain_on_path`, `gbrain_version`,
`gbrain_config_exists`, `gbrain_engine`, `gbrain_doctor_ok`, `gbrain_mcp_mode`,
`gstack_brain_sync_mode`, `gstack_brain_git`, `gstack_artifacts_remote`, and
the v1.34.0.0+ `gbrain_local_status` field (one of: `ok`, `no-cli`,
the `gbrain_local_status` field (one of: `ok`, `no-cli`,
`missing-config`, `broken-config`, `broken-db`, `engine-locked`, `timeout`,
`thin-client`). Treat `timeout` like `ok` (slow-but-healthy engine, #1964) — it
never triggers Step 1.5 remediation. Treat `thin-client` like `ok` too (#2051):
`thin-client`). Treat `timeout` like `ok` (slow-but-healthy engine) — it
never triggers Step 1.5 remediation. Treat `thin-client` like `ok` too:
the machine is a thin client of a remote-HTTP MCP brain, no local engine by
design — brain-aware blocks render, and the detect JSON carries
`gbrain_thin_client: {probed: false}` (config verified; remote reachability
@@ -89,7 +89,7 @@ invocation flags here and skip to the matching step.
---
## Step 1.5: Broken-local-engine remediation (plan D4)
## Step 1.5: Broken-local-engine remediation
Read `gbrain_local_status` from the Step 1 detect output. **If it's `broken-db`
or `broken-config` AND no shortcut flag was passed**, the user has a
@@ -173,7 +173,7 @@ Options (present based on detected state):
- **2b — Supabase, create manually.** Walk through supabase.com signup
yourself; paste the URL back when ready.
- **3 — PGLite local.** Zero accounts, ~30 seconds. Isolated brain on this
Mac only. Best for try-first.
machine only. Best for try-first.
- **4 — Remote gbrain MCP.** Someone else (or another machine of yours) is
already running `gbrain serve` with HTTP transport. You paste the MCP URL
+ a bearer token; this skill registers it as your MCP. No local brain DB,
@@ -181,7 +181,7 @@ Options (present based on detected state):
machines or run by a teammate.
- **Switch** (only if Step 1 detected an existing engine): "You already have
a `<engine>` brain. Migrate it to the other engine?" → runs
`gbrain migrate --to <other>` wrapped in `timeout 180s` (D9).
`gbrain migrate --to <other>` wrapped in `timeout 180s`.
Do NOT silently pick; fire the AskUserQuestion.
@@ -199,9 +199,9 @@ For Paths 1, 2a, 2b, 3, switch — only if `gbrain_on_path=false`:
~/.claude/skills/gstack/bin/gstack-gbrain-install
```
The installer runs D5 detect-first (probes `~/git/gbrain`, `~/gbrain` first),
then D19 PATH-shadow validation (post-link `gbrain --version` must match
install-dir `package.json`). On D19 failure the installer exits 3 with a
The installer reuses an existing checkout first (probes `~/git/gbrain`, `~/gbrain`),
then validates PATH shadowing (post-link `gbrain --version` must match
install-dir `package.json`). On a PATH-shadow failure the installer exits 3 with a
clear remediation menu; surface the full output to the user and STOP. Do not
continue the skill — the environment is broken until the user fixes PATH.
@@ -236,7 +236,7 @@ doctor output and STOP.
---
## Step 5a: Register gbrain as Claude Code MCP (D18)
## Step 5a: Register gbrain as Claude Code MCP
Only if `which claude` resolves. Ask: "Give Claude Code a typed tool surface
for gbrain? (recommended yes)"
@@ -294,7 +294,7 @@ session start, not mid-session."
---
## Step 6: Per-remote policy (D3 triad, gated repo-import)
## Step 6: Per-remote policy (gated repo-import)
If we're in a git repo with an `origin` remote, check the policy:
@@ -329,11 +329,7 @@ For `/setup-gbrain --repo` invocations, execute ONLY Step 6 and exit.
## Step 7: Offer artifacts sync + wire it into gbrain
Renamed from "session memory sync" in v1.27.0.0 — the on-disk concept is
artifacts (CEO plans, designs, /investigate reports, retros) rather than
"session memory," which was a confusing name for what was always a
human-readable artifact bucket. Behavioral transcript ingest is its own
step (7.5) with its own option set.
Behavioral transcript ingest is a separate step (7.5).
Separate AskUserQuestion: "Also sync your gstack artifacts (CEO plans,
designs, reports, retros) to a private git repo that gbrain can index
@@ -358,8 +354,8 @@ URL_FORM=${URL_FORM_SUPPORTED:-false}
```
`gstack-artifacts-init` always prints a "Send this to your brain admin" block
at the end with the exact `gbrain sources add` command. Per codex Finding #3:
the skill never auto-executes server-side gbrain commands; even if the user
at the end with the exact `gbrain sources add` command. The skill
never auto-executes server-side gbrain commands; even if the user
IS the brain admin, copy-pasting the printed command is the consistent UX.
### Path 4 (Remote MCP) — done after artifacts-init
@@ -373,7 +369,7 @@ brain admin runs the printed command on the brain host instead. Skip to Step 7.5
Then wire the artifacts repo into gbrain so its content is searchable from
any gbrain client. The helper creates a `git worktree` of `~/.gstack/`,
registers it as a federated source via `gbrain sources add --path
--federated`, and runs an initial `gbrain sync`. Local-Mac only.
--federated`, and runs an initial `gbrain sync`. Local-stdio paths only.
Capture the database URL out of `~/.gbrain/config.json` first and pass it
explicitly so the wireup is robust against any other process rewriting
@@ -401,14 +397,15 @@ the prereq is fixed.
---
## Step 7.5: Transcript & memory ingest gate
## Step 7.5: Transcript ingest consent
**SKIP entirely on Path 4 (Remote MCP).** Transcript ingest shells out to
the local `gbrain` CLI which Path 4 doesn't install. Remote-mode users
rely on the brain server's own ingest cadence — if your brain admin wants
this machine's transcripts indexed, they pull from your `gstack-artifacts-$USER`
repo (set up in Step 7) on whatever schedule they prefer. Set
`gstack-config set transcript_ingest_mode off` and continue to Step 8.
repo (set up in Step 7) on whatever schedule they prefer. Do not store a
transcript mode for the user here; transcripts stay skipped until they choose
(`/sync-gbrain` asks). Continue to Step 8.
For Paths 1, 2a, 2b, 3, run the ingest gate:
@@ -419,8 +416,9 @@ For Paths 1, 2a, 2b, 3, run the ingest gate:
## Step 8: Persist `## GBrain Configuration` in CLAUDE.md
CLAUDE.md is the audit trail: after a successful setup, persist the
configuration block. The exact block formats (remote-http vs local-stdio) and
the post-Step-9 Search Guidance write live in the claude-md-persist section.
configuration block. The exact block formats (remote-http vs local-stdio) live
in the claude-md-persist section. The search-guidance block is written only by
`/sync-gbrain` (Step 10 offers it).
{{SECTION:claude-md-persist}}
@@ -467,7 +465,7 @@ and STOP with a NEEDS_CONTEXT escalation.
---
## Step 9.5: Brain trust policy (v1.48 brain-aware planning, D4 / Phase 1.5)
## Step 9.5: Brain trust policy
The brain trust policy controls whether gstack auto-pushes `~/.gstack/`
artifacts and writes calibration takes back to this brain. It's per-
@@ -522,7 +520,7 @@ After answer, persist:
```
If `personal` was selected AND `artifacts_sync_mode` is still `off`, also
default it to `full` (D4 auto-push convention):
default it to `full` (personal brains auto-push):
```bash
_CURRENT_SYNC=$(~/.claude/skills/gstack/bin/gstack-config get artifacts_sync_mode 2>/dev/null || echo off)
@@ -539,13 +537,13 @@ or first-time-after-upgrade users.
## Step 10: GREEN/YELLOW/RED verdict block (idempotent doctor output)
After Steps 1-9 complete, summarize. Re-running `/setup-gbrain` on a
configured Mac is a first-class doctor path: every step detects existing
configured machine is a first-class doctor path: every step detects existing
state, repairs only what's missing, and reports here.
```bash
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
~/.claude/skills/gstack/bin/gstack-gbrain-detect 2>/dev/null || true
~/.claude/skills/gstack/bin/gstack-config get transcript_ingest_mode 2>/dev/null || echo "off"
~/.claude/skills/gstack/bin/gstack-config has transcript_ingest_mode && echo "transcript_ingest_mode: $(~/.claude/skills/gstack/bin/gstack-config get transcript_ingest_mode)" || echo "transcript_ingest_mode: not set"
~/.claude/skills/gstack/bin/gstack-config get artifacts_sync_mode 2>/dev/null || echo "off"
[ -f "$GSTACK_STATE_ROOT"/.gbrain-sync-state.json ] && cat "$GSTACK_STATE_ROOT"/.gbrain-sync-state.json || echo "{}"
```
@@ -565,7 +563,7 @@ gbrain status: GREEN (mode: remote-http)
Repo policy ..... OK {read-write|read-only|deny}
Artifacts repo .. OK {gstack_artifacts_remote URL}
Artifacts sync .. OK {artifacts_sync_mode}
Transcripts ..... OK route to artifacts repo → remote brain (plan D11)
Transcripts ..... OK {recent|all}: route to artifacts repo → remote brain | INFO {off|not set}: skipped
Code search ..... {OK local-pglite (~/.gbrain/pglite) | N/A declined at Step 4d}
CLAUDE.md ....... OK
Smoke test ...... INFO printed for post-restart manual verification
@@ -578,11 +576,11 @@ The **Code search** row reflects the choice at Step 4d:
- If user picked A (Yes): `OK local-pglite` and `gbrain_local_status == "ok"` going forward.
- If user picked B (No): `N/A declined at Step 4d` — `gstack-config set local_code_index_offered true` to silence future migration notices.
The **Transcripts** row changed in v1.34.0.0: in remote-http mode,
gstack-memory-ingest now persists staged transcripts to
`~/.gstack/transcripts/run-<pid>-<ts>/` and gstack-brain-sync pushes them
to the artifacts repo. Brain admin's pull job indexes into the remote brain.
Local PGLite (when present) stays code-only — no transcript pollution.
The **Transcripts** row: in remote-http mode, gstack-memory-ingest persists
staged transcripts to `~/.gstack/transcripts/run-<pid>-<ts>/` and
gstack-brain-sync pushes them to the artifacts repo; the brain admin's pull
job indexes them into the remote brain. Local PGLite (when present) stays
code-only.
### Paths 1, 2a, 2b, 3 (Local stdio)
@@ -596,23 +594,35 @@ gbrain status: GREEN (mode: local-stdio)
Repo policy ..... OK <read-write|read-only|deny>
Code import ..... OK <last_imported_head>
Artifacts sync .. OK <artifacts_sync_mode> to <remote>
Transcripts ..... OK <N> sessions, last ingest <when>
Transcripts ..... OK <recent|all>: <N> sessions, last ingest <when> | INFO <off|not set>: skipped
CLAUDE.md ....... OK
Smoke test ...... OK put → search → delete round-trip
Smoke test ...... OK put → search round-trip
Run `/setup-gbrain` again any time gbrain feels off; it's safe and idempotent.
```
A `not set` transcript mode is not a failure: transcripts stay skipped until
the user chooses, and `/sync-gbrain` asks.
If any row is YELLOW or RED, the verdict line says so and the failing rows
surface a one-line "next action" (e.g.,
`Engine .......... ERR PGLite corrupt — run \`gbrain restore-from-sync\` (V1.5)`).
For V1, restore-from-sync is a V1.5 P0 cross-repo TODO; until it ships,
the user's brain remote (with brain-sync enabled) holds curated artifacts
as markdown + git, recoverable manually via `gbrain import` from a clone.
`Engine .......... ERR PGLite corrupt — re-import from a clone of your brain remote with \`gbrain import\``).
There is no automatic restore: with brain-sync enabled, the brain remote holds
curated artifacts as markdown + git, recoverable via `gbrain import` from a
clone.
### Next: `/sync-gbrain`
This skill does not write the `## GBrain Search Guidance` block in CLAUDE.md;
`/sync-gbrain` writes it once this repo's code index answers a read. Unless
the verdict is RED, AskUserQuestion: "Run `/sync-gbrain` now to index this
repo and add the search guidance to CLAUDE.md?" A) Run it now (read and
follow the sync-gbrain skill) B) Later. If B, or with `SESSION_KIND: spawned`
or `headless`, end by naming `/sync-gbrain` as the next step.
---
## `/setup-gbrain --cleanup-orphans` (D20)
## `/setup-gbrain --cleanup-orphans`
Re-collect a PAT (show the Path 2a PAT scope disclosure — it lives in the
brain-init section; read that section if it isn't already loaded), then:
@@ -643,7 +653,7 @@ At end: `unset SUPABASE_ACCESS_TOKEN`. Revocation reminder.
---
## Telemetry (D4)
## Telemetry
The preamble's Telemetry block logs skill success/failure at exit. When
emitting the event, add these enumerated categorical values to the
@@ -653,15 +663,14 @@ telemetry payload (SAFE — no free-form secrets, never the URL or PAT):
`supabase-manual` | `pglite-local` | `switch-to-supabase` |
`switch-to-pglite` | `repo-flip-only` | `cleanup-orphans` |
`resume-provision`
- `install_performed`: `yes` | `no` (D5 reuse) | `skipped` (pre-existing)
- `install_performed`: `yes` | `no` (existing checkout reused) | `skipped` (pre-existing)
- `mcp_registered`: `yes` | `no` | `claude-missing`
- `trust_tier_set`: `read-write` | `read-only` | `deny` |
`skip-for-now` | `n/a` (outside git repo)
Never pass `SUPABASE_ACCESS_TOKEN`, `DB_PASS`, `GBRAIN_POOLER_URL`,
`GBRAIN_DATABASE_URL`, or any `postgresql://` substring to the telemetry
invocation. The CI grep test in `test/skill-validation.test.ts` enforces
this at build time.
invocation.
---
@@ -672,7 +681,7 @@ this at build time.
that holds the pooler URL long-term is `~/.gbrain/config.json`, written
by gbrain's own `init` at mode 0600 — that's gbrain's discipline, not
ours.
- **STOP points are hard.** Gbrain doctor not healthy, D19 PATH shadow, D9
- **STOP points are hard.** Gbrain doctor not healthy, installer PATH shadow,
migrate timeout, smoke test failure — each is a STOP. Do not paper over.
- **Concurrent-run lock.** At skill start, create the parent before acquiring
the lock atomically. Keep mkdir's error output; missing parents and filesystem
+60 -4
View File
@@ -1,9 +1,9 @@
# gstack memory ingest — what it does, what stays local, what you can do with it
This is the user-facing reference for the V1 transcript + memory ingest
feature in `/setup-gbrain`. If you ran `/setup-gbrain` and it asked
"Ingest THIS repo's transcripts into gbrain?", this doc explains what
happens after you say yes.
This is the user-facing reference for the transcript + memory ingest
feature in `/setup-gbrain` and `/sync-gbrain`. If either asked whether to
ingest your coding-agent sessions into gbrain, this doc explains what each
answer does.
## What gets ingested
@@ -20,6 +20,62 @@ happens after you say yes.
| Retros | `retro` | `~/.gstack/projects/<slug>/retros/*.md` | Medium |
| Builder profile | `builder-profile-entry` | `~/.gstack/builder-profile.jsonl` | Low |
## Transcripts
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:
| Value | What `/sync-gbrain` ingests |
|---|---|
| `recent` | Sessions from the last 90 days |
| `all` | All history (`--full` walks every session file) |
| `off` | No transcripts; other memory still syncs |
Both sources are covered: Claude Code sessions (`~/.claude/projects/`) and
Codex sessions (`~/.codex/sessions/`), from every project on this machine
that your per-remote trust policy allows, not only the current repo. Pages go
to your brain: the local engine (PGLite or Supabase) in local-stdio mode, or
the artifacts repo the remote brain pulls from in remote-http mode.
Set or change it:
```bash
gstack-config set transcript_ingest_mode recent # or all, or off
gstack-config has transcript_ingest_mode && gstack-config get transcript_ingest_mode
```
`gstack-config set` rejects any other value and keeps the stored one.
**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.
**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.
```bash
bun run bin/gstack-gbrain-sync.ts --incremental --sources transcript
```
**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.
**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.
**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).
## What stays local
- **State files** (`~/.gstack/.gbrain-sync-state.json`,
+18 -19
View File
@@ -20,7 +20,7 @@ printf '%s' "$GBRAIN_POOLER_URL" | ~/.claude/skills/gstack/bin/gstack-gbrain-sup
If the verify exit code is 3 (direct-connection URL), the verifier's own
message explains the fix; surface it and re-prompt for a Session Pooler URL.
On success, hand off to gbrain via env var (D10, never argv):
On success, hand off to gbrain via env var (never argv):
```bash
GBRAIN_DATABASE_URL="$GBRAIN_POOLER_URL" gbrain init --non-interactive --json
@@ -29,9 +29,9 @@ GBRAIN_DATABASE_URL="$GBRAIN_POOLER_URL" gbrain init --non-interactive --json
Then `unset GBRAIN_POOLER_URL GBRAIN_DATABASE_URL` immediately. The URL is
now persisted in `~/.gbrain/config.json` at mode 0600 by gbrain itself.
### Path 2a (Supabase, auto-provision — D7)
### Path 2a (Supabase, auto-provision)
Show the D11 PAT scope disclosure verbatim BEFORE collecting the token:
Show this PAT scope disclosure verbatim BEFORE collecting the token:
> *This Supabase Personal Access Token grants full read/write/delete access
> to every project in your Supabase account, not just the `gbrain` one we're
@@ -49,7 +49,7 @@ Then:
read_secret_to_env SUPABASE_ACCESS_TOKEN "Paste PAT: "
```
Ask the D17 tier prompt via AskUserQuestion: "Which Supabase tier?" Present
Ask the tier question via AskUserQuestion: "Which Supabase tier?" Present
Free (2-project limit, pauses after 7d inactivity) vs Pro ($25/mo, no
pauses, recommended for real use). Explain that tier is **org-level** (per
the Management API contract) — user picks their org based on its current
@@ -75,7 +75,7 @@ Generate the DB password (never shown to the user):
export DB_PASS=$(openssl rand -base64 24)
```
Set up a SIGINT trap (D12 basic recovery):
Set up a SIGINT trap so an interrupted provision can be resumed or deleted:
```bash
trap 'echo ""; echo "gstack-gbrain: interrupted. In-flight ref: $INFLIGHT_REF"; \
@@ -111,9 +111,9 @@ After success, emit the PAT revocation reminder:
Walk the user through the supabase.com steps:
1. Login at https://supabase.com/dashboard
2. Click "New Project," name it `gbrain`, pick a region, copy the generated
database password (you'll need it for paste-back? no — it's embedded in
the pooler URL we collect next)
2. Click "New Project," name it `gbrain`, pick a region, and copy the
generated database password — the pooler URL in step 4 must carry it
(replace any `[YOUR-PASSWORD]` placeholder before pasting)
3. Wait ~2 min for the project to initialize
4. Settings → Database → Connection Pooler → Session → copy the URL (port
6543)
@@ -126,8 +126,8 @@ Then follow the same secret-read + verify + init flow as Path 1.
# gstack default: voyage-code-3 (1024d) when VOYAGE_API_KEY is set — code
# retrieval beats general-purpose embeddings on real code queries (validated
# A/B). Without the key, gbrain auto-selects (OpenAI 1536d when available).
# Never select gbrain's legacy zeroentropyai recipe for a new brain: the hosted
# API sunsets September 4, 2026 (#2365); the wireup helper warns existing installs.
# Never select gbrain's legacy zeroentropyai recipe for a new brain: its hosted
# API's sunset date (September 4, 2026) has passed; the wireup helper warns existing installs.
set -- # flags ride the positional params — unquoted $VAR breaks under zsh word-splitting (#1798)
if [ -n "${VOYAGE_API_KEY:-}" ]; then
set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
@@ -155,7 +155,7 @@ Read with plain `read -r` (no secret hygiene needed — the URL alone isn't
a credential). Validate it starts with `https://` (require TLS for any
non-loopback host); refuse `http://` for non-localhost.
**4b. Collect bearer token via the secret-read helper (D10, never argv).**
**4b. Collect bearer token via the secret-read helper (never argv).**
```bash
. ~/.claude/skills/gstack/bin/gstack-gbrain-lib.sh
@@ -184,7 +184,7 @@ Capture two values from the verify output for downstream steps:
- `URL_FORM_SUPPORTED` (`true|false`) — passed to `gstack-artifacts-init` in
Step 7 to control which form of the brain-admin hookup command is printed.
**4d. (Path 4) Offer local PGLite for code search.** Per plan D10/D11, ask:
**4d. (Path 4) Offer local PGLite for code search.** Ask:
> D# — Want symbol-aware code search on this machine?
> Project/branch/task: <one-sentence grounding using detected slug + branch>
@@ -206,7 +206,7 @@ Capture two values from the verify output for downstream steps:
> ❌ Symbol code queries fall back to Grep in this repo's worktrees
> Net: A = full split-engine; B = remote-only.
**If A (Yes)**: install + init local PGLite with rollback-safe semantics (D7):
**If A (Yes)**: install + init local PGLite with rollback-safe semantics:
```bash
~/.claude/skills/gstack/bin/gstack-gbrain-install || exit $?
@@ -231,21 +231,20 @@ if ! gbrain init --pglite --json "$@"; then
fi
```
Then continue to Step 5a. The remote-http MCP registration in 5a runs as
today; the local PGLite is independent of MCP registration (Claude Code talks
Then continue to Step 5a. The remote-http MCP registration in 5a runs
unchanged; the local PGLite is independent of MCP registration (Claude Code talks
to the remote brain via MCP for queries; `gbrain` CLI talks to local PGLite
for code-def/refs/callers).
**If B (No)**: skip the install + init. The local engine stays absent.
`gbrain_local_status` will be `missing-config` (or `no-cli` if gbrain isn't
installed). `/sync-gbrain` will SKIP the code stage cleanly per plan D12.
installed). `/sync-gbrain` will SKIP the code stage cleanly.
**4e. Skip Steps 3, 4 (other paths) and 5 (local doctor) when B was picked.**
When A was picked, Step 3 already ran (via gstack-gbrain-install) and Step 4
already ran (via `gbrain init --pglite`); jump straight to Step 5a. When B
was picked, Steps 3/4/5 are no-ops; also skip Step 7.5 (transcript ingest)
since memory-stage routes through the artifacts pipeline in remote-http mode
per plan D11.
since memory-stage routes through the artifacts pipeline in remote-http mode.
The bearer token (`GBRAIN_MCP_TOKEN`) stays in process env until Step 5a's
`claude mcp add --header` consumes it; then `unset GBRAIN_MCP_TOKEN`
@@ -262,7 +261,7 @@ timeout 180s gbrain migrate --to supabase --url "$URL" --json
timeout 180s gbrain migrate --to pglite --json
```
If `timeout` returns 124 (exit code for timeout): surface D9 message
If `timeout` returns 124 (exit code for timeout): surface this message
("Migration didn't complete in 3 minutes — another gstack session may be
holding a lock on the source brain. Close other workspaces and re-run
`/setup-gbrain --switch`. Your original brain is untouched."). STOP.
@@ -32,46 +32,7 @@ in to git in many projects). It lives only in `~/.claude.json` where
- Current repo policy: {read-write|read-only|deny|unset}
```
**After Step 9 (smoke test) passes, also write the `## GBrain Search Guidance`
block** so the coding agent learns when to prefer `gbrain` over Grep. This
block is gated on the smoke test passing — write the Configuration block
first (so the user knows what state they're in even if the smoke test fails),
then return here after Step 9 and write the guidance block only if smoke
test succeeded.
When Step 9 passes, find-and-replace (or append) this block. Use HTML-comment
delimiters so removal regex is unambiguous and never eats user content. The
block content is machine-AGNOSTIC — no engine type, no page counts, no
last-sync time. Machine state stays in the Configuration block above.
```markdown
## GBrain Search Guidance (configured by /sync-gbrain)
<!-- gstack-gbrain-search-guidance:start -->
GBrain is set up and synced on this machine. The agent should prefer gbrain
over Grep when the question is semantic or when you don't know the exact
identifier yet. Two indexed corpora available via the `gbrain` CLI:
- This repo's code (registered as `gstack-code-<repo>` source).
- `~/.gstack/` curated memory (registered as `gstack-brain-<user>` source via
the existing federation pipeline).
Prefer gbrain when:
- "Where is X handled?" / semantic intent, no exact string yet:
`gbrain search "<terms>"` or `gbrain query "<question>"`
- "Where is symbol Y defined?" / symbol-based code questions:
`gbrain code-def <symbol>` or `gbrain code-refs <symbol>`
- "What calls Y?" / "What does Y depend on?":
`gbrain code-callers <symbol>` / `gbrain code-callees <symbol>`
- "What did we decide last time?" / past plans, retros, learnings:
`gbrain search "<terms>" --source gstack-brain-<user>`
Grep is still right for known exact strings, regex, multiline patterns, and
file globs. The brain auto-syncs incrementally on every gstack skill start.
Run `/sync-gbrain` to force-refresh, `/sync-gbrain --full` for full reindex.
<!-- gstack-gbrain-search-guidance:end -->
```
If Step 9 smoke test fails, skip the guidance block write entirely. The user's
next `/sync-gbrain` run will re-evaluate capability and write the block when
the round-trip works.
Do not write a `## GBrain Search Guidance` block here. `/sync-gbrain` is its
only writer: it writes the block after its read check confirms this repo's
code index answers, and replaces any older block under the same heading.
Step 10 offers to run `/sync-gbrain`.
@@ -1,6 +1,6 @@
### Step 1.5 remediation: broken-engine AskUserQuestion + repair branches
The user has a non-working local engine (Garry's repro: `~/.gbrain/config.json`
The user has a non-working local engine (for example, `~/.gbrain/config.json`
points at a dead Postgres URL). Fire a targeted AskUserQuestion BEFORE Step 2:
> D# — Your local gbrain engine isn't responding. How do you want to fix it?
@@ -37,7 +37,7 @@ with `GSTACK_DETECT_NO_CACHE=1` (busts the 60s cache). If the new
`gbrain_local_status` is `ok`, continue to Step 2. If still `broken-db` or
`broken-config`, fire the same AskUserQuestion again (the user picks again).
**If B (Switch to PGLite)** — execute the rollback-safe init sequence (plan D7):
**If B (Switch to PGLite)** — execute the rollback-safe init sequence:
```bash
BACKUP="$HOME/.gbrain/config.json.gstack-bak-$(date +%s)"
@@ -45,8 +45,8 @@ mv "$HOME/.gbrain/config.json" "$BACKUP"
# gstack default: voyage-code-3 (1024d) when VOYAGE_API_KEY is set — best for
# code retrieval. Without the key, fall back to gbrain's own auto-selected
# embedding provider chain (OpenAI 1536d when OPENAI_API_KEY is present, etc.).
# Never select gbrain's legacy zeroentropyai recipe for a new brain: the hosted
# API sunsets September 4, 2026 (#2365); the wireup helper warns existing installs.
# Never select gbrain's legacy zeroentropyai recipe for a new brain: its hosted
# API's sunset date (September 4, 2026) has passed; the wireup helper warns existing installs.
set -- # flags ride the positional params — unquoted $VAR breaks under zsh word-splitting (#1798)
if [ -n "${VOYAGE_API_KEY:-}" ]; then
set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
+3 -3
View File
@@ -19,14 +19,14 @@
{
"id": "transcript-gate",
"file": "transcript-gate.md",
"title": "Step 7.5: transcript & memory ingest gate (probe, thresholds, ingest AskUserQuestion)",
"title": "Step 7.5: transcript ingest consent (has-check, probe, recent/all/off AskUserQuestion)",
"trigger": "running the Step 7.5 transcript & memory ingest gate on Paths 1, 2a, 2b, or 3 (Path 4 skips this section entirely — see the skeleton's skip note)"
},
{
"id": "claude-md-persist",
"file": "claude-md-persist.md",
"title": "Step 8: persist the GBrain Configuration + Search Guidance blocks in CLAUDE.md",
"trigger": "persisting the Step 8 `## GBrain Configuration` block to CLAUDE.md (and the Search Guidance block after Step 9 passes)"
"title": "Step 8: persist the GBrain Configuration block in CLAUDE.md",
"trigger": "persisting the Step 8 `## GBrain Configuration` block to CLAUDE.md"
}
]
}
+71 -45
View File
@@ -1,64 +1,90 @@
After memory sync is wired (Step 7) but before persisting the CLAUDE.md
config (Step 8), offer to bring this Mac's coding-agent transcripts +
curated `~/.gstack/` artifacts into gbrain so the retrieval surface
(per-skill manifests, salience block) has data to surface.
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`.
Run the probe to size the operation:
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
bun run ~/.claude/skills/gstack/bin/gstack-memory-ingest.ts --probe
_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
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)" ;;
esac
else
echo "TRANSCRIPT_MODE: ask (not set)"
fi
```
Read the output. If `Total files in window: 0`, skip — there's nothing
to ingest. Set `gstack-config set transcript_ingest_mode incremental`
silently and continue to Step 8.
- `TRANSCRIPT_MODE: recent`, `all` or `off`: 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
that the next interactive `/sync-gbrain` will ask.
If `New (never ingested)` is < 200 AND total bytes are < 100MB: silent
bulk via `bun run ~/.claude/skills/gstack/bin/gstack-memory-ingest.ts --bulk --quiet`. Set
`transcript_ingest_mode=incremental` and continue.
Size the question with two probes. `--sources transcript` makes the probe
count transcripts even though none are consented yet:
```bash
bun run ~/.claude/skills/gstack/bin/gstack-memory-ingest.ts --probe --sources transcript
bun run ~/.claude/skills/gstack/bin/gstack-memory-ingest.ts --probe --sources transcript --all-history
```
Read `Total files in window`, `Total bytes` and the estimate from each.
Otherwise (the "many transcripts on disk" path): AskUserQuestion with
the exact counts AND the value promise. Default scope is **current repo
only, last 90 days**:
**No transcripts found** (both probes report 0): ask yes/no instead:
> "Found <N_repo> transcripts in THIS repo (<repo-slug>) over the last
> 90 days, plus <N_other> across other repos on this machine (<bytes>
> total if all ingested). Ingest THIS repo's transcripts into gbrain?
> "Ingest coding-agent sessions as they appear?"
Yes stores `recent`; 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,
2a, 2b) or the local PGLite brain on this machine (Path 3).
> "Found <N_recent> Claude Code and Codex sessions from the last 90 days
> (<N_all> in all history, <bytes>) across the projects on this machine
> that your repo policy allows. Ingest them into your <Supabase | local
> PGLite> brain?
>
> What you get after this: every gstack skill auto-loads recent salience
> from your past sessions in this repo, so the agent finds your prior
> work without you describing it. You can query 'what was I doing on
> day X' and get a real answer. Per-session pages are searchable,
> taggable, and deletable. Secret scanning runs before any push.
> What you get: gstack skills load recent context from your past sessions,
> so the agent finds your prior work without you describing it, and you
> can ask 'what was I doing on day X'. Per-session pages are searchable,
> taggable and deletable. Secret scanning runs before any push.
>
> What stays the same: nothing leaves your machine unless gbrain sync
> is enabled (Step 7). Per-repo trust policies still apply.
> What stays the same: nothing leaves this machine unless brain sync is
> enabled (Step 7). Per-repo trust policies still apply. The first full
> sync can take a while on a large history (estimate: <est>); skills stay
> usable while it runs.
>
> Multi-Mac note: if you HAVE enabled brain sync (Step 7), these
> transcript pages will sync across your Macs. Caveat: deleting a
> transcript page later removes it from gbrain but git history retains
> it in prior commits. Use `gstack-transcript-prune` to delete in bulk;
> use `git filter-repo` on the brain remote for hard-delete from
> history."
> Multi-machine note: with brain sync enabled, transcript pages sync across
> your machines. Deleting a page later removes it from gbrain, but git
> history keeps it in earlier commits. Use `gstack-transcript-prune` to
> delete in bulk, and `git filter-repo` on the brain remote to remove it
> from history."
Options:
- A) Yes — this repo, last 90 days (recommended; ~est min)
- B) Yes — this repo, ALL history
- C) Yes — this repo + other repos on this machine
- D) Skip historical, track new from now (`transcript_ingest_mode=incremental`)
- E) Never ingest transcripts (`transcript_ingest_mode=off`)
- A) Yes, last 90 days (`recent`)
- B) Yes, all history (`all`)
- E) No, never ingest transcripts (`off`)
After answer:
Store the mode value, never the letter:
```bash
~/.claude/skills/gstack/bin/gstack-config set transcript_ingest_mode <recent|all|off>
```
For `recent` or `all`, run the first sync now:
```bash
~/.claude/skills/gstack/bin/gstack-config set transcript_ingest_mode <choice>
bun run ~/.claude/skills/gstack/bin/gstack-gbrain-sync.ts --full --no-brain-sync
```
(`--no-brain-sync` because Step 7 already wired that path; this just
runs the code import + memory ingest stages. Brain-sync will run on the
next preamble hook.)
(`--no-brain-sync` because Step 7 already wired that path; this runs the
code import and memory ingest stages, and `all` walks the full history.)
For `off`, run nothing here; other memory still syncs on the next
`/sync-gbrain`.
If A/D/E, ingest is incremental from this point on; preamble-boundary
hook runs `bun run ~/.claude/skills/gstack/bin/gstack-gbrain-sync.ts --incremental --quiet` on every skill
start (cheap mtime fast-path).
New transcripts are ingested on the next `/sync-gbrain` run. Nothing at
skill start ingests them.
Reference doc for users: `setup-gbrain/memory.md` (linked from CLAUDE.md
Step 8).
Reference doc for users: `setup-gbrain/memory.md#transcripts`.
+79 -24
View File
@@ -34,7 +34,7 @@ refreshed against this repo's current state, and refreshes the agent-side
guidance in CLAUDE.md so the coding agent knows when to prefer `gbrain`
search over Grep.
**Architecture (post-codex review):** This skill uses gbrain v0.20.0+'s
**Architecture:** This skill uses gbrain v0.20.0+'s
**native code surfaces** (`gbrain sources add`, `gbrain sync --strategy code`,
`gbrain reindex-code`, `gbrain code-def/code-refs/code-callers/code-callees`).
It does NOT use `gbrain import` (that path is for markdown directories).
@@ -53,10 +53,10 @@ the skill itself, not a dispatcher binary):
- `/sync-gbrain --code-only` — only run the code stage; skip memory + brain-sync
- `/sync-gbrain --dry-run` — preview what would sync; no writes anywhere
- `/sync-gbrain --no-memory` / `--no-brain-sync` — selectively skip stages
- `/sync-gbrain --sources <types|all>` — memory types to ingest (env `GSTACK_MEMORY_INGEST_SOURCES`); by default, types a federated gstack source already indexes are skipped
- `/sync-gbrain --sources <types|all>` — memory types to ingest (env `GSTACK_MEMORY_INGEST_SOURCES`); by default, types a federated gstack source already indexes are skipped, and transcripts are skipped unless `transcript_ingest_mode` is `recent` or `all` (a list naming `transcript` overrides that for one run)
- `/sync-gbrain --quiet` — suppress per-stage output
- `/sync-gbrain --refresh-cache` — force-rebuild brain-aware planning cache (v1.48; replaces /brain-refresh-context per D1 fold). Skips code + memory stages; routes to `gstack-brain-cache refresh --project <slug>`.
- `/sync-gbrain --audit` — emit summary of gstack-owned pages per project + sensitive-content audit (v1.48 / D10 lifecycle). Read-only.
- `/sync-gbrain --refresh-cache` — force-rebuild brain-aware planning cache. Skips code + memory stages; routes to `gstack-brain-cache refresh --project <slug>`.
- `/sync-gbrain --audit` — emit summary of gstack-owned pages per project + sensitive-content audit. Read-only.
Pass-through args go straight to the orchestrator at
`{{BIN_DIR}}/gstack-gbrain-sync.ts`.
@@ -71,20 +71,20 @@ brain has new info gstack should pick up before the next planning skill.
**`--audit` short-circuit:** when this flag is present, the skill runs
`gstack-brain-cache list --project <slug> --json`, summarizes by page
type, then scans for any cached salience entries that ended up outside
the SALIENCE_DEFAULT_ALLOWLIST (T17 / D9 leak check). Read-only; no
the SALIENCE_DEFAULT_ALLOWLIST (leak check). Read-only; no
modifications to brain or cache.
---
## Step 1: State probe
Before doing anything, check that /setup-gbrain has been run on this Mac.
Before doing anything, check that /setup-gbrain has been run on this machine.
```bash
~/.claude/skills/gstack/bin/gstack-gbrain-detect 2>/dev/null
```
**Brain trust policy gate (v1.48 / Phase 1.5 / D4 — added by T13+T5c):**
**Brain trust policy gate:**
If `gbrain_mcp_mode == "remote-http"` from the detect output AND the per-
endpoint policy is `unset`, the policy question MUST fire here before
the orchestrator runs. Local engines auto-set to `personal` silently per
@@ -107,20 +107,20 @@ If `_POLICY == "unset"` AND `_HASH == "local"`, auto-set personal:
~/.claude/skills/gstack/bin/gstack-config set brain_trust_policy@$_HASH personal
```
**Split-engine model (v1.34.0.0+).** Code stage runs locally against the
**Split-engine model.** Code stage runs locally against the
per-machine gbrain engine (PGLite or whatever `gbrain config` points to),
with each worktree of a repo registered as its own source. **Memory stage
also runs locally** in local-stdio MCP mode — `gstack-memory-ingest` shells
out to `gbrain import` against the same local engine. In remote-http MCP
mode (Path 4), the memory stage instead persists staged markdown to
`~/.gstack/transcripts/<run-id>/` and the artifacts pipeline pushes it to
the brain admin's pull job (plan D11). Brain-sync (the `gstack-brain-sync`
the brain admin's pull job. Brain-sync (the `gstack-brain-sync`
push to git) is the one stage that never touches local engine and runs
regardless of mode.
Practically: local PGLite stays code-only on remote-http machines; the
remote brain holds everything else. Local-stdio machines mix code +
transcripts in one local engine, as they always have.
transcripts in one local engine.
Also check the per-repo trust policy. If `gstack-gbrain-repo-policy get` for
this repo returns `deny`, STOP:
@@ -130,18 +130,18 @@ this repo returns `deny`, STOP:
---
## Step 1.5: Local engine pre-flight (plan D12)
## Step 1.5: Local engine pre-flight
Read `gbrain_local_status` from the Step 1 detect output. Branch as follows
BEFORE invoking the orchestrator:
- **`ok`**: proceed to Step 2 normally.
- **`timeout`**: proceed to Step 2 — the engine is most likely healthy but
slow (cold pooler connection, #1964). Tell the user in one line: "Engine
slow (cold pooler connection). Tell the user in one line: "Engine
probe timed out (>15s) — proceeding; raise `GSTACK_GBRAIN_PROBE_TIMEOUT_MS`
if your pooler is slow." Do NOT treat this as a broken config.
- **`thin-client`**: proceed to Step 2 — this machine is a thin client of a
remote-HTTP MCP brain (#2051): no local engine BY DESIGN, so the code,
remote-HTTP MCP brain: no local engine BY DESIGN, so the code,
memory, and dream stages will SKIP with a thin-client reason (code indexing
runs on the brain server; memory syncs via the remote brain's artifacts
pull). Only the brain-sync push runs locally. Tell the user in one line:
@@ -157,12 +157,12 @@ BEFORE invoking the orchestrator:
- **`missing-config`** AND `gbrain_mcp_mode == "remote-http"`: tell the user
"Your brain queries (the `mcp__gbrain__*` tools) work via remote MCP, but
symbol code search needs a local PGLite. Run `/setup-gbrain` and pick
'Yes' at the new 'local code index' prompt (Step 4.5), or run
'Yes' at the local code-search prompt (Path 4, step 4d), or run
`gbrain init --pglite --json --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024`
directly (drop the voyage flags if `VOYAGE_API_KEY` isn't set). Continuing
without code stage."
Then proceed to Step 2 — the orchestrator's `runCodeImport()` and
`runMemoryIngest()` will return SKIP per plan D12; only `runBrainSyncPush()`
`runMemoryIngest()` will return SKIP; only `runBrainSyncPush()`
will run. Do NOT abort.
- **`missing-config`** AND `gbrain_mcp_mode != "remote-http"`: STOP. "Local
gbrain CLI is installed but no engine config. Run `/setup-gbrain` first."
@@ -171,7 +171,7 @@ BEFORE invoking the orchestrator:
Local gbrain config at ~/.gbrain/config.json points at an unreachable
engine (status: {gbrain_local_status}). Two options:
1. Re-run /setup-gbrain — Step 1.5 offers Retry / Switch to PGLite /
Switch brain mode / Quit (plan D4).
Switch brain mode / Quit.
2. Repair manually: mv ~/.gbrain/config.json ~/.gbrain/config.json.bak
&& gbrain init --pglite --json --embedding-model voyage:voyage-code-3 \
--embedding-dimensions 1024 (drop voyage flags if VOYAGE_API_KEY unset)
@@ -187,6 +187,61 @@ 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).
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:
```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
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)" ;;
esac
else
echo "TRANSCRIPT_MODE: ask (not set)"
fi
```
`recent`, `all` or `off`: 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
consented yet):
```bash
bun run ~/.claude/skills/gstack/bin/gstack-memory-ingest.ts --probe --sources transcript
bun run ~/.claude/skills/gstack/bin/gstack-memory-ingest.ts --probe --sources transcript --all-history
```
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
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:
PGLite or Supabase) or, in remote-http mode, the artifacts repo the remote
brain pulls from. Say that a full sync of a large history can take a while
and skills stay usable meanwhile. Options:
- A) Yes, last 90 days (`recent`)
- B) Yes, all history (`all`)
- E) No, never ingest transcripts (`off`)
Store the value, never the letter, then continue to Step 2:
```bash
~/.claude/skills/gstack/bin/gstack-config set transcript_ingest_mode <recent|all|off>
```
Other memory types sync whatever the answer. Details:
`setup-gbrain/memory.md#transcripts`.
---
## Step 2: Run the orchestrator
Pass user args to the orchestrator. Do not paraphrase them — pass through
@@ -196,8 +251,7 @@ as-is.
bun run ~/.claude/skills/gstack/bin/gstack-gbrain-sync.ts <user-args>
```
The orchestrator runs three stages: code → memory → brain-sync (per the
plan's storage tiering). Each stage failure is non-fatal; subsequent stages
The orchestrator runs three stages: code → memory → brain-sync. Each stage failure is non-fatal; subsequent stages
still run. State is persisted to `~/.gstack/.gbrain-sync-state.json` via
tmp-file + atomic rename. Concurrent runs are blocked by a lock file at
`~/.gstack/.sync-gbrain.lock` (5-min stale-takeover).
@@ -227,7 +281,7 @@ the preamble:
>
> ELI10: gbrain hasn't indexed this repo's code yet. The semantic search
> tools (`gbrain search`, `code-def`, `code-refs`) will return nothing
> until we run a full pass. Takes ~25-35 minutes on a big Mac.
> until we run a full pass. Takes ~25-35 minutes on a big repo.
>
> Recommendation: A — the brain is unusable for code search until indexed,
> and Step 2 of this skill already verified gbrain is configured correctly.
@@ -318,7 +372,7 @@ only that a cycle has run, not that edges exist (a non-code-aware pack reports
## Step 4: Refresh `## GBrain Search Guidance` block in CLAUDE.md
Capability check (per /plan-eng-review §6):
Capability check:
```bash
bun run ~/.claude/skills/gstack/bin/gstack-gbrain-read-capability.ts <user-args>
@@ -432,8 +486,8 @@ continue.
Print a status block matching `/setup-gbrain` Step 10 conventions. Each row
is `[OK]/[FIX]/[WARN]/[ERR]`. Reuse `gbrain doctor --json --fast` for
informational rows but DO NOT gate the guidance block on doctor (per
/plan-eng-review §6 — doctor is too strict for unrelated reasons).
informational rows but DO NOT gate the guidance block on doctor (doctor
fails for reasons unrelated to code search).
```
gbrain status: GREEN
@@ -445,6 +499,7 @@ gbrain status: GREEN
Call graph ...... OK <N> edges resolved (code-callers/callees live)
~/.gstack source. OK <gstack-brain-{user}> (page_count=<N>) — managed by /setup-gbrain
Memory sync ..... OK <artifacts_sync_mode>
Transcripts ..... OK <recent|all> | INFO <off|not set>: skipped
CLAUDE.md ....... OK ## GBrain Search Guidance present
Last sync ....... OK <last_sync from state file>
@@ -481,7 +536,7 @@ A `never`/`unknown` Call graph row flips the verdict to YELLOW.
## Concurrency note
This skill is safe to run concurrently from multiple terminals on the same
Mac. The orchestrator acquires a lock at `~/.gstack/.sync-gbrain.lock` before
machine. The orchestrator acquires a lock at `~/.gstack/.sync-gbrain.lock` before
any state-file or CLAUDE.md mutation and exits with code 2 if another sync is
in flight. Stale locks (process died) auto-clear after 5 minutes.
@@ -489,7 +544,7 @@ in flight. Stale locks (process died) auto-clear after 5 minutes.
The `## GBrain Search Guidance` block is committed to the repo's CLAUDE.md
and travels with `git push`/`git pull` — NOT through `~/.gstack/.brain-allowlist`
(which is for `~/.gstack/` brain-sync only). On a different Mac with a synced
(which is for `~/.gstack/` brain-sync only). On a different machine with a synced
CLAUDE.md but no local gbrain, /sync-gbrain reports an unknown read and
preserves the block rather than deleting committed instructions based on a
transient or machine-local failure. Agents must not treat unknown as ready.
+33
View File
@@ -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]);
+173
View File
@@ -26,6 +26,11 @@
* cross_project_learnings ("unset triggers the first-time prompt") and
* redact_repo_visibility ("empty falls through to gh/glab detection")
* depend on receiving "" successfully.
*
* The reverse direction is pinned too: every key in the DEFAULTS table must
* be read by code (bin/, lib/, scripts/, setup, or a .tmpl), or be listed in
* PROSE_ONLY_KEYS with a reason. A documented setting no code reads is a
* switch that does nothing, which is how a consent choice once went unread.
*/
import { describe, test, expect } from 'bun:test';
@@ -109,6 +114,74 @@ function keysReadInTree(root = ROOT): string[] {
return [...keys].sort();
}
/**
* DEFAULTS-table keys no code reads. Each entry needs a reason; an empty list
* means every documented setting has a reader.
*/
const PROSE_ONLY_KEYS: Record<string, string> = {};
const READER_DIRS = ['bin', 'lib', 'scripts'];
const READER_FILES = ['setup'];
/**
* Source text a config reader could live in: everything under READER_DIRS,
* READER_FILES, and every .tmpl in the tree. bin/gstack-config counts too,
* minus its documentation header and its DEFAULTS table.
*/
function readerCorpus(root = ROOT): string {
const files = new Set<string>();
const walk = (dir: string, all: boolean) => {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const ent of entries) {
if (SKIP_DIRS.has(ent.name) || ent.isSymbolicLink()) continue;
const full = path.join(dir, ent.name);
if (ent.isDirectory()) walk(full, all);
else if (all || ent.name.endsWith('.tmpl')) files.add(full);
}
};
for (const d of READER_DIRS) walk(path.join(root, d), true);
walk(root, false);
for (const f of READER_FILES) files.add(path.join(root, f));
return [...files]
.map((f) => {
let text = '';
try {
text = fs.readFileSync(f, 'utf-8');
} catch {
return '';
}
if (f === path.join(root, 'bin', 'gstack-config')) {
text = text.replace(/CONFIG_HEADER='[\s\S]*?\n'\n/, '').replace(/lookup_default\(\) \{[\s\S]*?\n\}/, '');
}
return text;
})
.join('\n');
}
/**
* A key counts as read when the corpus calls `gstack-config get|has <key>`
* (directly or through a variable naming the config binary), passes
* ['get', '<key>'] to a spawn, calls a config-reader function with the key,
* or anchors the YAML key (`^<key>:`). Wildcard keys match by prefix.
*/
function isReadInCorpus(arm: string, corpus: string): boolean {
const esc = (v: string) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wildcard = arm.endsWith('*');
const k = wildcard ? esc(arm.slice(0, -1)) : `${esc(arm)}(?![A-Za-z0-9_])`;
const re = new RegExp([
`(?:gstack-config|\\$\\{?[A-Z_]*CONF[A-Z_]*\\}?)["']?[ \\t]+(?:get|has)[ \\t]+["']?${k}`,
`['"](?:get|has)['"]\\s*,\\s*['"]${k}`,
`(?:readConfigKey|readConfigKeyWithRoot|configValue|configGet|gstack_read_config_key|gstack_config_select)\\(?[ \\t]*['"\`]?${k}`,
`\\^${k}${wildcard ? '' : ':'}`,
].join('|'));
return re.test(corpus);
}
describe('gstack-config defaults (gate, free)', () => {
test('retired checkpoint keys have no defaults or advertised configuration', () => {
expect(fs.readFileSync(CONFIG_BIN, 'utf8')).not.toMatch(/checkpoint/i);
@@ -146,6 +219,49 @@ describe('gstack-config defaults (gate, free)', () => {
expect(uncovered).toEqual([]);
});
test('every DEFAULTS key is read by code or listed as prose-only', () => {
const corpus = readerCorpus();
const arms = defaultArms();
const unread = arms.filter((a) => !(a in PROSE_ONLY_KEYS) && !isReadInCorpus(a, corpus));
if (unread.length > 0) {
throw new Error(
`DEFAULTS keys with no reader: ${unread.join(', ')}. Fix one of two ways: ` +
`add the code that reads the key (gstack-config get/has <key> in bin/, lib/, scripts/, setup or a .tmpl), ` +
`or add it to PROSE_ONLY_KEYS in ${SELF} with the reason it has no reader.`,
);
}
const stale = Object.keys(PROSE_ONLY_KEYS).filter((k) => !arms.includes(k) || isReadInCorpus(k, corpus));
if (stale.length > 0) {
throw new Error(
`PROSE_ONLY_KEYS entries that are no longer prose-only: ${stale.join(', ')}. Fix one of two ways: ` +
`remove the entry because code now reads the key, or remove it because the key left the DEFAULTS table.`,
);
}
});
test('the reader matcher finds each reader form and nothing in prose', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-config-readers-'));
try {
fs.mkdirSync(path.join(root, 'bin'));
fs.mkdirSync(path.join(root, 'docs'));
fs.writeFileSync(path.join(root, 'bin', 'a'), [
'V=$("$CONFIG_BIN" get key_one 2>/dev/null)',
'gstack-config has key_two',
"spawnSync(bin, ['get', 'key_three'], { timeout: 2000 })",
"readConfigKey('key_four')",
'grep -E "^key_five_${hash}:" "$F"',
].join('\n'));
fs.writeFileSync(path.join(root, 'docs', 'prose.md'), 'gstack-config get key_six\n');
fs.writeFileSync(path.join(root, 'skill.md.tmpl'), 'gstack-config get key_seven\n');
const corpus = readerCorpus(root);
const read = ['key_one', 'key_two', 'key_three', 'key_four', 'key_five_*', 'key_six', 'key_seven', 'key_one_more']
.filter((k) => isReadInCorpus(k, corpus));
expect(read).toEqual(['key_one', 'key_two', 'key_three', 'key_four', 'key_five_*', 'key_seven']);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('an unknown key exits non-zero, so the caller fallback fires', () => {
const r = get('definitely_not_a_gstack_key_9f3a');
expect(r.code).not.toBe(0);
@@ -223,3 +339,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);
}
});
});
@@ -0,0 +1,458 @@
/**
* Transcript consent (transcript_ingest_mode) in the memory stage.
*
* Transcripts ingest only for a stored `recent` (last 90 days) or `all` (all
* history). An absent key, `off`, legacy gate values and anything else skip
* transcripts while the other memory types still sync, and the sync prints a
* notice naming how to choose. Runs the real orchestrator and memory ingest
* against a stub gbrain in a temp HOME / GSTACK_HOME.
*/
import { describe, expect, test } from "bun:test";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { spawn, spawnSync } from "child_process";
import {
MEMORY_INGEST_TYPES,
applyTranscriptConsent,
transcriptConsentNotice,
} from "../bin/gstack-gbrain-sync";
import { normalizeTranscriptConsent } from "../bin/gstack-memory-ingest";
const ROOT = join(import.meta.dir, "..");
const SYNC = join(ROOT, "bin", "gstack-gbrain-sync.ts");
const INGEST = join(ROOT, "bin", "gstack-memory-ingest.ts");
const HINT =
"To choose: run /sync-gbrain, or gstack-config set transcript_ingest_mode recent|all|off. Details: setup-gbrain/memory.md#transcripts";
const notice = (value: string) =>
`gbrain-sync: transcripts skipped (transcript_ingest_mode=${value}). Other memory still syncs. ${HINT}`;
describe("normalizeTranscriptConsent", () => {
const consent = (yaml: string | null) => {
const dir = mkdtempSync(join(tmpdir(), "gstack-consent-"));
try {
if (yaml !== null) writeFileSync(join(dir, "config.yaml"), yaml);
return normalizeTranscriptConsent({ GSTACK_STATE_ROOT: dir, HOME: dir });
} finally {
rmSync(dir, { recursive: true, force: true });
}
};
test("only recent and all are consent", () => {
expect(consent("transcript_ingest_mode: recent\n")).toEqual({ affirmative: true, window: "recent", reason: "recent", value: "recent" });
expect(consent("transcript_ingest_mode: all\n")).toEqual({ affirmative: true, window: "all", reason: "all", value: "all" });
expect(consent("transcript_ingest_mode: off\n")).toMatchObject({ affirmative: false, window: null, reason: "off" });
expect(consent(null)).toEqual({ affirmative: false, window: null, reason: "not-set", value: null });
expect(consent("telemetry: off\n")).toMatchObject({ affirmative: false, reason: "not-set" });
});
test("legacy gate values, any case and padding, need a new choice", () => {
for (const v of [" A ", "b", "C", "D", "e", "incremental", "Incremental"]) {
expect(consent(`transcript_ingest_mode: ${v}\n`)).toMatchObject({ affirmative: false, window: null, reason: "legacy" });
}
});
test("anything else is unrecognized, not consent", () => {
for (const v of ["yes", "new-only", "90d", "true"]) {
expect(consent(`transcript_ingest_mode: ${v}\n`)).toMatchObject({ affirmative: false, reason: "unrecognized", value: v });
}
});
test("the last line wins, like gstack-config get", () => {
expect(consent("transcript_ingest_mode: all\ntranscript_ingest_mode: off\n")).toMatchObject({ reason: "off" });
});
});
describe("applyTranscriptConsent", () => {
const yes = { affirmative: true, window: "recent" as const, reason: "recent" as const, value: "recent" };
const no = { affirmative: false, window: null, reason: "not-set" as const, value: null };
test("without consent, transcript leaves the default and the rest is pinned as an explicit list", () => {
const r = applyTranscriptConsent({ sources: null, why: "default: no federated gstack source registered" }, no);
expect(r.sources).toEqual(MEMORY_INGEST_TYPES.filter((t) => t !== "transcript"));
expect(r.selected).toEqual(r.sources!);
expect(r.override).toBe(false);
});
test("with consent, the default stays null so the ingest walks its own default", () => {
const r = applyTranscriptConsent({ sources: null, why: "default: no federated gstack source registered" }, yes);
expect(r.sources).toBeNull();
expect(r.selected).toEqual([...MEMORY_INGEST_TYPES]);
});
test("an explicit list naming transcript overrides; a derived list does not", () => {
expect(applyTranscriptConsent({ sources: ["transcript", "learning"], why: "--sources" }, no)).toMatchObject({ override: true, sources: ["transcript", "learning"] });
expect(applyTranscriptConsent({ sources: ["transcript"], why: "GSTACK_MEMORY_INGEST_SOURCES" }, no)).toMatchObject({ override: true });
const federated = applyTranscriptConsent({ sources: ["transcript", "learning"], why: "default: ceo-plan,design-doc already indexed" }, no);
expect(federated).toMatchObject({ override: false, sources: ["learning"] });
});
test("notice copy", () => {
expect(transcriptConsentNotice(no, true)).toBe(notice("not set"));
expect(transcriptConsentNotice({ ...no, reason: "legacy", value: "A" }, false)).toBe(notice("A"));
expect(transcriptConsentNotice({ ...no, reason: "off", value: "off" }, false)).toBe("gbrain-sync: transcripts off (your choice)");
expect(transcriptConsentNotice({ ...no, reason: "off", value: "off" }, true)).toBeNull();
expect(transcriptConsentNotice(yes, false)).toBeNull();
});
});
interface Sandbox {
home: string;
gstackHome: string;
stagedList: string;
calls: string;
env: Record<string, string>;
cleanup: () => void;
}
const DAY_MS = 24 * 60 * 60 * 1000;
function sandbox(opts: { federated?: boolean; mode?: string } = {}): Sandbox {
const home = mkdtempSync(join(tmpdir(), "gstack-tmode-"));
const gstackHome = join(home, ".gstack");
const worktree = join(home, ".gstack-brain-worktree");
const bindir = join(home, "bin");
const stagedList = join(home, "staged.list");
const calls = join(home, "gbrain.calls");
for (const d of [gstackHome, worktree, bindir, join(home, ".gbrain")]) mkdirSync(d, { recursive: true });
writeFileSync(join(home, ".gbrain", "config.json"), JSON.stringify({ engine: "pglite", database_url: "pglite:///test" }));
if (opts.mode !== undefined) writeFileSync(join(gstackHome, "config.yaml"), `transcript_ingest_mode: ${opts.mode}\n`);
// Curated memory: one learning and one CEO plan.
const proj = join(gstackHome, "projects", "acme-app");
mkdirSync(join(proj, "ceo-plans"), { recursive: true });
writeFileSync(join(proj, "ceo-plans", "2026-09-01-brand.md"), "# Brand plan\n\nShip the brand refresh.\n");
writeFileSync(
join(proj, "learnings.jsonl"),
JSON.stringify({ ts: "2026-09-01T00:00:00Z", skill: "review", type: "pitfall", key: "k", insight: "Check the lock first.", confidence: 8 }) + "\n",
);
// Two attributable Claude Code sessions: one recent, one 200 days old.
const repo = join(home, "work", "acme");
mkdirSync(repo, { recursive: true });
const git = (...args: string[]) => spawnSync("git", ["-C", repo, ...args], { encoding: "utf-8", timeout: 30_000 });
git("init", "-q");
git("remote", "add", "origin", "https://github.com/acme/app.git");
git("config", "--local", "url.https://github.com/acme/app.git.insteadOf", "https://github.com/acme/app.git");
const sessionDir = join(home, ".claude", "projects", "work-acme");
mkdirSync(sessionDir, { recursive: true });
const session = (id: string, text: string) =>
`{"type":"user","message":{"role":"user","content":"${text}"},"timestamp":"2026-09-01T00:00:00Z","cwd":"${repo}","sessionId":"${id}"}\n` +
`{"type":"assistant","message":{"role":"assistant","content":"ok"},"timestamp":"2026-09-01T00:00:01Z"}\n`;
writeFileSync(join(sessionDir, "recent0001.jsonl"), session("recent0001", "recent session"));
const old = join(sessionDir, "old000000001.jsonl");
writeFileSync(old, session("old000000001", "old session"));
const oldTime = new Date(Date.now() - 200 * DAY_MS);
utimesSync(old, oldTime, oldTime);
// Stub gbrain. With GBRAIN_PAUSE_DIR set, `sources list` (the first gbrain
// call of the memory stage) waits until the test releases it.
const sources = opts.federated
? `{"sources":[{"id":"gstack-artifacts-acme","local_path":"${worktree}","federated":true,"page_count":12}]}`
: `{"sources":[]}`;
writeFileSync(join(bindir, "gbrain"), `#!/bin/sh
printf '%s\\n' "$*" >> "${calls}"
case "$1" in
--version) echo "gbrain 0.60.28.0" ;;
--help) printf 'Commands:\\n import <dir> Import markdown directory\\n' ;;
sources)
if [ -n "$GBRAIN_PAUSE_DIR" ]; then
touch "$GBRAIN_PAUSE_DIR/paused"
i=0
while [ ! -e "$GBRAIN_PAUSE_DIR/release" ] && [ $i -lt 300 ]; do sleep 0.1; i=$((i+1)); done
fi
echo '${sources}' ;;
import)
( cd "$2" && find . -name '*.md' -type f | sort ) > "${stagedList}"
n=$(wc -l < "${stagedList}" | tr -d ' ')
echo "{\\"status\\":\\"success\\",\\"imported\\":$n,\\"skipped\\":0,\\"errors\\":0,\\"total_files\\":$n}"
;;
*) echo "unexpected gbrain $*" >&2; exit 1 ;;
esac
`);
chmodSync(join(bindir, "gbrain"), 0o755);
return {
home,
gstackHome,
stagedList,
calls,
env: {
HOME: home,
GSTACK_HOME: gstackHome,
GBRAIN_HOME: "",
GSTACK_MEMORY_INGEST_SOURCES: "",
GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: join(home, ".gitconfig"),
PATH: `${bindir}:${process.env.PATH || ""}`,
},
cleanup: () => rmSync(home, { recursive: true, force: true }),
};
}
function runSync(sb: Sandbox, args: string[], env: Record<string, string> = {}) {
return spawnSync("bun", [SYNC, "--no-code", "--no-brain-sync", ...args], {
encoding: "utf-8",
timeout: 60_000,
cwd: sb.home,
env: { ...process.env, ...sb.env, ...env },
});
}
function staged(sb: Sandbox): string {
return existsSync(sb.stagedList) ? readFileSync(sb.stagedList, "utf-8") : "";
}
function memoryStage(sb: Sandbox): { ok: boolean; memory_sources?: string[]; transcript_consent?: Record<string, unknown> } {
const state = JSON.parse(readFileSync(join(sb.gstackHome, ".gbrain-sync-state.json"), "utf-8"));
return state.last_stages.find((s: { name: string }) => s.name === "memory");
}
const transcripts = (sb: Sandbox) => staged(sb).split("\n").filter((l) => l.includes("transcripts/"));
describe.skipIf(process.platform === "win32")("memory stage transcript consent", () => {
test("absent key: transcripts skipped, other memory syncs, notice prints under --quiet", () => {
const sb = sandbox();
try {
const r = runSync(sb, ["--incremental", "--quiet"]);
expect(r.status).toBe(0);
expect(transcripts(sb)).toEqual([]);
expect(staged(sb)).toMatch(/learning/);
expect(staged(sb)).toMatch(/ceo-plan/);
expect(r.stderr).toContain(notice("not set"));
const stage = memoryStage(sb);
expect(stage.memory_sources).not.toContain("transcript");
expect(stage.transcript_consent).toEqual({ mode: "not-set", window: null, skip_reason: "not-set" });
} finally {
sb.cleanup();
}
});
test("stored off: skipped; neutral line only without --quiet", () => {
const sb = sandbox({ mode: "off" });
try {
const loud = runSync(sb, ["--incremental"]);
expect(loud.status).toBe(0);
expect(transcripts(sb)).toEqual([]);
expect(loud.stderr).toContain("gbrain-sync: transcripts off (your choice)");
expect(loud.stderr).not.toContain("transcripts skipped");
const quiet = runSync(sb, ["--incremental", "--quiet"]);
expect(quiet.status).toBe(0);
expect(quiet.stderr).not.toContain("gbrain-sync: transcripts");
expect(memoryStage(sb).transcript_consent).toEqual({ mode: "off", window: null, skip_reason: "off" });
} finally {
sb.cleanup();
}
});
test("legacy values are not consent", () => {
for (const mode of [" A ", "b", "D", "incremental"]) {
const sb = sandbox({ mode });
try {
const r = runSync(sb, ["--full", "--no-dream", "--quiet"]);
expect(r.status).toBe(0);
expect(transcripts(sb)).toEqual([]);
expect(staged(sb)).toMatch(/learning/);
expect(r.stderr).toContain(notice(mode.trim()));
expect(memoryStage(sb).transcript_consent).toMatchObject({ mode: "legacy", skip_reason: "legacy" });
} finally {
sb.cleanup();
}
}
});
test("recent: the 90-day window, on --full too", () => {
const sb = sandbox({ mode: "recent" });
try {
const r = runSync(sb, ["--full", "--no-dream", "--quiet"]);
expect(r.status).toBe(0);
expect(transcripts(sb).join("\n")).toMatch(/recent0001/);
expect(transcripts(sb).join("\n")).not.toMatch(/old00000000/);
expect(r.stderr).not.toContain("gbrain-sync: transcripts");
expect(memoryStage(sb).transcript_consent).toEqual({ mode: "recent", window: "recent" });
} finally {
sb.cleanup();
}
});
test("all: --full walks all history", () => {
const sb = sandbox({ mode: "all" });
try {
const r = runSync(sb, ["--full", "--no-dream", "--quiet"]);
expect(r.status).toBe(0);
expect(transcripts(sb).join("\n")).toMatch(/recent0001/);
expect(transcripts(sb).join("\n")).toMatch(/old00000000/);
expect(memoryStage(sb).transcript_consent).toEqual({ mode: "all", window: "all" });
} finally {
sb.cleanup();
}
});
test("an explicit list naming transcript overrides an absent key; the notice still prints", () => {
for (const [args, env, why] of [
[["--sources", "transcript,learning"], {}, "--sources"],
[[], { GSTACK_MEMORY_INGEST_SOURCES: "transcript" }, "GSTACK_MEMORY_INGEST_SOURCES"],
] as const) {
const sb = sandbox();
try {
const r = runSync(sb, ["--incremental", "--quiet", ...args], { ...env });
expect(r.status).toBe(0);
expect(transcripts(sb).join("\n")).toMatch(/recent0001/);
expect(r.stderr).toContain(`gbrain-sync: transcripts ingested because ${why} names transcript (transcript_ingest_mode=not set). ${HINT}`);
expect(memoryStage(sb).transcript_consent).toEqual({ mode: "not-set", window: null });
} finally {
sb.cleanup();
}
}
});
test("--sources all, an empty env list and an invalid-only list do not override", () => {
for (const [args, env] of [
[["--sources", "all"], {}],
[[], { GSTACK_MEMORY_INGEST_SOURCES: "" }],
[["--sources", "bogus"], {}],
[[], { GSTACK_MEMORY_INGEST_SOURCES: "nope" }],
] as const) {
const sb = sandbox();
try {
const r = runSync(sb, ["--incremental", "--quiet", ...args], { ...env });
expect(r.status).toBe(0);
expect(transcripts(sb)).toEqual([]);
expect(staged(sb)).toMatch(/learning/);
expect(r.stderr).toContain(notice("not set"));
} finally {
sb.cleanup();
}
}
});
test("federated source registered + absent key: no curated pages, no transcripts", () => {
const sb = sandbox({ federated: true });
try {
const r = runSync(sb, ["--incremental", "--quiet"]);
expect(r.status).toBe(0);
expect(staged(sb)).toMatch(/learning/);
expect(staged(sb)).not.toMatch(/ceo-plan/);
expect(transcripts(sb)).toEqual([]);
expect(memoryStage(sb).memory_sources).toEqual(
MEMORY_INGEST_TYPES.filter((t) => !["transcript", "ceo-plan", "design-doc"].includes(t)),
);
} finally {
sb.cleanup();
}
});
test("a config change during the run does not add transcripts to it", async () => {
const sb = sandbox();
const pause = join(sb.home, "pause");
mkdirSync(pause);
try {
const child = spawn("bun", [SYNC, "--no-code", "--no-brain-sync", "--incremental", "--quiet"], {
cwd: sb.home,
env: { ...process.env, ...sb.env, GBRAIN_PAUSE_DIR: pause },
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stderr.on("data", (d) => (stderr += d));
child.stdout.resume();
const exited = new Promise<number | null>((resolve) => child.on("exit", resolve));
const deadline = Date.now() + 30_000;
while (!existsSync(join(pause, "paused")) && Date.now() < deadline) await Bun.sleep(50);
expect(existsSync(join(pause, "paused"))).toBe(true);
writeFileSync(join(sb.gstackHome, "config.yaml"), "transcript_ingest_mode: all\n");
writeFileSync(join(pause, "release"), "");
expect(await exited).toBe(0);
expect(transcripts(sb)).toEqual([]);
expect(staged(sb)).toMatch(/learning/);
expect(stderr).toContain(notice("not set"));
expect(memoryStage(sb).transcript_consent).toMatchObject({ mode: "not-set" });
} finally {
sb.cleanup();
}
}, 60_000);
describe("checkpoint resume requires the same consent policy", () => {
function checkpoint(sb: Sandbox, previous: { memory_sources: string[]; transcript_consent?: Record<string, unknown> }): void {
const stagingDir = join(sb.gstackHome, ".staging-ingest-1-1");
mkdirSync(join(stagingDir, "projects"), { recursive: true });
writeFileSync(join(stagingDir, ".gstack-staging"), "");
writeFileSync(join(stagingDir, "projects", "stale-page.md"), "# stale\n");
writeFileSync(join(sb.home, ".gbrain", "import-checkpoint.json"), JSON.stringify({ dir: stagingDir, processedIndex: 0, totalFiles: 1 }));
writeFileSync(join(sb.gstackHome, ".gbrain-sync-state.json"), JSON.stringify({
schema_version: 1,
last_writer: "gstack-gbrain-sync",
last_stages: [{ name: "memory", ran: true, ok: false, duration_ms: 1, summary: "timeout", ...previous }],
}));
}
test("same types and policy resume the checkpoint", () => {
const sb = sandbox({ mode: "recent" });
try {
checkpoint(sb, { memory_sources: [...MEMORY_INGEST_TYPES], transcript_consent: { mode: "recent", window: "recent" } });
// The fixture's staging dir is not a real interrupted import, so the
// import's page accounting fails afterwards; the decision is what counts.
const r = runSync(sb, ["--incremental", "--quiet"]);
expect(r.stderr).toContain("resuming from gbrain checkpoint");
expect(r.stderr).not.toContain("transcript consent changed");
expect(staged(sb)).toContain("stale-page");
} finally {
sb.cleanup();
}
});
test("recent → all restages once and says so", () => {
const sb = sandbox({ mode: "all" });
try {
checkpoint(sb, { memory_sources: [...MEMORY_INGEST_TYPES], transcript_consent: { mode: "recent", window: "recent" } });
const r = runSync(sb, ["--incremental", "--quiet"]);
expect(r.status).toBe(0);
expect(r.stderr).toContain("gbrain-sync: transcript consent changed since the interrupted import (recent → all)");
expect(r.stderr).not.toContain("resuming from gbrain checkpoint");
expect(staged(sb)).not.toContain("stale-page");
} finally {
sb.cleanup();
}
});
test("all → off restages, with the skip notice first", () => {
const sb = sandbox({ mode: "off" });
try {
checkpoint(sb, { memory_sources: [...MEMORY_INGEST_TYPES], transcript_consent: { mode: "all", window: "all" } });
const r = runSync(sb, ["--incremental"]);
expect(r.status).toBe(0);
const offAt = r.stderr.indexOf("gbrain-sync: transcripts off (your choice)");
const restageAt = r.stderr.indexOf("gbrain-sync: transcript consent changed since the interrupted import (all → off)");
expect(offAt).toBeGreaterThanOrEqual(0);
expect(restageAt).toBeGreaterThan(offAt);
expect(staged(sb)).not.toContain("stale-page");
expect(transcripts(sb)).toEqual([]);
} finally {
sb.cleanup();
}
});
});
test("gstack-memory-ingest --bulk with the key absent ingests no transcripts", () => {
const sb = sandbox();
try {
const r = spawnSync("bun", [INGEST, "--bulk"], { encoding: "utf-8", timeout: 60_000, cwd: sb.home, env: { ...process.env, ...sb.env } });
expect(r.status).toBe(0);
expect(transcripts(sb)).toEqual([]);
expect(staged(sb)).toMatch(/learning/);
expect(r.stderr).toContain("gstack-memory-ingest: transcripts skipped (transcript_ingest_mode=not set)");
} finally {
sb.cleanup();
}
});
test("gstack-memory-ingest --bulk with all walks all history", () => {
const sb = sandbox({ mode: "all" });
try {
const r = spawnSync("bun", [INGEST, "--bulk", "--quiet"], { encoding: "utf-8", timeout: 60_000, cwd: sb.home, env: { ...process.env, ...sb.env } });
expect(r.status).toBe(0);
expect(transcripts(sb).join("\n")).toMatch(/old00000000/);
} finally {
sb.cleanup();
}
});
});
+15
View File
@@ -791,7 +791,22 @@ function isolateGitRemote(repo: string, url: string): void {
expect(git("remote", "get-url", "origin")).toBe(url);
}
/**
* These cases exercise transcript ingest after the user consented, so the
* isolated state root gets `transcript_ingest_mode: recent` unless the case
* set the key itself. The no-consent paths live in
* gstack-gbrain-sync-transcript-mode.test.ts.
*/
function seedTranscriptConsent(gstackHome: string | undefined): void {
if (!gstackHome) return;
mkdirSync(gstackHome, { recursive: true });
const config = join(gstackHome, "config.yaml");
const current = existsSync(config) ? readFileSync(config, "utf-8") : "";
if (!/^transcript_ingest_mode:/m.test(current)) writeFileSync(config, `${current}transcript_ingest_mode: recent\n`);
}
function runScript(args: string[], env: Record<string, string> = {}): { stdout: string; stderr: string; exitCode: number } {
seedTranscriptConsent(env.GSTACK_HOME);
const result = spawnSync("bun", [SCRIPT, ...args], {
encoding: "utf-8",
timeout: 30000,
+68
View File
@@ -0,0 +1,68 @@
/**
* /open-gstack-browser must not kill a live browse daemon on its own.
*
* Step 0 probes with `$B status` (without auto-starting a daemon). No live
* daemon → plain `$B connect`, which reaps orphans and refuses to replace a
* busy daemon itself. A live daemon → AskUserQuestion, and only an explicit
* yes runs `$B connect --force-restart`. Spawned or headless sessions leave it
* running and print how to replace it. The skill never kills the PID recorded
* in .gstack/browse.json by hand.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const TMPL = fs.readFileSync(path.join(ROOT, 'open-gstack-browser', 'SKILL.md.tmpl'), 'utf-8');
// Printed with the resolved browse binary path in place of %s.
const STOP_LINE = 'Live browse daemon left running. Run %s stop, then re-run /open-gstack-browser to replace it.';
const section = (from: string, to: string) => {
const start = TMPL.indexOf(from);
const end = TMPL.indexOf(to, start + from.length);
expect(start).toBeGreaterThanOrEqual(0);
expect(end).toBeGreaterThan(start);
return TMPL.slice(start, end);
};
describe('/open-gstack-browser daemon liveness', () => {
test('no hand-rolled kill of the recorded daemon PID', () => {
expect(TMPL).not.toMatch(/\bkill\b[^\n]*(_OLD_PID|\$\w*PID)/);
expect(TMPL).not.toMatch(/kill -9/);
expect(TMPL).not.toMatch(/grep -o '"pid":/);
expect(TMPL).not.toMatch(/rm -f[^\n]*browse\.json/);
});
test('Step 0 probes with $B status without auto-starting a daemon, before any connect', () => {
const probeAt = TMPL.indexOf('BROWSE_NO_AUTOSTART=1 $B status');
expect(probeAt).toBeGreaterThanOrEqual(0);
expect(probeAt).toBeLessThan(TMPL.indexOf('$B connect'));
});
test('--force-restart appears only in the explicit-yes branch', () => {
const live = section('`DAEMON: live`', '## Step 2');
const ask = live.indexOf('AskUserQuestion');
expect(ask).toBeGreaterThanOrEqual(0);
const occurrences = [...TMPL.matchAll(/--force-restart/g)].map((m) => m.index!);
expect(occurrences.length).toBeGreaterThan(0);
const liveStart = TMPL.indexOf('`DAEMON: live`');
const askAbs = liveStart + ask;
for (const at of occurrences) expect(at).toBeGreaterThan(askAbs);
expect(live).toMatch(/only an explicit a runs[^\n]*--force-restart/i);
expect(TMPL).toMatch(/after an explicit a[^\n]*\n+```bash\n\$B connect --force-restart\n```/i);
});
test('the no-daemon branch runs plain connect and keeps the Chromium profile-lock cleanup', () => {
const none = section('`DAEMON: none`', '`DAEMON: headed`');
expect(none).toContain('plain `$B connect`');
expect(none).toContain('SingletonLock SingletonSocket SingletonCookie');
expect(none).not.toContain('--force-restart');
});
test('spawned or headless sessions leave the daemon running and print the resolved-binary stop line', () => {
const live = section('`DAEMON: live`', '## Step 1');
expect(live).toMatch(/spawned[\s\S]{0,40}headless[\s\S]{0,40}do not ask/i);
expect(live).toContain(`printf '${STOP_LINE}\\n' "$B"`);
expect(live.indexOf(STOP_LINE)).toBeLessThan(live.indexOf('AskUserQuestion'));
});
});
@@ -194,6 +194,8 @@ describe.skipIf(process.platform === "win32")("memory stage source selection (#2
test("without a federated source, the default still ingests every type", () => {
const sb = sandbox({ federated: false });
try {
// Transcripts join the default selection only with consent.
writeFileSync(join(sb.gstackHome, "config.yaml"), "transcript_ingest_mode: recent\n");
const r = runSync(sb, ["--incremental"]);
expect(r.status).toBe(0);
expect(ingestedTypes(sb)).toEqual(["ceo-plan", "design-doc", "learning"]);
+20 -15
View File
@@ -74,10 +74,8 @@ describe('setup-gbrain templates (skeleton + sections) — bin invocation paths'
);
});
test('the silent-bulk mention uses bun run + .ts (R2, transcript-gate section)', () => {
expect(transcriptGate).toContain(
'bun run ~/.claude/skills/gstack/bin/gstack-memory-ingest.ts --bulk --quiet'
);
test('the gate never bulk-ingests before the user answers (transcript-gate section)', () => {
expect(transcriptGate).not.toMatch(/gstack-memory-ingest\.ts --bulk/);
});
test('the post-answer full-sync step uses bun run + .ts (R3, transcript-gate section)', () => {
@@ -86,21 +84,28 @@ describe('setup-gbrain templates (skeleton + sections) — bin invocation paths'
);
});
test('the preamble-hook incremental-sync mention uses bun run + .ts (R4, transcript-gate section)', () => {
expect(transcriptGate).toContain(
'bun run ~/.claude/skills/gstack/bin/gstack-gbrain-sync.ts --incremental --quiet'
);
test('no skill-start hook is claimed to ingest transcripts; /sync-gbrain is named (transcript-gate section)', () => {
expect(transcriptGate).not.toMatch(/every skill\s+start/i);
expect(transcriptGate).toContain('/sync-gbrain');
});
test('the neighboring gstack-config line in the post-answer block is untouched (bash script, no extension)', () => {
expect(transcriptGate).toContain(
'~/.claude/skills/gstack/bin/gstack-config set transcript_ingest_mode <choice>'
);
test('the post-answer gstack-config line stores a mode value, not the answer letter (bash script, no extension)', () => {
expect(transcriptGate).toContain('~/.claude/skills/gstack/bin/gstack-config set transcript_ingest_mode');
expect(transcriptGate).not.toContain('transcript_ingest_mode <choice>');
});
});
test('the prose-only mention naming the tool as a sentence subject is left unchanged (KTD4 — not a literal invocation; Step 10 verdict, skeleton)', () => {
expect(tmpl).toContain('gstack-memory-ingest now persists staged transcripts to');
});
describe('transcript consent question is gated on `gstack-config has`', () => {
const syncTmpl = fs.readFileSync(path.join(ROOT, 'sync-gbrain', 'SKILL.md.tmpl'), 'utf-8');
for (const [name, text] of [['setup-gbrain transcript gate', transcriptGate], ['sync-gbrain', syncTmpl]] as const) {
test(`${name} checks presence with has before asking, and never asks spawned or headless sessions`, () => {
const hasAt = text.indexOf('gstack-config has transcript_ingest_mode');
expect(hasAt).toBeGreaterThanOrEqual(0);
expect(text.indexOf('AskUserQuestion', hasAt)).toBeGreaterThan(hasAt);
for (const v of ['recent', 'all', 'off']) expect(text).toContain(`\`${v}\``);
expect(text).toMatch(/spawned[\s\S]{0,40}headless[\s\S]{0,80}do not ask/i);
});
}
});
describe('setup-gbrain/memory.md — bin invocation paths', () => {
+9 -16
View File
@@ -4,20 +4,14 @@ import * as os from 'os';
import * as path from 'path';
import { execFileSync } from 'child_process';
// Tripwire for the pid/port extraction snippets in /open-gstack-browser.
// Tripwire for the port extraction snippet in /open-gstack-browser.
//
// Step 0 (pre-flight cleanup) reads the stale daemon's pid out of
// .gstack/browse.json to kill it; Step 2 reads the port back to tell the user
// which one the Side Panel needs. Both used `grep -o '"pid":[0-9]*'`, which
// cannot match: every writer of that file in browse/src/server.ts serializes
// with `JSON.stringify(state, null, 2)`, so the real bytes are `"pid": 12060`
// — colon, SPACE, digits.
//
// The failure was silent in the worst way. `_OLD_PID` came back empty, the
// `kill` never ran, browse.json was deleted anyway, and the next `connect`
// died with "existing daemon has different config (proxy/headed mismatch)"
// — an error that points at proxy/headed flags, not at the cleanup that
// no-opped. Observed 2026-08-28 against a daemon left over from a reboot.
// Step 2 reads the port out of .gstack/browse.json to tell the user which one
// the Side Panel needs. A `grep -o '"port":[0-9]*'` pattern cannot match:
// every writer of that file in browse/src/server.ts serializes with
// `JSON.stringify(state, null, 2)`, so the real bytes are `"port": 34567`
// — colon, SPACE, digits. (The skill no longer reads the pid at all: the
// browse CLI owns daemon liveness and replacement.)
//
// So this test does not match strings; it RUNS the snippets the skill tells
// the agent to run, against a state file written exactly the way the server
@@ -38,7 +32,7 @@ function writeStateFile(dir: string, pid: number, port: number): string {
}
/** Pull the grep pipeline for `field` out of the skill prose and run it. */
function extractViaSkill(source: string, field: 'pid' | 'port', stateFile: string): string {
function extractViaSkill(source: string, field: 'port', stateFile: string): string {
const line = source
.split('\n')
.find((l) => l.includes(`grep -o '"${field}":`));
@@ -54,12 +48,11 @@ function extractViaSkill(source: string, field: 'pid' | 'port', stateFile: strin
describe('/open-gstack-browser state-file extraction', () => {
for (const [label, file] of [['generated', SKILL], ['template', TMPL]] as const) {
test(`${label}: pid and port survive the pretty-printed state file`, () => {
test(`${label}: the port survives the pretty-printed state file`, () => {
const source = fs.readFileSync(file, 'utf-8');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-state-'));
try {
const stateFile = writeStateFile(dir, 12060, 34567);
expect(extractViaSkill(source, 'pid', stateFile)).toBe('12060');
expect(extractViaSkill(source, 'port', stateFile)).toBe('34567');
} finally {
fs.rmSync(dir, { recursive: true, force: true });