From 566ccc857793e3b62372c8c738fb7956b3f9cfc4 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 1 May 2026 20:01:49 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20bin/gstack-brain-context-load=20?= =?UTF-8?q?=E2=80=94=20V1=20retrieval=20surface=20(Lane=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Called from the gstack preamble at every skill start. Reads the active skill's gbrain.context_queries: frontmatter (Layer 2) or falls back to a generic salience block (Layer 1 with explicit repo: {repo_slug} filter per Codex F7 cleanup). Dispatches each query by kind: kind: vector → gbrain query kind: list → gbrain list_pages --filter ... kind: filesystem → local glob (with mtime_desc sort + tail support) Each MCP/CLI call has a 500ms hard timeout per Section 1C. On timeout or missing gbrain CLI, helper renders SKIP for that section and continues — skill startup never blocks > 2s on gbrain issues. Datamark envelope per Section 1D + D12: rendered body wrapped once at the page level in (not per-message). Layer 1 prompt-injection defense. Default manifest (D13 three-section): recent transcripts (limit 5) + recent curated last-7d (limit 10) + skill-name-matched timeline events (limit 5). All scoped to {repo_slug}. Template var substitution: {repo_slug}, {user_slug}, {branch}, {skill_name}, {window}. Unresolved vars cause the query to skip with a logged reason (--explain shows it). 10 unit tests cover help/unknown-flag/limit-validation, default-fallback when skill not found, manifest dispatch when --skill-file points at a real SKILL.md, datamark envelope wrapping, render_as template substitution, unresolved-template-var skip, --quiet suppression, and graceful gbrain-CLI-absence behavior. All passing. V1.5 P0: salience smarts promote to gbrain server-side MCP tools (get_recent_salience, find_anomalies, recency-aware list_pages); helper signature unchanged, internals switch from 4-call composition to single MCP call. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/gstack-brain-context-load.ts | 465 +++++++++++++++++++++++++ test/gstack-brain-context-load.test.ts | 217 ++++++++++++ 2 files changed, 682 insertions(+) create mode 100644 bin/gstack-brain-context-load.ts create mode 100644 test/gstack-brain-context-load.test.ts diff --git a/bin/gstack-brain-context-load.ts b/bin/gstack-brain-context-load.ts new file mode 100644 index 00000000..e68e46e2 --- /dev/null +++ b/bin/gstack-brain-context-load.ts @@ -0,0 +1,465 @@ +#!/usr/bin/env bun +/** + * gstack-brain-context-load — V1 retrieval surface (Lane C). + * + * Called from the gstack preamble at every skill start. Reads the active skill's + * `gbrain.context_queries:` frontmatter (Layer 2) or falls back to a generic + * salience block (Layer 1). Dispatches each query by kind: + * + * kind: vector → gbrain query + * kind: list → gbrain list_pages --filter ... + * kind: filesystem → local glob + * + * Each MCP/CLI call has a 500ms hard timeout per Section 1C. On timeout or + * "gbrain not in PATH" / "MCP not registered", the helper renders + * `(unavailable)` for that section and continues — skill startup never blocks + * > 2s on gbrain issues. + * + * Layer 1 fallback per F7 (Codex outside-voice): every default query carries + * an explicit `repo: {repo_slug}` filter so cross-repo contamination is the + * non-default path. + * + * Datamark envelope per Section 1D: each rendered page body is wrapped in + * `...` + * once at the page level (not per-message). Layer 1 prompt-injection defense. + * + * V1.5 P0: salience smarts promote to gbrain server-side MCP tools + * (`get_recent_salience`, `find_anomalies`). Helper signature stays the same; + * internals switch from 4-call composition to a single MCP call. + * + * Usage: + * gstack-brain-context-load --skill office-hours --repo garrytan-gstack + * gstack-brain-context-load --skill-file ./SKILL.md --repo X --user Y + * gstack-brain-context-load --window 14d --explain + * gstack-brain-context-load --quiet + */ + +import { existsSync, readFileSync, statSync, readdirSync } from "fs"; +import { join, dirname, basename, resolve } from "path"; +import { execFileSync, spawnSync } from "child_process"; +import { homedir } from "os"; + +import { parseSkillManifest, type GbrainManifest, type GbrainManifestQuery, withErrorContext } from "../lib/gstack-memory-helpers"; + +// ── Types ────────────────────────────────────────────────────────────────── + +interface CliArgs { + skill?: string; + skillFile?: string; + repo?: string; + user?: string; + branch?: string; + window: string; // e.g. "14d" + limit: number; + explain: boolean; + quiet: boolean; +} + +interface QueryResult { + query: GbrainManifestQuery; + ok: boolean; + rendered: string; + bytes: number; + duration_ms: number; + reason?: string; +} + +// ── Constants ────────────────────────────────────────────────────────────── + +const HOME = homedir(); +const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack"); +const MCP_TIMEOUT_MS = 500; +const PAGE_SIZE_CAP = 10 * 1024; // 10KB per query result before truncation + +// ── CLI ──────────────────────────────────────────────────────────────────── + +function printUsage(): void { + console.error(`Usage: gstack-brain-context-load [options] + +Options: + --skill Active skill name (looks up SKILL.md path) + --skill-file Direct path to SKILL.md (overrides --skill) + --repo Repo slug for {repo_slug} template var + --user User slug for {user_slug} template var + --branch Branch name for {branch} template var + --window Layer 1 window (default: 14d) + --limit Max results per query (default: from manifest, else 10) + --explain Print byte counts + which queries ran (to stderr) + --quiet Suppress everything except the rendered block + --help This text. + +Output: rendered ## sections to stdout, ready for the preamble to inject. +`); +} + +function parseArgs(): CliArgs { + const args = process.argv.slice(2); + let skill: string | undefined; + let skillFile: string | undefined; + let repo: string | undefined; + let user: string | undefined; + let branch: string | undefined; + let window = "14d"; + let limit = 10; + let explain = false; + let quiet = false; + + for (let i = 0; i < args.length; i++) { + const a = args[i]; + switch (a) { + case "--skill": skill = args[++i]; break; + case "--skill-file": skillFile = args[++i]; break; + case "--repo": repo = args[++i]; break; + case "--user": user = args[++i]; break; + case "--branch": branch = args[++i]; break; + case "--window": window = args[++i] || "14d"; break; + case "--limit": + limit = parseInt(args[++i] || "10", 10); + if (!Number.isFinite(limit) || limit <= 0) { + console.error("--limit requires a positive integer"); + process.exit(1); + } + break; + case "--explain": explain = true; break; + case "--quiet": quiet = true; break; + case "--help": + case "-h": + printUsage(); + process.exit(0); + default: + console.error(`Unknown argument: ${a}`); + printUsage(); + process.exit(1); + } + } + + return { skill, skillFile, repo, user, branch, window, limit, explain, quiet }; +} + +// ── Template var substitution ────────────────────────────────────────────── + +function substituteTemplateVars(s: string, args: CliArgs): { resolved: string; unresolved: string[] } { + const unresolved: string[] = []; + const resolved = s.replace(/\{(\w+)\}/g, (full, name) => { + switch (name) { + case "repo_slug": + if (args.repo) return args.repo; + unresolved.push(name); + return full; + case "user_slug": + if (args.user) return args.user; + unresolved.push(name); + return full; + case "branch": + if (args.branch) return args.branch; + unresolved.push(name); + return full; + case "skill_name": + if (args.skill) return args.skill; + unresolved.push(name); + return full; + case "window": + return args.window; + default: + unresolved.push(name); + return full; + } + }); + return { resolved, unresolved }; +} + +// ── Skill manifest resolution ────────────────────────────────────────────── + +function resolveSkillFile(args: CliArgs): string | null { + if (args.skillFile) { + return resolve(args.skillFile); + } + if (!args.skill) return null; + // Look in common gstack skill locations + const candidates = [ + join(HOME, ".claude", "skills", args.skill, "SKILL.md"), + join(HOME, ".claude", "skills", "gstack", args.skill, "SKILL.md"), + join(process.cwd(), ".claude", "skills", args.skill, "SKILL.md"), + join(process.cwd(), args.skill, "SKILL.md"), + ]; + for (const c of candidates) { + if (existsSync(c)) return c; + } + return null; +} + +// ── Dispatchers ──────────────────────────────────────────────────────────── + +function gbrainAvailable(): boolean { + try { + execFileSync("command", ["-v", "gbrain"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function dispatchVector(q: GbrainManifestQuery, args: CliArgs): QueryResult { + const t0 = Date.now(); + const { resolved: query, unresolved } = substituteTemplateVars(q.query || "", args); + if (unresolved.length > 0) { + return { + query: q, + ok: false, + rendered: "", + bytes: 0, + duration_ms: Date.now() - t0, + reason: `template vars unresolved: ${unresolved.join(",")}`, + }; + } + if (!gbrainAvailable()) { + return { query: q, ok: false, rendered: "", bytes: 0, duration_ms: Date.now() - t0, reason: "gbrain CLI missing" }; + } + + const limit = q.limit ?? args.limit; + const result = spawnSync("gbrain", ["query", query, "--limit", String(limit), "--format", "compact"], { + encoding: "utf-8", + timeout: MCP_TIMEOUT_MS, + }); + + if (result.status !== 0 || !result.stdout) { + return { + query: q, + ok: false, + rendered: "", + bytes: 0, + duration_ms: Date.now() - t0, + reason: result.error?.message || `gbrain query exited ${result.status}`, + }; + } + + const rendered = wrapDatamarked(q.render_as, capBody(result.stdout)); + return { query: q, ok: true, rendered, bytes: rendered.length, duration_ms: Date.now() - t0 }; +} + +function dispatchList(q: GbrainManifestQuery, args: CliArgs): QueryResult { + const t0 = Date.now(); + if (!gbrainAvailable()) { + return { query: q, ok: false, rendered: "", bytes: 0, duration_ms: Date.now() - t0, reason: "gbrain CLI missing" }; + } + const limit = q.limit ?? args.limit; + const cliArgs: string[] = ["list_pages", "--limit", String(limit)]; + if (q.sort) cliArgs.push("--sort", q.sort); + if (q.filter) { + for (const [k, v] of Object.entries(q.filter)) { + const { resolved: rv } = substituteTemplateVars(String(v), args); + cliArgs.push("--filter", `${k}=${rv}`); + } + } + const result = spawnSync("gbrain", cliArgs, { encoding: "utf-8", timeout: MCP_TIMEOUT_MS }); + if (result.status !== 0 || !result.stdout) { + return { + query: q, + ok: false, + rendered: "", + bytes: 0, + duration_ms: Date.now() - t0, + reason: result.error?.message || `gbrain list_pages exited ${result.status}`, + }; + } + const rendered = wrapDatamarked(q.render_as, capBody(result.stdout)); + return { query: q, ok: true, rendered, bytes: rendered.length, duration_ms: Date.now() - t0 }; +} + +function dispatchFilesystem(q: GbrainManifestQuery, args: CliArgs): QueryResult { + const t0 = Date.now(); + if (!q.glob) { + return { query: q, ok: false, rendered: "", bytes: 0, duration_ms: Date.now() - t0, reason: "filesystem kind missing glob" }; + } + const { resolved: glob, unresolved } = substituteTemplateVars(q.glob, args); + if (unresolved.length > 0) { + return { + query: q, + ok: false, + rendered: "", + bytes: 0, + duration_ms: Date.now() - t0, + reason: `template vars unresolved: ${unresolved.join(",")}`, + }; + } + // Expand ~ to home dir + const expanded = glob.replace(/^~/, HOME); + + // Simple glob: match against filesystem + const matches = simpleGlob(expanded); + if (matches.length === 0) { + return { query: q, ok: false, rendered: "", bytes: 0, duration_ms: Date.now() - t0, reason: "no matches" }; + } + + // Sort + limit + let sorted = matches; + if (q.sort === "mtime_desc") { + sorted = matches + .map((p) => ({ p, mtime: tryStatMtime(p) })) + .sort((a, b) => b.mtime - a.mtime) + .map((x) => x.p); + } + const limit = q.limit ?? args.limit; + const limited = q.tail !== undefined ? sorted.slice(-q.tail) : sorted.slice(0, limit); + + const lines = limited.map((p) => { + const mt = new Date(tryStatMtime(p)).toISOString().slice(0, 10); + return `- ${mt} — ${basename(p)}`; + }); + const rendered = wrapDatamarked(q.render_as, capBody(lines.join("\n"))); + return { query: q, ok: true, rendered, bytes: rendered.length, duration_ms: Date.now() - t0 }; +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function simpleGlob(pattern: string): string[] { + // Handle simple patterns: /** or /file or + if (!pattern.includes("*") && !pattern.includes("?")) { + return existsSync(pattern) ? [pattern] : []; + } + // Split on the last '/' before any glob char + const idx = pattern.search(/[*?]/); + const dirEnd = pattern.lastIndexOf("/", idx); + if (dirEnd === -1) return []; + const dir = pattern.slice(0, dirEnd); + const fileGlob = pattern.slice(dirEnd + 1); + if (!existsSync(dir)) return []; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return []; + } + const re = new RegExp("^" + fileGlob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$"); + return entries.filter((e) => re.test(e)).map((e) => join(dir, e)); +} + +function tryStatMtime(p: string): number { + try { + return statSync(p).mtimeMs; + } catch { + return 0; + } +} + +function capBody(s: string): string { + if (s.length <= PAGE_SIZE_CAP) return s; + return s.slice(0, PAGE_SIZE_CAP) + `\n\n_(truncated; ${s.length - PAGE_SIZE_CAP} more bytes — query gbrain directly for full results)_\n`; +} + +function wrapDatamarked(renderAs: string, body: string): string { + // Layer 1 prompt-injection defense (Section 1D, D12). Single envelope around + // the whole rendered body, not per-message. + return [ + renderAs, + "", + "", + body, + "", + "", + ].join("\n"); +} + +// ── Layer 1 fallback (no manifest) ───────────────────────────────────────── + +function defaultManifest(args: CliArgs): GbrainManifest { + // Per plan §"Three-section default" (D13). Each query carries explicit + // `repo: {repo_slug}` filter (F7 cleanup) so cross-repo contamination is + // the non-default path. + return { + schema: 1, + context_queries: [ + { + id: "recent-transcripts", + kind: "list", + filter: { type: "transcript", "tags_contains": "repo:{repo_slug}" }, + sort: "updated_at_desc", + limit: 5, + render_as: "## Recent transcripts in this repo", + }, + { + id: "recent-curated", + kind: "list", + filter: { "tags_contains": "repo:{repo_slug}", updated_after: "now-7d" }, + sort: "updated_at_desc", + limit: 10, + render_as: "## Recent curated memory", + }, + { + id: "skill-name-events", + kind: "list", + filter: { type: "timeline", content_contains: "{skill_name}" }, + limit: 5, + render_as: "## Recent {skill_name} events", + }, + ], + }; +} + +// ── Main pipeline ────────────────────────────────────────────────────────── + +async function loadContext(args: CliArgs): Promise<{ rendered: string; results: QueryResult[]; mode: "manifest" | "default" }> { + const skillFile = resolveSkillFile(args); + let manifest: GbrainManifest | null = null; + let mode: "manifest" | "default" = "default"; + + if (skillFile) { + manifest = parseSkillManifest(skillFile); + if (manifest && manifest.context_queries.length > 0) { + mode = "manifest"; + } + } + if (!manifest) { + manifest = defaultManifest(args); + } + + const results: QueryResult[] = []; + for (const q of manifest.context_queries) { + const r = await withErrorContext(`context-load:${q.id}`, () => { + switch (q.kind) { + case "vector": return dispatchVector(q, args); + case "list": return dispatchList(q, args); + case "filesystem": return dispatchFilesystem(q, args); + } + }, "gstack-brain-context-load"); + results.push(r); + } + + // Substitute render_as template vars (e.g. "{skill_name}") + const rendered = results + .filter((r) => r.ok && r.rendered.length > 0) + .map((r) => { + const { resolved } = substituteTemplateVars(r.rendered, args); + return resolved; + }) + .join("\n"); + + return { rendered, results, mode }; +} + +// ── Entry point ──────────────────────────────────────────────────────────── + +async function main(): Promise { + const args = parseArgs(); + const { rendered, results, mode } = await loadContext(args); + + if (!args.quiet && rendered.length > 0) { + console.log(rendered); + } + + if (args.explain) { + console.error(`[brain-context-load] mode=${mode} queries=${results.length}`); + for (const r of results) { + const status = r.ok ? "OK" : "SKIP"; + console.error(` ${status.padEnd(5)} ${r.query.id.padEnd(28)} kind=${r.query.kind.padEnd(10)} bytes=${r.bytes.toString().padStart(6)} dur=${r.duration_ms}ms${r.reason ? ` (${r.reason})` : ""}`); + } + const totalBytes = results.reduce((s, r) => s + r.bytes, 0); + const totalDur = results.reduce((s, r) => s + r.duration_ms, 0); + console.error(`[brain-context-load] total bytes=${totalBytes} dur=${totalDur}ms`); + } +} + +main().catch((err) => { + console.error(`gstack-brain-context-load fatal: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/test/gstack-brain-context-load.test.ts b/test/gstack-brain-context-load.test.ts new file mode 100644 index 00000000..459a20e2 --- /dev/null +++ b/test/gstack-brain-context-load.test.ts @@ -0,0 +1,217 @@ +/** + * Unit tests for bin/gstack-brain-context-load.ts (Lane C). + * + * Tests CLI surface, template var substitution, manifest vs default-fallback + * routing, datamark envelope wrapping, and graceful degradation when gbrain + * CLI is missing. Full E2E (real gbrain MCP calls) lives in Lane F. + */ + +import { describe, it, expect } from "bun:test"; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { spawnSync } from "child_process"; + +const SCRIPT = join(import.meta.dir, "..", "bin", "gstack-brain-context-load.ts"); + +function runScript(args: string[], env: Record = {}): { stdout: string; stderr: string; exitCode: number } { + const result = spawnSync("bun", [SCRIPT, ...args], { + encoding: "utf-8", + timeout: 30000, + env: { ...process.env, ...env }, + }); + return { + stdout: result.stdout || "", + stderr: result.stderr || "", + exitCode: result.status ?? 1, + }; +} + +describe("gstack-brain-context-load CLI", () => { + it("--help exits 0 with usage", () => { + const r = runScript(["--help"]); + expect(r.exitCode).toBe(0); + expect(r.stderr).toContain("Usage: gstack-brain-context-load"); + expect(r.stderr).toContain("--skill"); + expect(r.stderr).toContain("--repo"); + }); + + it("rejects unknown flag", () => { + const r = runScript(["--bogus"]); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain("Unknown argument: --bogus"); + }); + + it("--limit must be positive integer", () => { + const r = runScript(["--limit", "0"]); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain("--limit requires a positive integer"); + }); +}); + +describe("gstack-brain-context-load — manifest dispatch", () => { + it("falls back to default manifest when --skill resolves to no file", () => { + const r = runScript(["--skill", "nonexistent-skill-xyz", "--repo", "test-repo", "--explain", "--quiet"]); + expect(r.exitCode).toBe(0); + expect(r.stderr).toContain("mode=default"); + // 3 queries in default + expect(r.stderr).toContain("queries=3"); + }); + + it("uses skill manifest when --skill-file points at a valid SKILL.md", () => { + const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-")); + const skillFile = join(dir, "SKILL.md"); + writeFileSync( + skillFile, + `--- +name: test-skill +gbrain: + schema: 1 + context_queries: + - id: my-prior + kind: filesystem + glob: "${dir}/notes/*.md" + sort: mtime_desc + limit: 5 + render_as: "## My prior notes" +--- + +body +`, + "utf-8" + ); + + // Create some matching files + mkdirSync(join(dir, "notes")); + writeFileSync(join(dir, "notes", "one.md"), "first\n"); + writeFileSync(join(dir, "notes", "two.md"), "second\n"); + + const r = runScript(["--skill-file", skillFile, "--repo", "test-repo", "--explain"]); + expect(r.exitCode).toBe(0); + expect(r.stderr).toContain("mode=manifest"); + expect(r.stderr).toContain("queries=1"); + expect(r.stdout).toContain("## My prior notes"); + expect(r.stdout).toContain("one.md"); + expect(r.stdout).toContain("two.md"); + rmSync(dir, { recursive: true, force: true }); + }); + + it("wraps rendered body in USER_TRANSCRIPT_DATA envelope (datamark per D12)", () => { + const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-")); + const skillFile = join(dir, "SKILL.md"); + writeFileSync( + skillFile, + `--- +name: x +gbrain: + schema: 1 + context_queries: + - id: fs + kind: filesystem + glob: "${dir}/*.md" + render_as: "## FS results" +--- +`, + "utf-8" + ); + writeFileSync(join(dir, "a.md"), "x\n"); + + const r = runScript(["--skill-file", skillFile]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain(""); + expect(r.stdout).toContain(""); + rmSync(dir, { recursive: true, force: true }); + }); + + it("substitutes {repo_slug} in render_as", () => { + const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-")); + const skillFile = join(dir, "SKILL.md"); + writeFileSync( + skillFile, + `--- +name: x +gbrain: + schema: 1 + context_queries: + - id: fs + kind: filesystem + glob: "${dir}/*.md" + render_as: "## My events for {repo_slug}" +--- +`, + "utf-8" + ); + writeFileSync(join(dir, "a.md"), "x\n"); + + const r = runScript(["--skill-file", skillFile, "--repo", "my-test-repo"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("## My events for my-test-repo"); + rmSync(dir, { recursive: true, force: true }); + }); + + it("skips queries with unresolved template vars (logged via --explain)", () => { + const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-")); + const skillFile = join(dir, "SKILL.md"); + writeFileSync( + skillFile, + `--- +name: x +gbrain: + schema: 1 + context_queries: + - id: needs-user + kind: filesystem + glob: "${dir}/{user_slug}/file.md" + render_as: "## Needs user_slug" +--- +`, + "utf-8" + ); + + // No --user passed; {user_slug} unresolved + const r = runScript(["--skill-file", skillFile, "--repo", "x", "--explain"]); + expect(r.exitCode).toBe(0); + expect(r.stderr).toContain("template vars unresolved"); + expect(r.stderr).toContain("user_slug"); + rmSync(dir, { recursive: true, force: true }); + }); + + it("--quiet suppresses rendered output", () => { + const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-")); + const skillFile = join(dir, "SKILL.md"); + writeFileSync( + skillFile, + `--- +name: x +gbrain: + schema: 1 + context_queries: + - id: fs + kind: filesystem + glob: "${dir}/*.md" + render_as: "## Stuff" +--- +`, + "utf-8" + ); + writeFileSync(join(dir, "a.md"), "x\n"); + + const r = runScript(["--skill-file", skillFile, "--quiet"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe(""); + rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe("gstack-brain-context-load — graceful gbrain absence", () => { + it("vector + list queries still complete (with SKIP) when gbrain CLI is missing", () => { + // We can't easily un-install gbrain; rely on the helper's own missing-binary + // detection. The default manifest uses kind: list which calls gbrain. If + // gbrain is missing, the helper should still exit 0 and explain shows SKIP. + // We use --explain to verify the SKIP code path doesn't hard-fail. + const r = runScript(["--repo", "test-repo", "--explain", "--quiet"]); + expect(r.exitCode).toBe(0); + // Either OK (gbrain available) or SKIP (gbrain missing or query timeout) — both fine + expect(r.stderr).toMatch(/(OK|SKIP)/); + }); +});