diff --git a/bin/gstack-brain-context-load.ts b/bin/gstack-brain-context-load.ts index 8ad4eb63c..9a8efe998 100644 --- a/bin/gstack-brain-context-load.ts +++ b/bin/gstack-brain-context-load.ts @@ -34,9 +34,9 @@ * 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 { existsSync, readFileSync, statSync, readdirSync, accessSync, constants } from "fs"; +import { join, dirname, basename, resolve, delimiter } from "path"; +import { spawnSync } from "child_process"; import { homedir } from "os"; import { parseSkillManifest, type GbrainManifest, type GbrainManifestQuery, withErrorContext } from "../lib/gstack-memory-helpers"; @@ -68,7 +68,9 @@ interface QueryResult { const HOME = homedir(); const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack"); -const MCP_TIMEOUT_MS = 500; +// 500ms hard cap per Section 1C; overridable for slow/loaded environments +// (test harnesses under CI load, cold CLI starts). +const MCP_TIMEOUT_MS = Math.max(1, parseInt(process.env.GSTACK_BRAIN_TIMEOUT_MS || "", 10) || 500); const PAGE_SIZE_CAP = 10 * 1024; // 10KB per query result before truncation // ── CLI ──────────────────────────────────────────────────────────────────── @@ -190,16 +192,28 @@ function resolveSkillFile(args: CliArgs): string | null { // ── Dispatchers ──────────────────────────────────────────────────────────── +let gbrainOnPath: boolean | null = null; + function gbrainAvailable(): boolean { - try { - execFileSync("gbrain", ["--version"], { - stdio: "ignore", - timeout: MCP_TIMEOUT_MS, - }); - return true; - } catch { - return false; - } + // Stat-based PATH scan, memoized. Spawning `gbrain --version` under the + // 500ms budget misreported gbrain as missing whenever a cold process spawn + // exceeded the timeout (loaded machine, node-based CLI cold start), and + // re-probing per query burned 3x the budget before any real work. + if (gbrainOnPath !== null) return gbrainOnPath; + const exts = process.platform === "win32" + ? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";") + : [""]; + gbrainOnPath = (process.env.PATH || "").split(delimiter).some((dir) => + dir !== "" && exts.some((ext) => { + try { + accessSync(join(dir, `gbrain${ext}`), constants.X_OK); + return true; + } catch { + return false; + } + }) + ); + return gbrainOnPath; } function dispatchVector(q: GbrainManifestQuery, args: CliArgs): QueryResult { diff --git a/test/gstack-brain-context-load.test.ts b/test/gstack-brain-context-load.test.ts index 61985f0fc..e452e8d4f 100644 --- a/test/gstack-brain-context-load.test.ts +++ b/test/gstack-brain-context-load.test.ts @@ -55,7 +55,12 @@ fi function prependPath(binDir: string): Record { const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "PATH"; const currentPath = process.env[pathKey] || ""; - return { [pathKey]: `${binDir}${delimiter}${currentPath}` }; + return { + [pathKey]: `${binDir}${delimiter}${currentPath}`, + // Cold process spawns on a loaded machine can exceed the 500ms default + // budget; the fake gbrain is instant once spawned, so give it headroom. + GSTACK_BRAIN_TIMEOUT_MS: "10000", + }; } describe("gstack-brain-context-load CLI", () => { @@ -252,6 +257,44 @@ describe("gstack-brain-context-load — graceful gbrain absence", () => { } }); + it("manifest filter: blocks reach gbrain as --filter args with template vars resolved (#1687)", () => { + const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-")); + const binDir = join(dir, "bin"); + mkdirSync(binDir); + writeFakeGbrain(binDir); + const skillFile = join(dir, "SKILL.md"); + writeFileSync( + skillFile, + `--- +name: x +gbrain: + schema: 1 + context_queries: + - id: prior-sessions + kind: list + filter: + type: ceo-plan + tags_contains: "repo:{repo_slug}" + sort: updated_at_desc + limit: 5 + render_as: "## Prior sessions" +--- +`, + "utf-8" + ); + + try { + const r = runScript(["--skill-file", skillFile, "--repo", "my-test-repo"], prependPath(binDir)); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("fake gbrain list_pages"); + expect(r.stdout).toContain("--filter type=ceo-plan"); + expect(r.stdout).toContain("--filter tags_contains=repo:my-test-repo"); + expect(r.stdout).toContain("--sort updated_at_desc"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + 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