mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 06:54:50 +01:00
Merge remote-tracking branch 'origin/garrytan/opus55-wip-B' into garrytan/opus-5-5-prompt-audit
This commit is contained in:
commit
522e300d4e
21 files changed
+1352
-259
No files matched your search
+22
-3
@@ -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
@@ -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
@@ -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"));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user