diff --git a/bin/gstack-brain-cache b/bin/gstack-brain-cache new file mode 100755 index 000000000..8cbdb4e76 --- /dev/null +++ b/bin/gstack-brain-cache @@ -0,0 +1,587 @@ +#!/usr/bin/env bun +/** + * gstack-brain-cache — three-tier cache for brain-aware planning skills. + * + * Subcommands: + * get [--project ] — return digest content; refresh if stale + * refresh [--full] [--entity X] [--project ] — force refresh one or all + * invalidate [--project ] — mark stale; next get triggers cold + * digest — compress a brain page slug to digest + * meta [--project ] — print _meta.json + * + * (Later commits add: bootstrap [T2b], list [T18], purge [T18], retention sweep [T18].) + * + * Cache layout: + * ~/.gstack/brain-cache/ ← cross-project (user-profile only) + * ~/.gstack/projects//brain-cache/ ← per-project (everything else) + * + * Atomic writes via .tmp + rename. Stale-but-usable fallback when brain + * unreachable. Concurrent-refresh dedup is a follow-up commit (T15). + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, statSync, unlinkSync, readdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; +import { execGbrainJson, spawnGbrain } from '../lib/gbrain-exec'; +import { + BRAIN_CACHE_ENTITIES, + GSTACK_SCHEMA_PACK_NAME, + GSTACK_SCHEMA_PACK_VERSION, + type BrainCacheEntity, +} from '../scripts/brain-cache-spec'; + +// ────────────────────────────────────────────────────────────────────────── +// Paths + meta +// ────────────────────────────────────────────────────────────────────────── + +const GSTACK_HOME = process.env.GSTACK_HOME || join(homedir(), '.gstack'); + +interface CacheMeta { + /** Version of the schema pack the cache was built against. Mismatch → full rebuild. */ + schema_version: string; + /** SHA8 hash of the brain MCP endpoint URL (or 'local' for on-disk engines). */ + endpoint_hash: string; + /** Per-entity last-refresh epoch ms. Absent → never refreshed. */ + last_refresh: Record; + /** Per-entity last-attempt epoch ms (even if attempt failed). For stale-but-usable diagnostics. */ + last_attempt?: Record; +} + +/** Returns the directory holding a given entity's cache file. */ +export function entityDir(entity: BrainCacheEntity, projectSlug: string | null): string { + if (entity.scope === 'cross-project') { + return join(GSTACK_HOME, 'brain-cache'); + } + if (!projectSlug) { + throw new Error(`Per-project entity needs a project slug: ${entity.file}`); + } + return join(GSTACK_HOME, 'projects', projectSlug, 'brain-cache'); +} + +/** Returns the path to the cache file for a given entity. */ +export function entityPath(entityName: string, projectSlug: string | null): string { + const entity = BRAIN_CACHE_ENTITIES[entityName]; + if (!entity) throw new Error(`Unknown brain cache entity: ${entityName}`); + return join(entityDir(entity, projectSlug), entity.file); +} + +/** Returns the path to the _meta.json for a given scope. */ +export function metaPath(scope: 'cross-project' | 'per-project', projectSlug: string | null): string { + if (scope === 'cross-project') { + return join(GSTACK_HOME, 'brain-cache', '_meta.json'); + } + if (!projectSlug) throw new Error('Per-project meta needs a project slug'); + return join(GSTACK_HOME, 'projects', projectSlug, 'brain-cache', '_meta.json'); +} + +function loadMeta(scope: 'cross-project' | 'per-project', projectSlug: string | null): CacheMeta { + const path = metaPath(scope, projectSlug); + if (!existsSync(path)) { + return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} }; + } + try { + return JSON.parse(readFileSync(path, 'utf-8')) as CacheMeta; + } catch { + // Corrupt _meta — start fresh (entries will refresh on next access). + return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} }; + } +} + +function saveMeta(scope: 'cross-project' | 'per-project', projectSlug: string | null, meta: CacheMeta): void { + const path = metaPath(scope, projectSlug); + mkdirSync(dirname(path), { recursive: true }); + atomicWrite(path, JSON.stringify(meta, null, 2)); +} + +// ────────────────────────────────────────────────────────────────────────── +// Endpoint hash detection +// ────────────────────────────────────────────────────────────────────────── + +import { createHash } from 'crypto'; + +function sha8(input: string): string { + return createHash('sha256').update(input).digest('hex').slice(0, 8); +} + +/** + * Detects the active brain endpoint (MCP URL or 'local') and returns its + * stable identity hash. Used to detect when the user switches brains + * (different endpoint → different cache). + */ +export function detectEndpointHash(): string { + const claudeJsonPath = join(homedir(), '.claude.json'); + if (existsSync(claudeJsonPath)) { + try { + const cfg = JSON.parse(readFileSync(claudeJsonPath, 'utf-8')); + const gbrainServer = cfg?.mcpServers?.gbrain; + const url = gbrainServer?.url || gbrainServer?.transport?.url; + if (typeof url === 'string' && url.length > 0) { + return sha8(url); + } + } catch { /* fall through to local */ } + } + // Local engine — no endpoint URL; use a stable literal hash. + return 'local'; +} + +// ────────────────────────────────────────────────────────────────────────── +// Atomic write (tmp + rename) +// ────────────────────────────────────────────────────────────────────────── + +function atomicWrite(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp.${process.pid}.${Date.now()}`; + writeFileSync(tmp, content, 'utf-8'); + renameSync(tmp, path); +} + +// ────────────────────────────────────────────────────────────────────────── +// Staleness + refresh logic +// ────────────────────────────────────────────────────────────────────────── + +/** Returns true if the cached digest is past its TTL. */ +function isStale(entityName: string, meta: CacheMeta): boolean { + const entity = BRAIN_CACHE_ENTITIES[entityName]; + if (!entity) return true; + const last = meta.last_refresh[entityName]; + if (!last) return true; + return Date.now() - last > entity.ttl_ms; +} + +/** Returns true if the cache file exists on disk. */ +function hasFile(entityName: string, projectSlug: string | null): boolean { + return existsSync(entityPath(entityName, projectSlug)); +} + +/** Returns true if schema version recorded in meta differs from current pack version. */ +function schemaVersionMismatch(meta: CacheMeta): boolean { + return meta.schema_version !== GSTACK_SCHEMA_PACK_VERSION; +} + +/** Returns true if endpoint hash recorded in meta differs from current detected endpoint. */ +function endpointSwitched(meta: CacheMeta): boolean { + return meta.endpoint_hash !== detectEndpointHash(); +} + +// ────────────────────────────────────────────────────────────────────────── +// Subcommand: get +// ────────────────────────────────────────────────────────────────────────── + +interface GetResult { + /** Path to the digest file. */ + path: string; + /** Cache state: 'warm' (fresh + valid), 'cold-refreshed' (was stale, refreshed inline), 'stale-fallback' (used stale because refresh failed), 'missing' (no cache and no refresh). */ + state: 'warm' | 'cold-refreshed' | 'stale-fallback' | 'missing'; + /** Optional message for diagnostics. */ + message?: string; +} + +export function cmdGet(entityName: string, projectSlug: string | null): GetResult { + const entity = BRAIN_CACHE_ENTITIES[entityName]; + if (!entity) throw new Error(`Unknown entity: ${entityName}`); + const scope = entity.scope; + const meta = loadMeta(scope, projectSlug); + + // Schema-version mismatch → full rebuild (D4 A4). + if (schemaVersionMismatch(meta) || endpointSwitched(meta)) { + rebuildAllForScope(scope, projectSlug); + // After rebuild, meta is fresh; fall through to warm path. + const newMeta = loadMeta(scope, projectSlug); + if (hasFile(entityName, projectSlug) && !isStale(entityName, newMeta)) { + return { path: entityPath(entityName, projectSlug), state: 'warm' }; + } + // Rebuild may have failed for this entity specifically. + return { path: entityPath(entityName, projectSlug), state: 'missing', message: 'rebuild after schema/endpoint change' }; + } + + if (hasFile(entityName, projectSlug) && !isStale(entityName, meta)) { + return { path: entityPath(entityName, projectSlug), state: 'warm' }; + } + + // Stale or missing — try cold refresh. + const refreshed = refreshEntity(entityName, projectSlug); + if (refreshed) { + return { path: entityPath(entityName, projectSlug), state: 'cold-refreshed' }; + } + // Refresh failed. Use stale-but-usable if file exists. + if (hasFile(entityName, projectSlug)) { + return { path: entityPath(entityName, projectSlug), state: 'stale-fallback', message: 'brain unreachable; using stale cache' }; + } + // No cache and no refresh = missing. + return { path: entityPath(entityName, projectSlug), state: 'missing', message: 'brain unreachable; no cache available' }; +} + +// ────────────────────────────────────────────────────────────────────────── +// Subcommand: refresh +// ────────────────────────────────────────────────────────────────────────── + +/** Refreshes one entity from the brain. Returns true on success. */ +export function refreshEntity(entityName: string, projectSlug: string | null): boolean { + const entity = BRAIN_CACHE_ENTITIES[entityName]; + if (!entity) return false; + + // Mark attempt + const meta = loadMeta(entity.scope, projectSlug); + meta.last_attempt = meta.last_attempt || {}; + meta.last_attempt[entityName] = Date.now(); + + // Fetch from brain. The actual fetch logic varies per entity — derived digests + // (recent-decisions, salience) need different queries from direct page reads. + // For T2a we implement the direct-page path; derived digests get filled in by + // the resolver / write-back paths in later commits. + const digestContent = fetchAndCompressEntity(entityName, projectSlug); + if (digestContent === null) { + saveMeta(entity.scope, projectSlug, meta); + return false; + } + + // Enforce per-entity budget by truncating from end (oldest items live there + // by convention in our compressor). The per-skill budget is separately + // enforced at preflight injection time. + let final = digestContent; + if (Buffer.byteLength(final, 'utf-8') > entity.budget_bytes) { + final = truncateToBudget(final, entity.budget_bytes); + } + + atomicWrite(entityPath(entityName, projectSlug), final); + meta.last_refresh[entityName] = Date.now(); + // Keep schema/endpoint identity fresh. + meta.schema_version = GSTACK_SCHEMA_PACK_VERSION; + meta.endpoint_hash = detectEndpointHash(); + saveMeta(entity.scope, projectSlug, meta); + return true; +} + +/** + * Refresh all entities for a scope (per-project or cross-project). + * Used by --full and by schema/endpoint-change rebuilds. + */ +export function refreshAll(projectSlug: string | null): { success: number; failed: number } { + let success = 0; + let failed = 0; + for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) { + // Cross-project entities only refresh when explicitly targeted via no-slug calls + if (entity.scope === 'cross-project' && projectSlug) continue; + if (entity.scope === 'per-project' && !projectSlug) continue; + if (refreshEntity(name, projectSlug)) success++; else failed++; + } + return { success, failed }; +} + +/** Rebuild on schema-version mismatch or endpoint switch. Wipes affected scope first. */ +function rebuildAllForScope(scope: 'cross-project' | 'per-project', projectSlug: string | null): void { + // Wipe files but preserve dir; meta gets fully rewritten by refreshes below. + for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) { + if (entity.scope !== scope) continue; + const p = entityPath(name, projectSlug); + if (existsSync(p)) { + try { unlinkSync(p); } catch { /* best effort */ } + } + } + // Fresh meta starts here + const fresh: CacheMeta = { + schema_version: GSTACK_SCHEMA_PACK_VERSION, + endpoint_hash: detectEndpointHash(), + last_refresh: {}, + last_attempt: {}, + }; + saveMeta(scope, projectSlug, fresh); + // Refresh all entities in this scope + for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) { + if (entity.scope !== scope) continue; + refreshEntity(name, projectSlug); + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Subcommand: invalidate +// ────────────────────────────────────────────────────────────────────────── + +export function cmdInvalidate(entityName: string, projectSlug: string | null): void { + const entity = BRAIN_CACHE_ENTITIES[entityName]; + if (!entity) throw new Error(`Unknown entity: ${entityName}`); + const meta = loadMeta(entity.scope, projectSlug); + delete meta.last_refresh[entityName]; + saveMeta(entity.scope, projectSlug, meta); +} + +// ────────────────────────────────────────────────────────────────────────── +// Fetch + compress per-entity +// ────────────────────────────────────────────────────────────────────────── + +/** + * Returns the digest markdown content for an entity, or null if the brain is + * unreachable / the source page doesn't exist. + * + * For T2a we implement the entity → page-slug mapping for the simple cases. + * Derived digests (recent-decisions, salience) get specialized paths. + */ +function fetchAndCompressEntity(entityName: string, projectSlug: string | null): string | null { + switch (entityName) { + case 'user-profile': + return fetchUserProfile(); + case 'product': + return fetchProduct(projectSlug); + case 'goals': + return fetchGoals(projectSlug); + case 'developer-persona': + return fetchSimplePage(`gstack/developer-persona/${projectSlug}`); + case 'brand': + return fetchSimplePage(`gstack/brand/${projectSlug}`); + case 'competitive-intel': + return fetchSimplePage(`gstack/competitive-intel/${projectSlug}`); + case 'recent-decisions': + return fetchRecentDecisions(projectSlug); + case 'salience': + // D9 salience allowlist applied in T17 commit; T2a returns raw output for now. + return fetchSalience(projectSlug); + default: + return null; + } +} + +/** Generic single-page fetch via `gbrain get`. Returns null on miss/unreachable. */ +function fetchSimplePage(slug: string): string | null { + const result = spawnGbrain(['get', slug, '--json'], { timeout: 10_000 }); + if (result.status !== 0) return null; + try { + const page = JSON.parse(result.stdout) as { body?: string; title?: string }; + if (!page?.body) return null; + return compressPage(slug, page.title || slug, page.body); + } catch { + return null; + } +} + +function fetchUserProfile(): string | null { + // The user-slug discovery is implemented in T16 (D4 A3). For T2a we accept + // env GSTACK_USER_SLUG as override, fallback to $USER for direct calls. + const slug = process.env.GSTACK_USER_SLUG || process.env.USER || 'unknown'; + return fetchSimplePage(`gstack/user-profile/${slug}`); +} + +function fetchProduct(projectSlug: string | null): string | null { + if (!projectSlug) return null; + return fetchSimplePage(`gstack/product/${projectSlug}`); +} + +/** + * Goals are LIST queries: all gstack/goal//* pages. + * Compress the top N by recency. + */ +function fetchGoals(projectSlug: string | null): string | null { + if (!projectSlug) return null; + const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string; body?: string }> }>([ + 'list-pages', + '--type', 'gstack/goal', + '--limit', '10', + '--json', + ]); + if (!result?.pages) return null; + const goals = result.pages.filter((p) => p.slug?.startsWith(`gstack/goal/${projectSlug}/`)); + if (goals.length === 0) { + // Empty digest is valid (just header + 'no active goals' line) + return `# Active goals (project: ${projectSlug})\n\n_No active goals recorded yet._\n`; + } + const lines = goals.map((g) => `- [[${g.slug}]] — ${g.title || '(untitled)'}`); + return `# Active goals (project: ${projectSlug})\n\n${lines.join('\n')}\n`; +} + +/** + * recent-decisions: last 5 gstack/skill-run pages for this project, compressed + * to one-line summaries. + */ +function fetchRecentDecisions(projectSlug: string | null): string | null { + if (!projectSlug) return null; + const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string }> }>([ + 'list-pages', + '--type', 'gstack/skill-run', + '--limit', '5', + '--sort', 'updated_desc', + '--json', + ]); + if (!result?.pages) { + return `# Recent decisions (project: ${projectSlug})\n\n_No prior skill runs recorded._\n`; + } + const lines = result.pages.map((p) => `- ${p.title || p.slug}`); + return `# Recent decisions (project: ${projectSlug})\n\n${lines.join('\n')}\n`; +} + +function fetchSalience(projectSlug: string | null): string | null { + // get-recent-salience is a gbrain CLI sub-shape; we use the MCP-shape JSON + const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string; emotional_weight?: number }> }>([ + 'get-recent-salience', + '--days', '14', + '--limit', '10', + '--json', + ]); + if (!result?.pages) return `# Recent salience\n\n_No salient pages in last 14d._\n`; + // T17 will add allowlist filtering here. For T2a we return raw output. + const lines = result.pages.map((p) => `- [[${p.slug}]] — ${p.title || ''} (weight: ${p.emotional_weight?.toFixed(2) ?? 'n/a'})`); + return `# Recent salience (last 14d)\n\n${lines.join('\n')}\n`; +} + +/** + * Compress a brain page body into a digest. The compressor keeps frontmatter + * out, trims body to the first H2/H3 sections, and prepends a slug header. + * Per-entity budget enforcement happens at the caller (refreshEntity). + */ +function compressPage(slug: string, title: string, body: string): string { + const trimmed = body + .replace(/^---[\s\S]*?---\s*\n/m, '') // strip frontmatter + .trim(); + return `# ${title}\nslug: ${slug}\n\n${trimmed}\n`; +} + +/** + * Truncate a digest to a byte budget. Tries to cut at the last newline before + * the budget so the digest stays readable. + */ +function truncateToBudget(content: string, budgetBytes: number): string { + const buf = Buffer.from(content, 'utf-8'); + if (buf.byteLength <= budgetBytes) return content; + const truncated = buf.slice(0, budgetBytes).toString('utf-8'); + const lastNewline = truncated.lastIndexOf('\n'); + const cleanCut = lastNewline > budgetBytes * 0.8 ? truncated.slice(0, lastNewline) : truncated; + return `${cleanCut}\n\n_(digest truncated to ${budgetBytes}-byte budget)_\n`; +} + +// ────────────────────────────────────────────────────────────────────────── +// Subcommand: digest +// ────────────────────────────────────────────────────────────────────────── + +/** + * Public: compress a brain page slug to digest format. Used by callers that + * want to know what the digest WOULD look like without writing to cache. + */ +export function cmdDigest(slug: string): string | null { + return fetchSimplePage(slug); +} + +// ────────────────────────────────────────────────────────────────────────── +// Subcommand: meta +// ────────────────────────────────────────────────────────────────────────── + +export function cmdMeta(projectSlug: string | null): CacheMeta { + if (projectSlug) return loadMeta('per-project', projectSlug); + return loadMeta('cross-project', null); +} + +// ────────────────────────────────────────────────────────────────────────── +// CLI dispatch +// ────────────────────────────────────────────────────────────────────────── + +function parseArgs(argv: string[]): { cmd: string; positional: string[]; flags: Record } { + const cmd = argv[2] || ''; + const rest = argv.slice(3); + const positional: string[] = []; + const flags: Record = {}; + for (let i = 0; i < rest.length; i++) { + const arg = rest[i]; + if (arg.startsWith('--')) { + const key = arg.slice(2); + const next = rest[i + 1]; + if (next && !next.startsWith('--')) { + flags[key] = next; + i++; + } else { + flags[key] = true; + } + } else { + positional.push(arg); + } + } + return { cmd, positional, flags }; +} + +function projectSlugFromFlag(flags: Record): string | null { + const v = flags.project; + return typeof v === 'string' ? v : null; +} + +function printUsage(): void { + process.stderr.write(`Usage: gstack-brain-cache + +Subcommands: + get [--project ] + refresh [--full] [--entity X] [--project ] + invalidate [--project ] + digest + meta [--project ] +`); +} + +async function main(): Promise { + const { cmd, positional, flags } = parseArgs(process.argv); + const projectSlug = projectSlugFromFlag(flags); + + try { + switch (cmd) { + case 'get': { + const entityName = positional[0]; + if (!entityName) { printUsage(); return 1; } + const result = cmdGet(entityName, projectSlug); + if (result.state === 'missing') { + process.stderr.write(`(${result.state}: ${result.message ?? 'no cache'})\n`); + return 2; + } + if (result.state !== 'warm') { + process.stderr.write(`(${result.state}${result.message ? ': ' + result.message : ''})\n`); + } + process.stdout.write(readFileSync(result.path, 'utf-8')); + return 0; + } + case 'refresh': { + if (flags.entity) { + const ok = refreshEntity(String(flags.entity), projectSlug); + process.stdout.write(ok ? `refreshed ${flags.entity}\n` : `failed to refresh ${flags.entity}\n`); + return ok ? 0 : 1; + } + const { success, failed } = refreshAll(projectSlug); + process.stdout.write(`refreshed=${success} failed=${failed}\n`); + return failed > 0 ? 1 : 0; + } + case 'invalidate': { + const entityName = positional[0]; + if (!entityName) { printUsage(); return 1; } + cmdInvalidate(entityName, projectSlug); + process.stdout.write(`invalidated ${entityName}\n`); + return 0; + } + case 'digest': { + const slug = positional[0]; + if (!slug) { printUsage(); return 1; } + const content = cmdDigest(slug); + if (content === null) { + process.stderr.write('brain unreachable or page not found\n'); + return 2; + } + process.stdout.write(content); + return 0; + } + case 'meta': { + const meta = cmdMeta(projectSlug); + process.stdout.write(JSON.stringify(meta, null, 2) + '\n'); + return 0; + } + case '': + case 'help': + case '--help': + case '-h': + printUsage(); + return 0; + default: + process.stderr.write(`unknown subcommand: ${cmd}\n`); + printUsage(); + return 1; + } + } catch (err) { + process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`); + return 1; + } +} + +// Only run main when invoked as a script (not when imported by tests) +if (import.meta.main) { + main().then((code) => process.exit(code)); +} diff --git a/test/brain-cache-roundtrip.test.ts b/test/brain-cache-roundtrip.test.ts new file mode 100644 index 000000000..d476f8b76 --- /dev/null +++ b/test/brain-cache-roundtrip.test.ts @@ -0,0 +1,164 @@ +/** + * brain-cache roundtrip integration tests (T2a / T19). + * + * Exercises the non-MCP-dependent parts of the cache layer: + * - Path resolution per scope (cross-project vs per-project) + * - Atomic _meta.json write/read + * - TTL staleness detection + * - Invalidate clears last_refresh + * - Schema-version mismatch triggers rebuild attempt (D4 A4) + * - Endpoint switch triggers rebuild attempt + * + * The brain-reachable refresh path (MCP fetch + compress) is tested + * separately in brain-cache-stale-but-usable.test.ts using a mocked + * spawnGbrain. T2a focuses on the cache-state machine. + * + * Uses tmp GSTACK_HOME per-test to avoid polluting the real ~/.gstack/. + * Gate-tier, free, ~50ms. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, existsSync, writeFileSync, readFileSync, rmSync, mkdirSync, readdirSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +let TMP_HOME: string; +const ORIGINAL_HOME = process.env.GSTACK_HOME; + +beforeEach(() => { + TMP_HOME = mkdtempSync(join(tmpdir(), 'gstack-cache-test-')); + process.env.GSTACK_HOME = TMP_HOME; + // Reload the cache module fresh per test so it picks up the new HOME. + delete require.cache[require.resolve('../bin/gstack-brain-cache')]; +}); + +afterEach(() => { + if (ORIGINAL_HOME) process.env.GSTACK_HOME = ORIGINAL_HOME; + else delete process.env.GSTACK_HOME; + try { rmSync(TMP_HOME, { recursive: true, force: true }); } catch { /* best effort */ } +}); + +async function importCache(): Promise { + return (await import('../bin/gstack-brain-cache')) as typeof import('../bin/gstack-brain-cache'); +} + +describe('brain-cache paths', () => { + test('cross-project entity (user-profile) lives in ~/.gstack/brain-cache/', async () => { + const mod = await importCache(); + const path = mod.entityPath('user-profile', null); + expect(path).toBe(join(TMP_HOME, 'brain-cache', 'user-profile.md')); + }); + + test('per-project entity (product) lives in ~/.gstack/projects//brain-cache/', async () => { + const mod = await importCache(); + const path = mod.entityPath('product', 'helsinki'); + expect(path).toBe(join(TMP_HOME, 'projects', 'helsinki', 'brain-cache', 'product.md')); + }); + + test('throws on unknown entity', async () => { + const mod = await importCache(); + expect(() => mod.entityPath('not-an-entity', null)).toThrow(); + }); + + test('per-project entity without slug throws', async () => { + const mod = await importCache(); + expect(() => mod.entityPath('product', null)).toThrow(); + }); +}); + +describe('brain-cache meta lifecycle', () => { + test('cmdMeta on empty cache returns valid fresh meta', async () => { + const mod = await importCache(); + const meta = mod.cmdMeta('helsinki'); + expect(meta.schema_version).toMatch(/^\d+\.\d+\.\d+$/); + expect(meta.endpoint_hash).toMatch(/^[a-f0-9]{1,8}$|^local$/); + expect(meta.last_refresh).toEqual({}); + }); + + test('cmdInvalidate writes meta even if no prior refresh', async () => { + const mod = await importCache(); + mod.cmdInvalidate('product', 'helsinki'); + const meta = mod.cmdMeta('helsinki'); + // last_refresh remains empty (we just delete an absent key — that's a no-op + // but the meta file is now written to disk). + expect(meta.last_refresh.product).toBeUndefined(); + expect(existsSync(join(TMP_HOME, 'projects', 'helsinki', 'brain-cache', '_meta.json'))).toBe(true); + }); +}); + +describe('brain-cache endpoint detection', () => { + test('detectEndpointHash returns "local" when no ~/.claude.json gbrain MCP', async () => { + // We don't write ~/.claude.json in the temp env, so this falls through to local. + const mod = await importCache(); + // The user's real ~/.claude.json may have an MCP server; in that case the hash + // will be a real sha8. Either way, it's a stable string. + const hash = mod.detectEndpointHash(); + expect(typeof hash).toBe('string'); + expect(hash.length).toBeGreaterThan(0); + }); +}); + +describe('brain-cache schema mismatch behavior', () => { + test('schema-version mismatch in meta triggers full-rebuild attempt on next get', async () => { + const mod = await importCache(); + // Pre-seed meta with a different schema version, and a cache file that's + // recent enough to be "warm" by TTL but stale by schema version. + const cacheDir = join(TMP_HOME, 'projects', 'helsinki', 'brain-cache'); + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(join(cacheDir, 'product.md'), '# stale-from-old-schema\n'); + writeFileSync(join(cacheDir, '_meta.json'), JSON.stringify({ + schema_version: '0.0.1', + endpoint_hash: mod.detectEndpointHash(), + last_refresh: { product: Date.now() }, + last_attempt: {}, + })); + + const result = mod.cmdGet('product', 'helsinki'); + // Brain is unreachable in this test (no gbrain mock), so refresh fails and + // the file gets deleted by the rebuild step. State should be 'missing' or + // 'stale-fallback' depending on whether the rebuild left a file behind. + expect(['missing', 'cold-refreshed', 'stale-fallback']).toContain(result.state); + }); +}); + +describe('brain-cache state machine', () => { + test('warm: pre-seeded fresh cache returns warm without touching brain', async () => { + const mod = await importCache(); + const cacheDir = join(TMP_HOME, 'projects', 'helsinki', 'brain-cache'); + mkdirSync(cacheDir, { recursive: true }); + const productContent = '# Product: helsinki\n\nA test product.\n'; + writeFileSync(join(cacheDir, 'product.md'), productContent); + writeFileSync(join(cacheDir, '_meta.json'), JSON.stringify({ + schema_version: '1.0.0', // matches GSTACK_SCHEMA_PACK_VERSION + endpoint_hash: mod.detectEndpointHash(), + last_refresh: { product: Date.now() }, // fresh + last_attempt: {}, + })); + const result = mod.cmdGet('product', 'helsinki'); + expect(result.state).toBe('warm'); + expect(readFileSync(result.path, 'utf-8')).toBe(productContent); + }); + + test('missing: no cache + no brain returns missing state', async () => { + const mod = await importCache(); + const result = mod.cmdGet('brand', 'helsinki'); + expect(result.state).toBe('missing'); + }); + + test('stale-fallback: stale cache with unreachable brain returns stale-fallback', async () => { + const mod = await importCache(); + const cacheDir = join(TMP_HOME, 'projects', 'helsinki', 'brain-cache'); + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(join(cacheDir, 'product.md'), '# stale\n'); + // Set last_refresh way in the past (> 1d TTL for product) + writeFileSync(join(cacheDir, '_meta.json'), JSON.stringify({ + schema_version: '1.0.0', + endpoint_hash: mod.detectEndpointHash(), + last_refresh: { product: 0 }, // epoch start = very stale + last_attempt: {}, + })); + const result = mod.cmdGet('product', 'helsinki'); + // Brain unreachable → cold refresh fails → stale-but-usable fallback + expect(result.state).toBe('stale-fallback'); + }); +});