diff --git a/CLAUDE.md b/CLAUDE.md index e334396f3..08b66d820 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -913,6 +913,8 @@ enhancement layered on top, never a dependency.) - **Resurface** active decisions before re-deciding: `bin/gstack-decision-search` (`--recent N`, `--scope repo|branch|issue`, `--query KW`, `--all`, `--json`). + Add `--semantic` (with `--query`) to append related hits from gbrain memory when + it's up; it degrades silently to the reliable file results when gbrain is off. Session start already surfaces scope-relevant active decisions via Context Recovery. If a decision is listed, treat it as settled with its rationale; if you're about to reverse it, say so explicitly. diff --git a/bin/gstack-decision-search b/bin/gstack-decision-search index 04406cd23..382160c52 100755 --- a/bin/gstack-decision-search +++ b/bin/gstack-decision-search @@ -5,11 +5,17 @@ * Usage: * gstack-decision-search [--query KW] [--scope repo|branch|issue] * [--branch B] [--issue I] [--recent N] [--all] [--json] + * [--semantic] * * Reads the BOUNDED active snapshot (decisions.active.json) — O(active), not a full * history scan — and rebuilds it from the event log if missing. Scope-filtered to the * current branch/issue context (recency != relevance). NON-INTERACTIVE. `--all` shows * superseded decisions too (from the full log). Exit 0 silently when there are none. + * + * `--semantic` (with `--query`) appends an OPTIONAL "related from memory" block from + * gbrain semantic recall. It is a pure enhancement: when gbrain is off/unconfigured/ + * empty it degrades silently to the reliable file results above. The reliable path + * never loads gbrain code (the semantic module is imported lazily only here). */ import { existsSync } from "fs"; @@ -43,13 +49,15 @@ function flagValue(name: string): string | undefined { const slug = resolveSlug(); const paths = decisionPaths(slug); -const query = flagValue("--query")?.toLowerCase(); +const queryRaw = flagValue("--query"); +const query = queryRaw?.toLowerCase(); const scope = flagValue("--scope"); const branch = flagValue("--branch") ?? gitBranch(); const issue = flagValue("--issue"); const recent = flagValue("--recent") ? parseInt(flagValue("--recent") as string, 10) : undefined; const showAll = args.includes("--all"); const asJson = args.includes("--json"); +const semantic = args.includes("--semantic"); let rows: ActiveDecision[]; if (showAll) { @@ -75,13 +83,28 @@ rows.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); // newest if (recent && recent > 0) rows = rows.slice(0, recent); if (asJson) { + // --json stays reliable-only (semantic recall is a human-facing supplement). console.log(JSON.stringify(rows)); process.exit(0); } -if (!rows.length) process.exit(0); // silent when nothing relevant for (const d of rows) { const scopeTag = d.scope === "repo" ? "" : ` [${d.scope}${d.branch ? `:${d.branch}` : ""}${d.issue ? `:${d.issue}` : ""}]`; console.log(`- ${d.decision}${scopeTag} (${d.source}, ${d.date.slice(0, 10)})`); if (d.rationale) console.log(` why: ${d.rationale}`); } + +// OPTIONAL gbrain enhancement. Lazy import so the reliable path above never loads +// gbrain code. Degrades silently: null (gbrain off) or [] (nothing found) leaves the +// reliable results above as the answer. +if (semantic && queryRaw) { + const { semanticRecall } = await import("../lib/gstack-decision-semantic"); + const hits = semanticRecall(queryRaw); + if (hits && hits.length) { + console.log("\nRelated from memory (gbrain semantic recall):"); + for (const h of hits) { + const snip = h.snippet.length > 100 ? `${h.snippet.slice(0, 100)}…` : h.snippet; + console.log(` [${h.score.toFixed(2)}] ${h.slug}: ${snip}`); + } + } +} diff --git a/lib/gstack-decision-semantic.ts b/lib/gstack-decision-semantic.ts new file mode 100644 index 000000000..22ab94056 --- /dev/null +++ b/lib/gstack-decision-semantic.ts @@ -0,0 +1,91 @@ +/** + * gstack-decision-semantic — OPTIONAL gbrain enhancement for decision resurfacing. + * + * This is the ONLY decision module that touches gbrain. The reliable core + * (lib/gstack-decision.ts) has zero gbrain imports and works with gbrain OFF; this + * module is loaded lazily by `gstack-decision-search` only on `--semantic`, and every + * path degrades to `null` (caller shows the reliable file results) when gbrain is + * absent, unconfigured, times out, or returns nothing. It NEVER throws and NEVER + * hangs (10s spawn timeout). We do not wire core function to this — gbrain is an + * enhancement, never a dependency (the code-search lesson). + * + * Surface reality (verified against gbrain 0.42.x, not guessed): + * - `gbrain search ""` prints TEXT lines `[score] slug -- snippet`, NOT JSON + * (so we parse the text surface; execGbrainJson would always null here). + * - The curated-memory source is the one whose local_path is the gstack brain + * worktree (`~/.gstack-brain-worktree`), id `default` by convention — NOT a + * `gstack-brain-` id. Scoping search to it keeps code/doc corpora out. + */ + +import { spawnGbrain } from "./gbrain-exec"; +import { parseSourcesList } from "./gbrain-sources"; + +const TIMEOUT_MS = 10_000; +const BRAIN_WORKTREE_SUFFIX = ".gstack-brain-worktree"; + +export interface SemanticHit { + score: number; + slug: string; + snippet: string; +} + +/** + * Resolve the curated-memory source id (the gstack brain worktree). Returns null + * when gbrain is down/unparseable OR no worktree-backed source is registered — the + * caller then searches unscoped (best-effort) rather than failing. + */ +export function resolveMemorySourceId(env?: NodeJS.ProcessEnv): string | null { + const r = spawnGbrain(["sources", "list", "--json"], { baseEnv: env, timeout: TIMEOUT_MS }); + if (r.status !== 0) return null; + let rows; + try { + rows = parseSourcesList(JSON.parse(r.stdout || "null")); + } catch { + return null; + } + const atWorktree = rows.filter( + (s) => typeof s.local_path === "string" && s.local_path.endsWith(BRAIN_WORKTREE_SUFFIX), + ); + const pick = atWorktree.find((s) => s.id === "default") ?? atWorktree[0]; + return pick?.id ?? null; +} + +/** + * Parse gbrain search's text output into scored hits. Lines look like: + * `[0.4361] slug -- snippet text...` + * Non-matching lines (banners, blanks) are skipped. Exported for deterministic + * unit testing of the parser without a live gbrain. + */ +export function parseSearchHits(stdout: string, minScore: number, limit: number): SemanticHit[] { + const hits: SemanticHit[] = []; + for (const line of stdout.split("\n")) { + const m = line.match(/^\[([\d.]+)\]\s+(\S+)\s+--\s+(.*)$/); + if (!m) continue; + const score = parseFloat(m[1]); + if (!Number.isFinite(score) || score < minScore) continue; + hits.push({ score, slug: m[2], snippet: m[3].trim() }); + } + return hits.slice(0, limit); +} + +/** + * Semantic recall over the curated-memory source. Returns parsed hits, or `null` + * when gbrain is unavailable / errors (caller MUST degrade to the reliable file + * results on null). An empty array means gbrain ran but found nothing relevant + * (e.g. memory not synced yet) — also honest, distinct from null. Never throws, + * never hangs. + */ +export function semanticRecall( + query: string, + env?: NodeJS.ProcessEnv, + minScore = 0.1, + limit = 3, +): SemanticHit[] | null { + if (!query.trim()) return null; + const sourceId = resolveMemorySourceId(env); + const args = ["search", query]; + if (sourceId) args.push("--source", sourceId); + const r = spawnGbrain(args, { baseEnv: env, timeout: TIMEOUT_MS }); + if (r.status !== 0) return null; // gbrain down / not on PATH / errored → degrade + return parseSearchHits(r.stdout || "", minScore, limit); +} diff --git a/test/gstack-decision-bins.test.ts b/test/gstack-decision-bins.test.ts index 8992c76ae..ca33736e9 100644 --- a/test/gstack-decision-bins.test.ts +++ b/test/gstack-decision-bins.test.ts @@ -102,3 +102,60 @@ describe("gstack-decision-search", () => { expect(search()).toBe(""); }); }); + +describe("gstack-decision-search --semantic (optional gbrain enhancement)", () => { + function shimDir(gbrainBody: string): string { + const d = fs.mkdtempSync(path.join(os.tmpdir(), "gbrain-shim-")); + const p = path.join(d, "gbrain"); + fs.writeFileSync(p, gbrainBody, { mode: 0o755 }); + fs.chmodSync(p, 0o755); + return d; + } + function searchWithPath(args: string, pathPrefix?: string): string { + const env = { ...process.env, GSTACK_HOME: tmpDir } as NodeJS.ProcessEnv; + if (pathPrefix) env.PATH = `${pathPrefix}:${process.env.PATH}`; + try { + return execSync(`${SEARCH} ${args}`, { cwd: ROOT, env, encoding: "utf-8", timeout: 20000 }).trim(); + } catch { + return ""; + } + } + + test("--semantic without --query behaves like a normal search (no gbrain spawn)", () => { + log('{"decision":"reliable-alpha","scope":"repo","source":"user"}'); + const out = searchWithPath("--semantic"); + expect(out).toContain("reliable-alpha"); + expect(out).not.toContain("Related from memory"); + }); + + test("--semantic --query appends a related-memory block when gbrain returns hits", () => { + log('{"decision":"reliable-alpha","scope":"repo","source":"user"}'); + const dir = shimDir( + `#!/usr/bin/env bash +if [ "$1" = "sources" ]; then echo '{"sources":[{"id":"default","local_path":"/u/.gstack-brain-worktree"}]}'; exit 0; fi +if [ "$1" = "search" ]; then echo "[0.88] decisions/related -- a semantically related past call"; exit 0; fi +exit 1 +`, + ); + try { + const out = searchWithPath("--query alpha --semantic", dir); + expect(out).toContain("reliable-alpha"); // reliable results still shown + expect(out).toContain("Related from memory"); + expect(out).toContain("decisions/related"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test("--semantic degrades silently when gbrain errors (reliable results stand)", () => { + log('{"decision":"reliable-alpha","scope":"repo","source":"user"}'); + const dir = shimDir(`#!/usr/bin/env bash\nexit 1\n`); + try { + const out = searchWithPath("--query alpha --semantic", dir); + expect(out).toContain("reliable-alpha"); + expect(out).not.toContain("Related from memory"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/gstack-decision-semantic.test.ts b/test/gstack-decision-semantic.test.ts new file mode 100644 index 000000000..3aba9311e --- /dev/null +++ b/test/gstack-decision-semantic.test.ts @@ -0,0 +1,139 @@ +/** + * Tests for lib/gstack-decision-semantic.ts — the OPTIONAL gbrain enhancement. + * + * The load-bearing contract is DEGRADE-TO-NULL: when gbrain is absent/errors, every + * entry point returns null (caller shows reliable file results), never throws, never + * hangs. We also pin the text-surface parser deterministically and prove the + * end-to-end scope+search path with a fake `gbrain` shim on PATH (no live gbrain). + */ + +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + parseSearchHits, + resolveMemorySourceId, + semanticRecall, +} from "../lib/gstack-decision-semantic"; + +describe("parseSearchHits (text surface)", () => { + const sample = [ + "[0.91] decisions/foo -- We chose PGLite for the local engine", + "a banner line that is not a hit", + "", + "[0.42] docs/bar -- Some other relevant snippet", + "[0.05] noise/baz -- below the threshold", + ].join("\n"); + + test("parses scored lines, skips non-hit lines", () => { + const hits = parseSearchHits(sample, 0.1, 10); + expect(hits).toHaveLength(2); + expect(hits[0]).toEqual({ score: 0.91, slug: "decisions/foo", snippet: "We chose PGLite for the local engine" }); + expect(hits[1].slug).toBe("docs/bar"); + }); + + test("applies minScore floor", () => { + expect(parseSearchHits(sample, 0.5, 10)).toHaveLength(1); + }); + + test("applies limit", () => { + expect(parseSearchHits(sample, 0.0, 1)).toHaveLength(1); + }); + + test("empty / garbage input yields no hits (no throw)", () => { + expect(parseSearchHits("", 0.1, 10)).toEqual([]); + expect(parseSearchHits("not a hit at all\n???", 0.1, 10)).toEqual([]); + }); +}); + +describe("degrade-to-null contract (gbrain absent)", () => { + // HOME without ~/.gbrain so buildGbrainEnv doesn't seed a DB; PATH without gbrain. + const absentEnv = { PATH: "/nonexistent-bin-dir", HOME: os.tmpdir() }; + + test("semanticRecall returns null on empty query (no spawn)", () => { + expect(semanticRecall(" ", absentEnv)).toBeNull(); + }); + + test("semanticRecall returns null when gbrain is not on PATH", () => { + expect(semanticRecall("pglite", absentEnv)).toBeNull(); + }); + + test("resolveMemorySourceId returns null when gbrain is not on PATH", () => { + expect(resolveMemorySourceId(absentEnv)).toBeNull(); + }); +}); + +describe("end-to-end with a fake gbrain shim", () => { + let binDir: string; + let homeDir: string; + + function writeShim(body: string): void { + const p = path.join(binDir, "gbrain"); + fs.writeFileSync(p, body, { mode: 0o755 }); + fs.chmodSync(p, 0o755); + } + function env(): NodeJS.ProcessEnv { + // Keep the real PATH so /usr/bin/env + bash resolve; prepend the shim dir. + return { PATH: `${binDir}:${process.env.PATH}`, HOME: homeDir }; + } + + beforeEach(() => { + binDir = fs.mkdtempSync(path.join(os.tmpdir(), "gbrain-shim-")); + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "gbrain-home-")); + }); + afterEach(() => { + fs.rmSync(binDir, { recursive: true, force: true }); + fs.rmSync(homeDir, { recursive: true, force: true }); + }); + + test("resolves the worktree-backed source and scopes search to it", () => { + writeShim( + `#!/usr/bin/env bash +if [ "$1" = "sources" ]; then + echo '{"sources":[{"id":"code","local_path":"/repo","page_count":100},{"id":"default","local_path":"/u/.gstack-brain-worktree","page_count":3}]}' + exit 0 +fi +if [ "$1" = "search" ]; then + if printf '%s ' "$@" | grep -q -- "--source default"; then + echo "[0.91] decisions/foo -- We chose PGLite for the local engine" + else + echo "[0.91] WRONG-SOURCE -- unscoped fallback" + fi + echo "[0.05] noise/baz -- below threshold" + exit 0 +fi +exit 1 +`, + ); + expect(resolveMemorySourceId(env())).toBe("default"); + const hits = semanticRecall("pglite", env()); + expect(hits).not.toBeNull(); + expect(hits).toHaveLength(1); + expect(hits![0].slug).toBe("decisions/foo"); // proves --source default was forwarded + }); + + test("searches unscoped when no worktree source is registered", () => { + writeShim( + `#!/usr/bin/env bash +if [ "$1" = "sources" ]; then echo '{"sources":[{"id":"code","local_path":"/repo"}]}'; exit 0; fi +if [ "$1" = "search" ]; then echo "[0.50] code/x -- unscoped hit"; exit 0; fi +exit 1 +`, + ); + expect(resolveMemorySourceId(env())).toBeNull(); + const hits = semanticRecall("anything", env()); + expect(hits).not.toBeNull(); + expect(hits![0].slug).toBe("code/x"); + }); + + test("degrades to null when gbrain search exits non-zero", () => { + writeShim( + `#!/usr/bin/env bash +if [ "$1" = "sources" ]; then echo '{"sources":[{"id":"default","local_path":"/u/.gstack-brain-worktree"}]}'; exit 0; fi +exit 1 +`, + ); + expect(semanticRecall("pglite", env())).toBeNull(); + }); +});