diff --git a/test/bootstrap-codex-door.test.ts b/test/bootstrap-codex-door.test.ts new file mode 100644 index 000000000..2f4eb7b4b --- /dev/null +++ b/test/bootstrap-codex-door.test.ts @@ -0,0 +1,117 @@ +/** + * Codex door, end to end (GAP 3a). The Codex harness installs NO per-turn + * hooks — the pull protocol in AGENTS.md IS the per-message seam, and the MCP + * registration is what wires the tools. Two contracts are load-bearing and + * were previously only spot-checked by grepping the "Per-message gates" + * heading (a template edit could gut Gate 3's brain-first prose silently): + * + * 1. The RENDERED AGENTS.md carries Gate 3 ("Entity lookup (brain first)") in + * full — search-the-brain-before-answering with the actual brain tools — + * so a Codex agent with no hooks still pulls context every turn. + * 2. `registerCodexMcp` argv pins `serve --surface full` (bootstrap needs the + * whole op surface, not a narrowed `verbs`) and binds GBRAIN_SOURCE so a + * GUI-spawned serve (which inherits no shell env) writes to the workspace + * source [G1, CX-P1.4]. + */ +import { describe, test, expect } from 'bun:test'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { renderWorkspace } from '../src/core/bootstrap/render.ts'; +import { setAnswer } from '../src/core/bootstrap/interview.ts'; +import { registerCodexMcp } from '../src/core/bootstrap/hooks.ts'; + +const REQUIRED_ANSWERS: Record = { + AGENT_NAME: 'Codexeer', + PRINCIPAL_NAME: 'Alice Example', + AGENT_PURPOSE: 'Maintain the research corpus and draft the weekly memo.', + AGENT_TOP_JOBS: '- corpus upkeep\n- weekly memo\n- meeting prep', + PRINCIPAL_CONTEXT: 'Runs a small research lab; ships a memo every Friday.', + VOICE_REGISTER: 'Direct. Three options, the second one wins.', +}; + +function answeredWs(): string { + const ws = mkdtempSync(join(tmpdir(), 'gbrain-codex-door-')); + for (const [key, value] of Object.entries(REQUIRED_ANSWERS)) { + const r = setAnswer(ws, key, value); + if (!r.ok) throw new Error(`setup failed for ${key}: ${r.message}`); + } + return ws; +} + +describe('Codex door — rendered AGENTS.md pull protocol (Gate 3)', () => { + test('rendered AGENTS.md carries the full brain-first Entity-lookup gate, not just the heading', () => { + const ws = answeredWs(); + renderWorkspace(ws); + const agents = readFileSync(join(ws, 'AGENTS.md'), 'utf8'); + + // The heading everyone already greps — necessary but far from sufficient. + expect(agents).toContain('Per-message gates'); + + // Gate 3 body — the actual pull-protocol prose. If a template edit guts + // any of this, a hookless Codex agent stops pulling context and this fails. + expect(agents).toContain('Gate 3 — Entity lookup (brain first)'); + expect(agents).toContain('search the brain before answering'); + // Names the concrete brain tools, in order (recall → query → get_page). + expect(agents).toContain('`recall` for hot'); + expect(agents).toContain('`query` for synthesis'); + expect(agents).toContain('`get_page` for the record'); + // Push-context path: already-injected context is used rather than re-fetched. + expect(agents).toContain('If context was already\ninjected this turn, use it.'); + // The anti-narration + no-generic-grep rule that keeps retrieval silent. + expect(agents).toContain('never narrate retrieval'); + expect(agents).toContain('Never use generic file-grep where a brain tool'); + + // The identity token resolved (this is a real render, not the template). + expect(agents).toContain('Codexeer'); + expect(/\{\{[A-Z0-9_]+\}\}/.test(agents)).toBe(false); + }); + + test('Gate 3 sits inside the Per-message gates block, ahead of the write-back gate', () => { + const ws = answeredWs(); + renderWorkspace(ws); + const agents = readFileSync(join(ws, 'AGENTS.md'), 'utf8'); + const gatesIdx = agents.indexOf('## Per-message gates'); + const gate3Idx = agents.indexOf('Gate 3 — Entity lookup (brain first)'); + const gate7Idx = agents.indexOf('Gate 7 — Write-back'); + expect(gatesIdx).toBeGreaterThanOrEqual(0); + expect(gate3Idx).toBeGreaterThan(gatesIdx); + expect(gate7Idx).toBeGreaterThan(gate3Idx); + }); +}); + +describe('Codex door — registerCodexMcp argv (--surface full + GBRAIN_SOURCE)', () => { + const BIN = '/opt/gbrain/bin/gbrain'; + + test('user-global stdio registration binds the source and pins the full op surface', () => { + const argvs = registerCodexMcp({ gbrainBin: BIN, sourceId: 'workspace' }); + expect(argvs.length).toBe(1); + const argv = argvs[0]!; + // Exact shape — codex has no scope flag (user-global), env rides the add. + expect(argv).toEqual([ + 'codex', 'mcp', 'add', 'gbrain', + '--env', 'GBRAIN_SOURCE=workspace', + '--', BIN, 'serve', '--surface', 'full', + ]); + // The two contracts, asserted independently so a reshuffle can't hide a drop. + const joined = argv.join(' '); + expect(joined).toContain('--env GBRAIN_SOURCE=workspace'); + expect(joined).toContain('serve --surface full'); + // `serve` argv must NOT narrow to the verbs surface. + expect(joined).not.toContain('--surface verbs'); + }); + + test('isolated install also threads GBRAIN_HOME onto the registration [CX2-8]', () => { + const argv = registerCodexMcp({ gbrainBin: BIN, sourceId: 'workspace', gbrainHome: '/home/me/agent' })[0]!; + expect(argv).toContain('GBRAIN_HOME=/home/me/agent'); + // GBRAIN_HOME is a second --env, GBRAIN_SOURCE still present, surface intact. + expect(argv.filter((a) => a === '--env').length).toBe(2); + expect(argv).toContain('GBRAIN_SOURCE=workspace'); + expect(argv.join(' ')).toContain('serve --surface full'); + }); + + test('a non-absolute gbrain binary is refused (GUI hosts inherit no PATH) [CX-P1.4]', () => { + expect(() => registerCodexMcp({ gbrainBin: 'gbrain', sourceId: 'workspace' })).toThrow(/absolute path/); + }); +}); diff --git a/test/bootstrap-verify.serial.test.ts b/test/bootstrap-verify.serial.test.ts index cebcf6634..f57cc5cef 100644 --- a/test/bootstrap-verify.serial.test.ts +++ b/test/bootstrap-verify.serial.test.ts @@ -26,6 +26,8 @@ import { } from '../src/core/bootstrap/verify.ts'; import { listVerifyRuns } from '../src/core/bootstrap/status.ts'; import type { CapabilityReport } from '../src/core/capability.ts'; +import { operations, type OperationContext } from '../src/core/operations.ts'; +import { loadCorpusPages, loadCorpusQueries } from './helpers/bootstrap-corpus.ts'; const KEYLESS: CapabilityReport = { embeddings: { available: false }, @@ -389,3 +391,59 @@ describe('verifyWorkspace — source_id collision resolution', () => { } }, 240_000); }); + +describe('verifyWorkspace — real corpus graph floor + qrels recall', () => { + test('auto-link builds real multi-entity edges; verify passes on the populated brain; gold queries recall the expected pages', async () => { + // Seed the synthetic world into the SAME workspace source verify checks, + // through the real put_page handler so auto-link builds REAL edges — a + // 12-page graph, not the 2-node self-planted probe pair verify writes. + const loaded = await loadCorpusPages(engine, { sourceId: 'workspace' }); + expect(loaded).toContain('people/alice-example'); + expect(loaded).toContain('companies/ridge-platform'); + + // Real edges exist from the wikilinks in the page bodies (not the probe): + // alice-example → ridge-platform, with multiple outbound edges on a real + // entity, and the reverse edge answers through the backlink table. + const aliceLinks = await engine.getLinks('people/alice-example', { sourceId: 'workspace' }); + const aliceTargets = aliceLinks.map((l) => l.to_slug); + expect(aliceTargets).toContain('companies/ridge-platform'); + expect(aliceTargets.length).toBeGreaterThanOrEqual(2); + const ridgeBacklinks = await engine.getBacklinks('companies/ridge-platform', { sourceId: 'workspace' }); + expect(ridgeBacklinks.map((l) => l.from_slug)).toContain('people/alice-example'); + + // verify still runs green end-to-end on the now-populated brain, and its + // graph_floor / roundtrip checks pass alongside the real corpus graph. + const res = await verifyWorkspace(engine, ws, { + sourceId: 'workspace', + gbrainHomeDir: home, + capabilities: KEYLESS, + skipHooksSmoke: true, + }); + if (!res.ok) console.error(res.report); + expect(res.ok).toBe(true); + expect(check(res.checks, 'graph_floor')[0].ok).toBe(true); + for (const c of check(res.checks, 'roundtrip')) expect(c.ok).toBe(true); + + // qrels: each gold page-recall case returns its expected slug through the + // REAL keyword-search query op (keyless), over the multi-page corpus. + const queryOp = operations.find((o) => o.name === 'query')!; + const ctx: OperationContext = { + engine, + config: { engine: 'pglite' } as never, + logger: { info: () => {}, warn: () => {}, error: () => {} }, + dryRun: false, + remote: false, + sourceId: 'workspace', + }; + const goldPageCases = loadCorpusQueries().filter((q) => q.kind === 'page'); + expect(goldPageCases.length).toBeGreaterThan(0); + const misses: string[] = []; + for (const q of goldPageCases) { + const result = await queryOp.handler(ctx, { query: q.query, limit: 10, expand: false }); + if (!JSON.stringify(result).includes(q.expect_slug!)) { + misses.push(`${q.id}: "${q.query}" did not recall ${q.expect_slug}`); + } + } + expect(misses).toEqual([]); + }, 240_000); +}); diff --git a/test/brain-durability-hook.serial.test.ts b/test/brain-durability-hook.serial.test.ts index b46523f0a..d751e573d 100644 --- a/test/brain-durability-hook.serial.test.ts +++ b/test/brain-durability-hook.serial.test.ts @@ -9,7 +9,8 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync import { join } from 'path'; import { tmpdir } from 'os'; import { execFileSync } from 'child_process'; -import { hardenBrainRepo } from '../src/core/brain-repo-durability.ts'; +import { hardenBrainRepo, unhardenBrainRepo } from '../src/core/brain-repo-durability.ts'; +import { runPull } from '../src/commands/sources-harden.ts'; // #2943 root cause: `env: process.env` is REQUIRED here. Bun snapshots // process.env at startup, so without it the spawned git — and any post-commit @@ -192,3 +193,65 @@ describe('post-commit hook (D9 local, D7 self-contained)', () => { expect(found).toBe(true); }, 60_000); }); + +// The persistence schedule is the DB-free PULL cron (D2/D12) — the push side +// is the post-commit hook proved above. beforeEach hardens with +// installCron:false; this block re-hardens the SAME repo with installCron:true +// and proves the scheduled job is registered with the right command + interval, +// then invokes that exact command to prove it performs a real pull. It always +// unregisters the launchd/cron job afterward so no scheduled job survives. +describe('durability schedule (installCron:true) [D2/D12]', () => { + test('registers the DB-free pull job with the right command + interval, and the job performs a real pull', async () => { + const sourceId = 'wiki'; + const report = await hardenBrainRepo({ + repoPath: work, sourceId, pat: 'ghp_x', installCron: true, intervalSec: 900, verify: false, + }); + const toplevel = git(work, 'rev-parse', '--show-toplevel'); + try { + const cronStep = report.steps.find((s) => s.step === 'cron')!; + expect(cronStep).toBeDefined(); + expect(cronStep.status).not.toBe('skipped'); // installCron:true → it ran + + // The scheduled COMMAND: a DB-free `sources pull` wrapper for THIS repo, + // written to /brain-pull-.sh regardless of platform. + const wrapper = join(process.env.HOME!, '.gbrain', `brain-pull-${sourceId}.sh`); + expect(existsSync(wrapper)).toBe(true); + const body = readFileSync(wrapper, 'utf-8'); + expect(body).toContain(`sources pull --path '${toplevel}' --branch 'main'`); + + // The REGISTERED job + its 15-minute INTERVAL. launchd (darwin) is + // deterministic — assert the plist directly; the step detail names the + // interval on the darwin path. + if (process.platform === 'darwin') { + const plist = join(process.env.HOME!, 'Library', 'LaunchAgents', `com.gbrain.brain-pull.${sourceId}.plist`); + expect(existsSync(plist)).toBe(true); + const xml = readFileSync(plist, 'utf-8'); + expect(xml).toContain('StartInterval900'); // 900s = 15m + expect(xml).toContain(wrapper); // ProgramArguments points at our wrapper + expect(cronStep.detail).toContain('900s'); + } + + // Invoke the scheduled command directly to PROVE it performs the pull. + // Advance origin from a second clone, then run the exact DB-free pull the + // wrapper execs (`gbrain sources pull --path --branch main`). + const other = mkdtempSync(join(root, 'other-')); + execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, other], { stdio: 'ignore', env: process.env }); + git(other, 'config', 'user.email', 'o@o.o'); git(other, 'config', 'user.name', 'other'); + writeFileSync(join(other, 'from-remote.md'), 'landed via a second device\n'); + git(other, 'add', 'from-remote.md'); git(other, 'commit', '-qm', 'remote advance'); git(other, 'push', '-q', 'origin', 'main'); + + const remoteHead = originHead(bare); + expect(git(work, 'rev-parse', 'HEAD')).not.toBe(remoteHead); // local is behind + + await runPull(null, ['--path', work, '--branch', 'main']); + + // The scheduled pull fast-forwarded the local checkout to the remote and + // the remote-authored file is now present locally. + expect(git(work, 'rev-parse', 'HEAD')).toBe(remoteHead); + expect(existsSync(join(work, 'from-remote.md'))).toBe(true); + } finally { + // Unregister while HOME is still the temp home (afterEach restores it). + await unhardenBrainRepo({ repoPath: work, sourceId }); + } + }, 60_000); +}); diff --git a/test/e2e/bootstrap-attach.serial.test.ts b/test/e2e/bootstrap-attach.serial.test.ts index b4ab45706..58b57e8e3 100644 --- a/test/e2e/bootstrap-attach.serial.test.ts +++ b/test/e2e/bootstrap-attach.serial.test.ts @@ -35,6 +35,26 @@ import { attachWorkspace } from '../../src/core/bootstrap/attach.ts'; import { readManifest, readReceipt } from '../../src/core/bootstrap/format.ts'; import { initState, setAnswer, confirm, readBackHash } from '../../src/core/bootstrap/interview.ts'; import { realpathOrResolve } from '../../src/core/path-confine.ts'; +import { PGLiteEngine } from '../../src/core/pglite-engine.ts'; +import { addSource } from '../../src/core/sources-ops.ts'; +import { importFromFile } from '../../src/core/import-file.ts'; +import { verifyWorkspace } from '../../src/core/bootstrap/verify.ts'; +import { operations, type OperationContext } from '../../src/core/operations.ts'; +import type { CapabilityReport } from '../../src/core/capability.ts'; +import { CORPUS_DIR } from '../helpers/bootstrap-corpus.ts'; + +/** Keyless capability report — machine 2 re-ingests + verifies with ZERO keys. */ +const KEYLESS: CapabilityReport = { + embeddings: { available: false }, + extraction: { available: false }, + search: 'keyword-only', + mode: 'keyless', +}; + +/** A page authored ONLY on machine 1, committed to the repo, cloned to machine 2. */ +const MACHINE1_PAGE_SLUG = 'companies/summit-robotics'; +/** A distinctive fact that exists nowhere but machine 1's authored repo page. */ +const MACHINE1_MARKER = 'The flagship warehouse pilot runs at the Rivermouth fulfillment center.'; const SAVED_ENV: Record = {}; const ENV_KEYS = [ @@ -129,6 +149,17 @@ beforeAll(async () => { expect(renderCode).toBe(0); expect(readManifest(ws1).state).toBe('initialized'); + // Author a corpus page into machine 1's brain/ BEFORE the commit — a real + // synthetic page carrying a machine-1-only fact (the Rivermouth marker). It + // lives ONLY in the repo, so machine 2 can only learn it by re-ingesting the + // clone [multi-device promise: hot facts arrive via the repo]. + const corpusPage = readFileSync(join(CORPUS_DIR, 'pages', 'companies__summit-robotics.md'), 'utf8'); + mkdirSync(join(ws1, 'brain', 'companies'), { recursive: true }); + writeFileSync( + join(ws1, 'brain', 'companies', 'summit-robotics.md'), + `${corpusPage.trimEnd()}\n\n## Operations\n\n${MACHINE1_MARKER}\n`, + ); + // git init + commit + local bare 'origin' + clone → machine 2. git(ws1, ['init', '-q', '-b', 'main']); git(ws1, ['config', 'user.email', 'test@example.com']); @@ -239,6 +270,81 @@ describe('bootstrap attach (machine-2 adoption, serial e2e)', () => { ]); }, 60_000); + test('machine-2 full round-trip: repo page re-ingests into a FRESH brain, verify passes, and a machine-1-only fact is recalled', async () => { + // A fresh machine-2 PGLite brain — nothing pre-seeded. The only path a fact + // can reach it is the cloned repo tree. Hermetic in-memory engine (the + // proven in-process verify pattern) so teardown is clean. + const engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); + try { + // Register the cloned brain/ as the workspace source (what `bootstrap + // attach` → `gbrain sources add` does on machine 2). + await addSource(engine, { id: 'workspace', localPath: join(ws2, 'brain'), force: true }); + + // Precondition: the machine-1-authored page is NOT in the fresh DB — it + // exists ONLY as a committed repo file cloned from machine 1. + expect(await engine.getPage(MACHINE1_PAGE_SLUG, { sourceId: 'workspace' })).toBeNull(); + const clonedFile = join(ws2, 'brain', 'companies', 'summit-robotics.md'); + expect(existsSync(clonedFile)).toBe(true); + expect(readFileSync(clonedFile, 'utf8')).toContain(MACHINE1_MARKER); + + // Re-ingest through the REAL per-file import path (the primitive `gbrain + // sync` runs on each changed file), scoped to the workspace source. + const rel = join('companies', 'summit-robotics.md'); + const imp = await importFromFile(engine, clonedFile, rel, { sourceId: 'workspace', noEmbed: true }); + expect(imp.status).toBe('imported'); + expect(imp.slug).toBe(MACHINE1_PAGE_SLUG); + + // The page + its machine-1-authored content now live in machine 2's DB. + const page = await engine.getPage(MACHINE1_PAGE_SLUG, { sourceId: 'workspace' }); + expect(page).not.toBeNull(); + expect(page!.title).toBe('Summit Robotics'); + expect(page!.compiled_truth ?? '').toContain(MACHINE1_MARKER); + + // The clone's origin is a local bare path gh cannot prove private; drop it + // so repo_privacy resolves to the honest local-only pass. The multi-device + // fact transport under test is orthogonal to remote-privacy verification. + execFileSync('git', ['-C', ws2, 'remote', 'remove', 'origin']); + + // `bootstrap verify` core runs GREEN on machine 2, keyless. + const res = await verifyWorkspace(engine, ws2, { + sourceId: 'workspace', + gbrainHomeDir: join(machine2Home, '.gbrain'), + capabilities: KEYLESS, + skipHooksSmoke: true, + }); + if (!res.ok) console.error(res.report); + expect(res.ok).toBe(true); + expect(res.checks.find((c) => c.id === 'roundtrip')!.ok).toBe(true); + + // Recall the machine-1-only fact through the REAL keyword-search query op. + // The Rivermouth marker was authored on machine 1 and reached machine 2 + // ONLY via the repo, so a hit proves the multi-device promise end to end. + const queryOp = operations.find((o) => o.name === 'query')!; + const ctx: OperationContext = { + engine, + config: { engine: 'pglite' } as never, + logger: { info: () => {}, warn: () => {}, error: () => {} }, + dryRun: false, + remote: false, + sourceId: 'workspace', + }; + const markerHit = await queryOp.handler(ctx, { query: 'Rivermouth fulfillment center warehouse', limit: 10, expand: false }); + expect(JSON.stringify(markerHit)).toContain(MACHINE1_PAGE_SLUG); + const goldHit = await queryOp.handler(ctx, { query: 'warehouse navigation robots startup', limit: 10, expand: false }); + expect(JSON.stringify(goldHit)).toContain(MACHINE1_PAGE_SLUG); + } finally { + // The query op fires retrieval telemetry (last_retrieved_at / search + // stats) as a background write. PGLite serializes one connection, so a + // yield-then-flush drains that in-flight write BEFORE close() — issuing + // db.close() with a query still queued otherwise deadlocks disconnect. + await new Promise((r) => setTimeout(r, 100)); + await engine.executeRaw('SELECT 1').catch(() => {}); + await engine.disconnect(); + } + }, 240_000); + test('template clone (initialized: false) is REFUSED with the agent-readable render pointer [CX2-1]', async () => { const ws3 = mkdtempSync(join(tmpdir(), 'gb-att-ws3-')); try { diff --git a/test/e2e/bootstrap-hook-under-serve.serial.test.ts b/test/e2e/bootstrap-hook-under-serve.serial.test.ts index cf32696b5..1ed6df16d 100644 --- a/test/e2e/bootstrap-hook-under-serve.serial.test.ts +++ b/test/e2e/bootstrap-hook-under-serve.serial.test.ts @@ -39,6 +39,12 @@ import { resolveSocketPath, ipcSecretPath } from '../../src/core/context/resolve import { LiveServeLockError } from '../../src/core/pglite-lock.ts'; import { createEngine } from '../../src/core/engine-factory.ts'; import { addSource } from '../../src/core/sources-ops.ts'; +import { + loadCorpusPages, + loadCorpusBeliefs, + loadCorpusBeliefData, + loadCorpusQueries, +} from '../helpers/bootstrap-corpus.ts'; const REPO_ROOT = resolve(import.meta.dir, '..', '..'); const TRANSCRIPT_FIXTURE = join(REPO_ROOT, 'test', 'fixtures', 'conversation-formats', 'claude-code.jsonl'); @@ -120,12 +126,20 @@ beforeAll(async () => { mkdirSync(join(ws, 'brain'), { recursive: true }); // Pre-init the brain in-process (schema + source) so the serve subprocess - // boots fast and doesn't spend its boot budget on migrations. + // boots fast and doesn't spend its boot budget on migrations. Also SEED the + // synthetic corpus (pages + world/private beliefs) into the serve's source + // BEFORE the serve takes the single-writer lock — the serve then reads this + // committed data when it assembles turn context, so Pin 1 can assert on real + // recalled content (world belief present, private belief fenced out) instead + // of accepting an empty block [GAP 2]. const engineConfig = { engine: 'pglite' as const, database_path: dbDir }; const engine = await createEngine(engineConfig); await engine.connect(engineConfig); await engine.initSchema(); await addSource(engine, { id: 'workspace', localPath: join(ws, 'brain'), force: true }); + await loadCorpusPages(engine, { sourceId: 'workspace' }); + const seededBeliefs = await loadCorpusBeliefs(engine, { sourceId: 'workspace' }); + if (seededBeliefs < 1) throw new Error('corpus beliefs failed to seed — GAP 2 has nothing to recall'); await engine.disconnect(); // REAL serve subprocess. stdin stays open (a stdin EOF is serve's shutdown @@ -198,17 +212,25 @@ describe('bootstrap hook under a live serve (serial e2e) [A7]', () => { expect(existsSync(ipcSecretPath(dbDir))).toBe(true); }, 30_000); - test('Pin 1: user-prompt hook with a LIVE serve → exit 0 + context JSON or a documented degradation heartbeat', async () => { + test('Pin 1: user-prompt hook with a LIVE serve → exit 0 + additionalContext carrying the recalled WORLD belief, never the PRIVATE one', async () => { // Transcript fixture under the confinement seam root. const projRoot = join(tmpParent, 'projects'); mkdirSync(join(projRoot, 'p1'), { recursive: true }); const transcript = join(projRoot, 'p1', 'session.jsonl'); copyFileSync(TRANSCRIPT_FIXTURE, transcript); + // Drive the turn off a real gold recall case: its query is the prompt, its + // expected substring is a WORLD belief we seeded, and its must_not is the + // PRIVATE sibling belief the visibility fence must keep out of the block. + const beliefCase = loadCorpusQueries().find((q) => q.id === 'belief-recall-alice-standups'); + expect(beliefCase).toBeDefined(); + expect(beliefCase!.expect_substring).toBeDefined(); + expect(beliefCase!.must_not_substring).toBeDefined(); + const out = collectStdout(); const code = await runHook(['user-prompt'], { stdin: JSON.stringify({ - prompt: 'what do we know about widget-co?', + prompt: beliefCase!.query, session_id: 'hook-under-serve-1', transcript_path: transcript, }), @@ -224,16 +246,28 @@ describe('bootstrap hook under a live serve (serial e2e) [A7]', () => { expect(hb.event).toBe('user-prompt'); expect(hb.outcome).not.toBe('error'); + // Every distinctive fragment of a PRIVATE belief that must NEVER cross the + // IPC boundary (the meta-hook pins visibility=['world'] for the hook path). + const privateFragments = loadCorpusBeliefData() + .filter((b) => b.visibility === 'private') + .map((b) => b.text); + if (payload.length > 0) { - // Non-empty additionalContext JSON — the full happy-path contract. + // Happy path: assert the REAL recalled content, not length>0. The + // assembled block must carry the seeded WORLD belief the gold case + // expects, and NONE of the private beliefs. const parsed = JSON.parse(payload) as { hookSpecificOutput: { hookEventName: string; additionalContext: string }; }; expect(parsed.hookSpecificOutput.hookEventName).toBe('UserPromptSubmit'); - expect(parsed.hookSpecificOutput.additionalContext.length).toBeGreaterThan(0); + const ctx = parsed.hookSpecificOutput.additionalContext; + expect(ctx).toContain(beliefCase!.expect_substring!); + expect(ctx).not.toContain(beliefCase!.must_not_substring!); + for (const frag of privateFragments) expect(ctx).not.toContain(frag); } else { // Empty stdout MUST be a documented degradation recorded in the - // heartbeat — never a silent nothing, never a wiring-broken reason. + // heartbeat — never a silent nothing, never a wiring-broken reason. A + // degraded turn also, trivially, leaked no private content. expect(hb.reason).toBeDefined(); expect(DOCUMENTED_LIVE_SERVE_REASONS.has(hb.reason!)).toBe(true); } diff --git a/test/e2e/bootstrap-keyed-postgres.serial.test.ts b/test/e2e/bootstrap-keyed-postgres.serial.test.ts new file mode 100644 index 000000000..aa429b4f8 --- /dev/null +++ b/test/e2e/bootstrap-keyed-postgres.serial.test.ts @@ -0,0 +1,332 @@ +/** + * GAP 6 — keyed mode + Postgres bootstrap coverage. + * + * Every other bootstrap test pins the KEYLESS posture (embeddings.available: + * false) against embedded PGLite with DATABASE_URL stripped. Nothing proved + * that (a) a keyed brain actually does SEMANTIC recall that BM25 can't, that + * (b) a real chat key drives auto fact-extraction the keyless path can't, or + * that (c) the bootstrap verify probes run on a real Postgres/Supabase engine + * instead of only PGLite. This file closes all three, each env-gated with + * `skipIf` so a CI run without secrets stays green while the path runs for + * real when the key / DATABASE_URL is present. + * + * Serial: mutates process.env.GBRAIN_HOME and the process-global AI gateway. + * Deliberately does NOT strip DATABASE_URL (the Postgres describe needs it). + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { PGLiteEngine } from '../../src/core/pglite-engine.ts'; +import { PostgresEngine } from '../../src/core/postgres-engine.ts'; +import { addSource } from '../../src/core/sources-ops.ts'; +import { loadCorpusPages } from '../helpers/bootstrap-corpus.ts'; +import { runEmbedCore } from '../../src/commands/embed.ts'; +import { hybridSearch } from '../../src/core/search/hybrid.ts'; +import { runSchemaTransition } from '../../src/core/retrieval-upgrade-planner.ts'; +import { extractTakesFromPages } from '../../src/core/extract-takes-from-pages.ts'; +import { configureGateway, resetGateway } from '../../src/core/ai/gateway.ts'; +import { + verifyWorkspace, + VERIFY_PROBE_SLUG, + VERIFY_PROBE_ENTITY_SLUG, + VERIFY_MAGIC_TOKEN, +} from '../../src/core/bootstrap/verify.ts'; +import type { CapabilityReport } from '../../src/core/capability.ts'; + +const OPENAI = process.env.OPENAI_API_KEY; +const VOYAGE = process.env.VOYAGE_API_KEY; +const ANTHROPIC = process.env.ANTHROPIC_API_KEY; +const DATABASE_URL = process.env.DATABASE_URL; + +const KEYLESS: CapabilityReport = { + embeddings: { available: false }, + extraction: { available: false }, + search: 'keyword-only', + mode: 'keyless', +}; + +/** Resolve the embedding provider the same way `gbrain init` would from env. + * OpenAI (1536d) matches the committed `embedding` column width, so no schema + * transition is needed; Voyage (1024d) requires resizing the column exactly + * the way the provider-agnostic migration does. */ +function resolveEmbedProvider(): + | { model: string; dims: number; env: Record; cfg: Record } + | null { + if (OPENAI) { + return { + model: 'openai:text-embedding-3-large', + dims: 1536, + env: { OPENAI_API_KEY: OPENAI }, + cfg: { openai_api_key: OPENAI }, + }; + } + if (VOYAGE) { + return { + model: 'voyage:voyage-3-large', + dims: 1024, + env: { VOYAGE_API_KEY: VOYAGE }, + cfg: { voyage_api_key: VOYAGE }, + }; + } + return null; +} + +/** Chat provider for the LLM fact-extraction path (embeddings not required). */ +function resolveChatProvider(): + | { model: string; env: Record } + | null { + if (ANTHROPIC) return { model: 'anthropic:claude-haiku-4-5', env: { ANTHROPIC_API_KEY: ANTHROPIC } }; + if (OPENAI) return { model: 'openai:gpt-4o-mini', env: { OPENAI_API_KEY: OPENAI } }; + return null; +} + +// ─────────────────────────────────────────────────────────────────────────── +// (a) Keyed semantic recall: a PARAPHRASE query (no shared keyword with the +// target page) recalls the right page via real embeddings — something the +// keyless BM25 path structurally cannot do (disjoint tokens ⇒ zero lexical +// hits). The contrast is asserted directly: the keyword arm returns the +// page NOT AT ALL, the vector arm surfaces it. +// ─────────────────────────────────────────────────────────────────────────── +describe.skipIf(!OPENAI && !VOYAGE)('keyed semantic recall (real embeddings)', () => { + let engine: PGLiteEngine; + let home: string; + let root: string; + let prevHome: string | undefined; + let embedded = 0; + + beforeAll(async () => { + const prov = resolveEmbedProvider()!; + root = mkdtempSync(join(tmpdir(), 'gb-keyed-')); + home = join(root, '.gbrain'); + mkdirSync(home, { recursive: true }); + writeFileSync( + join(home, 'config.json'), + JSON.stringify({ + engine: 'pglite', + embedding_model: prov.model, + embedding_dimensions: prov.dims, + ...prov.cfg, + }), + ); + prevHome = process.env.GBRAIN_HOME; + process.env.GBRAIN_HOME = root; + + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); + // Size the primary embedding column to the provider's width (OpenAI is + // already 1536; Voyage needs the resize the real migration performs). + if (prov.dims !== 1536) await runSchemaTransition(engine, prov.dims); + await addSource(engine, { id: 'workspace', localPath: join(root, 'brain'), force: true }); + + // Load the corpus KEYLESS (no gateway yet, so put_page never auto-embeds), + // then configure the real gateway and run the embed sweep for real. + mkdirSync(join(root, 'brain'), { recursive: true }); + await loadCorpusPages(engine, { sourceId: 'workspace' }); + configureGateway({ embedding_model: prov.model, embedding_dimensions: prov.dims, env: prov.env }); + const res = await runEmbedCore(engine, { stale: true, sourceId: 'workspace', quiet: true }); + embedded = res.embedded; + }, 300_000); + + afterAll(async () => { + try { await engine.disconnect(); } catch { /* noop */ } + resetGateway(); + if (prevHome === undefined) delete process.env.GBRAIN_HOME; + else process.env.GBRAIN_HOME = prevHome; + rmSync(root, { recursive: true, force: true }); + }); + + test('the embed sweep actually produced vectors', () => { + // Guards against a false-green semantic assertion below: if nothing + // embedded, a "recall" could only be lexical. + expect(embedded).toBeGreaterThan(0); + }); + + // Each case: a query whose tokens are DISJOINT from the target page so BM25 + // cannot reach it; only a real embedding can. Asserted both ways. + const cases = [ + { + name: 'summit-robotics', + slug: 'companies/summit-robotics', + paraphrase: 'company making self-guided fulfillment-center machinery', + }, + { + name: 'hybrid-retrieval', + slug: 'concepts/hybrid-retrieval', + paraphrase: 'combining vector similarity with lexical token matching for lookup', + }, + ]; + + for (const c of cases) { + test(`paraphrase "${c.paraphrase.slice(0, 32)}…" recalls ${c.name} via vectors, not keywords`, async () => { + // The keyless arm: pure BM25. Disjoint tokens ⇒ the target is unreachable. + const kw = await engine.searchKeyword(c.paraphrase, { limit: 10, sourceId: 'workspace' }); + expect(kw.map((r) => r.slug)).not.toContain(c.slug); + + // The keyed arm: real query embedding + vector search surfaces it. + const results = await hybridSearch(engine, c.paraphrase, { + limit: 10, + sourceId: 'workspace', + mode: 'balanced', + }); + const slugs = results.map((r) => r.slug); + expect(slugs).toContain(c.slug); + // And it ranks as a confident hit, not a tail match. + expect(slugs.slice(0, 5)).toContain(c.slug); + }, 120_000); + } +}); + +// ─────────────────────────────────────────────────────────────────────────── +// (b) Keyed auto fact-extraction: a real chat key drives the takes classifier +// over concept prose and lands a gradeable claim. The keyless path returns +// `llm_unavailable` with zero claims — the exact capability a key unlocks. +// ─────────────────────────────────────────────────────────────────────────── +describe.skipIf(!OPENAI && !ANTHROPIC)('keyed auto fact-extraction (LLM takes)', () => { + let engine: PGLiteEngine; + let home: string; + let root: string; + let prevHome: string | undefined; + const SLUG = 'concepts/keyed-extraction-probe'; + // >200 chars of concept prose carrying clear gradeable claims (facts/takes), + // so the classifier has something real to extract. + const PROSE = [ + 'Hybrid retrieval systems consistently outperform pure keyword search on', + 'recall-sensitive corpora because dense embeddings capture paraphrase', + 'relationships that sparse lexical matching misses. The reranker stage is', + 'the single highest-leverage component: teams that skip it leave roughly a', + 'third of achievable precision on the table. In 2026 most production memory', + 'stacks will standardize on a reranked hybrid pipeline as the default, and', + 'brains that stay keyword-only will feel noticeably worse to their users.', + ].join(' '); + + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), 'gb-extract-')); + home = join(root, '.gbrain'); + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, 'config.json'), JSON.stringify({ engine: 'pglite' })); + prevHome = process.env.GBRAIN_HOME; + process.env.GBRAIN_HOME = root; + + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); + await engine.putPage(SLUG, { + type: 'concept', + title: 'Keyed Extraction Probe', + compiled_truth: PROSE, + }); + }, 120_000); + + afterAll(async () => { + try { await engine.disconnect(); } catch { /* noop */ } + resetGateway(); + if (prevHome === undefined) delete process.env.GBRAIN_HOME; + else process.env.GBRAIN_HOME = prevHome; + rmSync(root, { recursive: true, force: true }); + }); + + test('keyless → llm_unavailable, zero claims; keyed → a real fact/take lands', async () => { + // Keyless contrast: no chat gateway ⇒ the sweep cannot extract anything. + resetGateway(); + const keyless = await extractTakesFromPages(engine, { bootstrapEnabled: true }); + expect(keyless.llm_unavailable).toBe(true); + expect(keyless.claims_extracted).toBe(0); + const beforeRows = await engine.executeRaw<{ n: number }>( + `SELECT COUNT(*)::int AS n FROM takes t JOIN pages p ON p.id = t.page_id WHERE p.slug = $1`, + [SLUG], + ); + expect(beforeRows[0].n).toBe(0); + + // Keyed: configure the real chat model and run the same sweep. + const chat = resolveChatProvider()!; + configureGateway({ chat_model: chat.model, env: chat.env }); + const keyed = await extractTakesFromPages(engine, { bootstrapEnabled: true, holder: 'test' }); + expect(keyed.llm_unavailable).toBe(false); + expect(keyed.claims_extracted).toBeGreaterThan(0); + + // The claim physically landed in the takes table on OUR probe page. + const rows = await engine.executeRaw<{ claim: string; kind: string }>( + `SELECT t.claim, t.kind FROM takes t JOIN pages p ON p.id = t.page_id WHERE p.slug = $1`, + [SLUG], + ); + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((r) => r.claim.trim().length > 0)).toBe(true); + expect(rows.every((r) => ['fact', 'take', 'bet', 'hunch'].includes(r.kind))).toBe(true); + }, 180_000); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// (c) Postgres bootstrap: drive the verify core's roundtrip + graph_floor + +// magic_moment probes against a REAL Postgres engine. First bootstrap +// coverage off PGLite — catches PGLite-only assumptions in the probes +// (tsvector recall, graph-edge materialization, probe cleanup DDL). +// ─────────────────────────────────────────────────────────────────────────── +describe.skipIf(!DATABASE_URL)('Postgres bootstrap verify (real Postgres)', () => { + let engine: PostgresEngine; + let home: string; + let root: string; + let ws: string; + let prevHome: string | undefined; + + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), 'gb-pg-verify-')); + home = join(root, '.gbrain'); + mkdirSync(join(home, 'bootstrap'), { recursive: true }); + writeFileSync(join(home, 'config.json'), JSON.stringify({ engine: 'postgres' })); + ws = mkdtempSync(join(tmpdir(), 'gb-pg-verify-ws-')); + mkdirSync(join(ws, 'brain'), { recursive: true }); + prevHome = process.env.GBRAIN_HOME; + process.env.GBRAIN_HOME = root; + + engine = new PostgresEngine(); + await engine.connect({ database_url: DATABASE_URL! }); + await engine.initSchema(); + await addSource(engine, { id: 'workspace', localPath: join(ws, 'brain'), force: true }); + }, 60_000); + + afterAll(async () => { + try { await engine.disconnect(); } catch { /* noop */ } + if (prevHome === undefined) delete process.env.GBRAIN_HOME; + else process.env.GBRAIN_HOME = prevHome; + rmSync(root, { recursive: true, force: true }); + rmSync(ws, { recursive: true, force: true }); + }); + + test('roundtrip + graph_floor + magic_moment pass on Postgres; probes cleaned up', async () => { + const res = await verifyWorkspace(engine, ws, { + sourceId: 'workspace', + gbrainHomeDir: home, + capabilities: KEYLESS, + skipHooksSmoke: true, + sweepBudgetMs: 20_000, + }); + + // The three probes the task targets — each must pass on real Postgres. + for (const c of res.checks.filter((c) => c.id === 'roundtrip')) expect(c.ok).toBe(true); + const graph = res.checks.find((c) => c.id === 'graph_floor'); + expect(graph?.ok).toBe(true); + const magic = res.checks.find((c) => c.id === 'magic_moment'); + expect(magic?.ok).toBe(true); + + // Probe cleanup [G13] must hold on the Postgres DDL path too. + const probePages = await engine.executeRaw<{ n: number }>( + `SELECT COUNT(*)::int AS n FROM pages WHERE source_id = $1 AND slug IN ($2, $3)`, + ['workspace', VERIFY_PROBE_SLUG, VERIFY_PROBE_ENTITY_SLUG], + ); + expect(probePages[0].n).toBe(0); + const probeFacts = await engine.executeRaw<{ n: number }>( + `SELECT COUNT(*)::int AS n FROM facts WHERE source_id = $1 AND source_markdown_slug = $2`, + ['workspace', VERIFY_PROBE_SLUG], + ); + expect(probeFacts[0].n).toBe(0); + // Sanity: the magic-token probe fact did not survive either. + const tokenFacts = await engine.executeRaw<{ n: number }>( + `SELECT COUNT(*)::int AS n FROM facts WHERE source_id = $1 AND source_markdown_slug = $2 AND fact LIKE $3`, + ['workspace', VERIFY_PROBE_SLUG, `%${VERIFY_MAGIC_TOKEN}%`], + ); + expect(tokenFacts[0].n).toBe(0); + }, 120_000); +}); diff --git a/test/e2e/bootstrap-lifecycle.serial.test.ts b/test/e2e/bootstrap-lifecycle.serial.test.ts index 8a24bd956..fa0c63949 100644 --- a/test/e2e/bootstrap-lifecycle.serial.test.ts +++ b/test/e2e/bootstrap-lifecycle.serial.test.ts @@ -201,14 +201,23 @@ exit 0 ); chmodSync(join(shimDir, 'claude'), 0o755); + // STATEFUL codex shim: `mcp add` records the exact command+env line, `mcp + // get` echoes it back, `mcp list`/`remove` reflect real state. This is what + // lets verifyMcpTargetsWorkspace ([FIX7]) actually pass/fail — an inert shim + // that always printed "gbrain" made the mismatch detector unfalsifiable. + // State lives next to the argv record so it's cleaned up with shimDir. writeFileSync( join(shimDir, 'codex'), `#!/bin/sh echo "codex $*" >> "$GB_FAKE_RECORD" +STATE="$(dirname "$GB_FAKE_RECORD")/codex-mcp.state" case "$1 $2" in - "mcp add") exit 0 ;; - "mcp list") echo "gbrain"; exit 0 ;; - "mcp remove") exit 0 ;; + "mcp add") printf '%s\\n' "$*" > "$STATE"; exit 0 ;; + "mcp get") + [ -f "$STATE" ] || { echo "no such MCP server: $3" >&2; exit 1; } + cat "$STATE"; exit 0 ;; + "mcp list") [ -f "$STATE" ] && echo "gbrain: stdio serve"; exit 0 ;; + "mcp remove") rm -f "$STATE"; exit 0 ;; esac exit 0 `, @@ -442,12 +451,18 @@ describe('bootstrap lifecycle (serial e2e)', () => { // binding [G1]); NO hooks written — the pull protocol is the per-turn // seam, stated plainly. const gbrainBin = join(shimDir, 'gbrain'); - expect( - await runBootstrap(['hooks', '--workspace', ws2, '--harness', 'codex', '--gbrain-bin', gbrainBin], { + const { result: hooksCode, out: hooksOut } = await captureStdout(() => + runBootstrap(['hooks', '--workspace', ws2, '--harness', 'codex', '--gbrain-bin', gbrainBin], { runner: testRunner, }), - ).toBe(0); + ); + expect(hooksCode).toBe(0); expect(record()).toContain('codex mcp add gbrain --env GBRAIN_SOURCE=workspace'); + // [FIX7] the stateful shim echoes the recorded reg on `mcp get`, so the + // registration smoke VERIFIES it targets THIS workspace (binary + source) + // rather than falling back to the substring probe. A regressed + // verifyMcpTargetsWorkspace (mismatch/unknown) would change this line. + expect(hooksOut).toContain('MCP registered with codex (scope: user-global) — verified targeting this workspace.'); expect(existsSync(join(ws2, '.claude', 'settings.local.json'))).toBe(false); const receipt = readReceipt(home); // Receipt is machine-scoped last-attach-wins: ws2's registration replaced ws's record. diff --git a/test/e2e/bootstrap-magic-moment.serial.test.ts b/test/e2e/bootstrap-magic-moment.serial.test.ts new file mode 100644 index 000000000..4247b0f4d --- /dev/null +++ b/test/e2e/bootstrap-magic-moment.serial.test.ts @@ -0,0 +1,257 @@ +/** + * Cross-session "magic moment" — the keyless agent-authored memory contract + * proven across a REAL engine session boundary (agent-bootstrap GAP 1). + * + * The shipped `gbrain bootstrap verify` magic_moment check writes a `## Facts` + * fence, reconciles it in the SAME connection, and reads it straight back with + * one SQL statement. That proves the fence parser runs — but it can NOT prove + * the fact survives a process/connection boundary, that the ZERO-LLM sweep pass + * actually banks it to disk, or that a keyless BM25 recall (the query op) finds + * it afterward scoped to the right source. This test closes that gap: + * + * Session 1 (engine A): init a real on-disk PGLite brain + two sources, + * author a fact through the REAL write path (put_page + * op → `## Facts` fence), run the maintenance sweep's + * zero-LLM fence pass, then DISCONNECT (engine A is + * gone — the session boundary is real, not simulated). + * Session 2 (engine B): a BRAND-NEW engine opens the SAME database_path with + * NO initSchema (the data must already be on disk), and + * we prove three things that the same-connection check + * can't: + * 1. persistence — the reconciled fact row survived + * to disk and reopens in a fresh connection. + * 2. keyless recall — the `query` op (BM25, no API + * key) returns the fence page BY CONTENT. + * 3. source-scope routing — the same recall scoped to + * a DIFFERENT registered source returns nothing. + * + * Keyless is forced deterministically (every provider key stripped from the + * env + a keyless capability report handed to the sweep) so the path under + * test is exactly the fresh-install / keyless posture, and CI without secrets + * runs it identically. + * + * Serial: cold PGLite init + a real disconnect/reopen cycle; env is + * save/restored; temp dirs are cleaned no matter what. + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { createEngine } from '../../src/core/engine-factory.ts'; +import { addSource } from '../../src/core/sources-ops.ts'; +import { runMaintenanceSweep } from '../../src/core/sweep.ts'; +import type { CapabilityReport } from '../../src/core/capability.ts'; +import { operations, type Operation, type OperationContext } from '../../src/core/operations.ts'; +import type { BrainEngine } from '../../src/core/engine.ts'; + +// ── Fixture: a distinctive, wikilink-free fence fact ──────────────────────── +// The claim carries rare tokens so a keyless BM25 recall is unambiguous, and no +// `|` so the fence-table markdown parses as a single claim cell. +const PROBE_SLUG = 'wiki/magic-moment-probe'; +const MAGIC_PHRASE = + 'The migratory narwhal expedition logged forty-two beacons near the aurora fjord'; +const RECALL_QUERY = 'narwhal expedition beacons aurora fjord'; + +const PROBE_CONTENT = `--- +title: Magic Moment Probe +type: concept +--- + +# Magic Moment Probe + +A keyless agent-authored memory probe for the cross-session magic-moment e2e. + +## Facts + + +| # | claim | kind | confidence | visibility | notability | valid_from | valid_until | source | context | +|---|-------|------|------------|------------|------------|------------|-------------|--------|---------| +| 1 | ${MAGIC_PHRASE} | fact | 1.0 | world | low | | | magic-moment-test | | + +`; + +// Keyless posture handed to the sweep so the fence pass runs zero-LLM and the +// corpus-ingest pass never reaches for a network key. +const KEYLESS_CAPS: CapabilityReport = { + embeddings: { available: false }, + extraction: { available: false }, + search: 'keyword-only', + mode: 'keyless', +}; + +const WORKSPACE_SOURCE = 'workspace'; +const OTHER_SOURCE = 'other'; + +// Every provider key that detectCapabilities / the sweep could observe. Stripped +// so keyless is deterministic (and no accidental live embedding cost/flake). +const PROVIDER_KEYS = [ + 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'ZEROENTROPY_API_KEY', 'OPENROUTER_API_KEY', + 'VOYAGE_API_KEY', 'DASHSCOPE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY', 'GEMINI_API_KEY', +]; +const ENV_KEYS = [ + 'GBRAIN_HOME', 'GBRAIN_DATABASE_URL', 'DATABASE_URL', 'GBRAIN_BRAIN_ID', + 'GBRAIN_SOURCE', ...PROVIDER_KEYS, +]; +const SAVED_ENV: Record = {}; + +let tmpParent: string; +let home: string; +let dbDir: string; +let wsBrain: string; +let otherBrain: string; +let engineConfig: { engine: 'pglite'; database_path: string }; +let session2: BrainEngine; + +function findOp(name: string): Operation { + const op = operations.find((o) => o.name === name); + if (!op) throw new Error(`op not registered: ${name}`); + return op; +} + +/** Trusted-local ctx (remote:false) so put_page fires its post-hooks and the + * query op runs the local hybrid path — mirrors the corpus helper's trustedCtx. */ +function trustedCtx(engine: BrainEngine, sourceId: string): OperationContext { + return { + engine, + config: { engine: 'pglite', database_path: dbDir } as never, + logger: { info: () => {}, warn: () => {}, error: () => {} }, + dryRun: false, + remote: false, + sourceId, + }; +} + +async function factsMatching(engine: BrainEngine, sourceId: string): Promise { + const rows = await engine.executeRaw<{ fact: string }>( + `SELECT fact FROM facts WHERE source_id = $1 AND visibility = 'world' AND fact LIKE $2`, + [sourceId, `%${MAGIC_PHRASE}%`], + ); + return rows.map((r) => r.fact); +} + +beforeAll(async () => { + for (const k of ENV_KEYS) SAVED_ENV[k] = process.env[k]; + // Ambient-state strip: a dev/CI DATABASE_URL must not flip the sandboxed brain + // to Postgres, and a real provider key must not turn the keyless path semantic. + delete process.env.GBRAIN_DATABASE_URL; + delete process.env.DATABASE_URL; + delete process.env.GBRAIN_BRAIN_ID; + delete process.env.GBRAIN_SOURCE; + for (const k of PROVIDER_KEYS) delete process.env[k]; + + tmpParent = mkdtempSync(join(tmpdir(), 'gb-magic-')); + home = join(tmpParent, '.gbrain'); + mkdirSync(home, { recursive: true }); + dbDir = join(tmpParent, 'db'); + wsBrain = join(tmpParent, 'ws', 'brain'); + otherBrain = join(tmpParent, 'other', 'brain'); + mkdirSync(wsBrain, { recursive: true }); + mkdirSync(otherBrain, { recursive: true }); + process.env.GBRAIN_HOME = tmpParent; + + writeFileSync( + join(home, 'config.json'), + JSON.stringify({ engine: 'pglite', database_path: dbDir, embedding_dimensions: 1536 }, null, 2), + ); + engineConfig = { engine: 'pglite', database_path: dbDir }; + + // ── SESSION 1 (engine A): author + reconcile + DISCONNECT ──────────────── + const engineA = await createEngine(engineConfig); + await engineA.connect(engineConfig); + await engineA.initSchema(); + await addSource(engineA, { id: WORKSPACE_SOURCE, localPath: wsBrain, force: true }); + await addSource(engineA, { id: OTHER_SOURCE, localPath: otherBrain, force: true }); + + // Author through the REAL write path: put_page op (remote:false) with a + // `## Facts` fence — the keyless agent-authored memory route. + const putPage = findOp('put_page'); + await putPage.handler(trustedCtx(engineA, WORKSPACE_SOURCE), { + slug: PROBE_SLUG, + content: PROBE_CONTENT, + }); + + // Reconcile the fence into the facts index via the sweep's ZERO-LLM pass — + // the exact `gbrain sweep --once` fence path, keyless. + const report = await runMaintenanceSweep(engineA, { + sourceId: WORKSPACE_SOURCE, + budgetMs: 60_000, + capabilities: KEYLESS_CAPS, + }); + // Session-1 sanity: the fence really reconciled in THIS connection. + if (report.factsReconciled < 1) { + throw new Error( + `session 1 fence reconciliation failed (factsReconciled=${report.factsReconciled}, ` + + `skipped=${JSON.stringify(report.skipped)}) — cannot test cross-session recall`, + ); + } + + // The session boundary: engine A is fully torn down. Session 2 opens a fresh + // engine against the same on-disk directory. + await engineA.disconnect(); + + // ── SESSION 2 (engine B): fresh engine, SAME db, NO initSchema ─────────── + session2 = await createEngine(engineConfig); + await session2.connect(engineConfig); +}, 180_000); + +afterAll(async () => { + try { + await session2?.disconnect(); + } catch { + /* already down */ + } + for (const k of ENV_KEYS) { + if (SAVED_ENV[k] === undefined) delete process.env[k]; + else process.env[k] = SAVED_ENV[k]; + } + try { + rmSync(tmpParent, { recursive: true, force: true }); + } catch { + /* best effort */ + } +}); + +describe('bootstrap magic moment — cross-session keyless recall (serial e2e) [GAP1]', () => { + test('persistence: the fence-reconciled fact survives to disk and reopens in a fresh engine', async () => { + // engine A wrote + disconnected; engine B never ran initSchema. If the + // reconciled row didn't bank to disk, this comes back empty. + const facts = await factsMatching(session2, WORKSPACE_SOURCE); + expect(facts.length).toBeGreaterThanOrEqual(1); + expect(facts[0]).toContain(MAGIC_PHRASE); + }, 60_000); + + test('keyless recall: the query op (BM25) returns the fence page BY CONTENT across the session boundary', async () => { + const queryOp = findOp('query'); + const results = (await queryOp.handler(trustedCtx(session2, WORKSPACE_SOURCE), { + query: RECALL_QUERY, + limit: 10, + expand: false, + source_id: WORKSPACE_SOURCE, + })) as Array<{ slug: string; chunk_text: string }>; + + // Real content assertion: the fence page comes back AND the recalled chunk + // carries the authored fact text — not merely "some non-empty result". + expect(Array.isArray(results)).toBe(true); + const hit = results.find((r) => r.slug === PROBE_SLUG); + expect(hit).toBeDefined(); + expect(JSON.stringify(results)).toContain(MAGIC_PHRASE); + }, 60_000); + + test('source-scope routing: the same recall scoped to a DIFFERENT source returns neither the fact nor the page', async () => { + // The fact and page live in `workspace`; `other` is registered but empty. + // A cross-session query scoped to `other` must not leak workspace content. + const otherFacts = await factsMatching(session2, OTHER_SOURCE); + expect(otherFacts.length).toBe(0); + + const queryOp = findOp('query'); + const results = (await queryOp.handler(trustedCtx(session2, OTHER_SOURCE), { + query: RECALL_QUERY, + limit: 10, + expand: false, + source_id: OTHER_SOURCE, + })) as Array<{ slug: string; chunk_text: string }>; + expect(results.find((r) => r.slug === PROBE_SLUG)).toBeUndefined(); + expect(JSON.stringify(results)).not.toContain(MAGIC_PHRASE); + }, 60_000); +}); diff --git a/test/e2e/bootstrap-persistence.serial.test.ts b/test/e2e/bootstrap-persistence.serial.test.ts new file mode 100644 index 000000000..b0e4f9da9 --- /dev/null +++ b/test/e2e/bootstrap-persistence.serial.test.ts @@ -0,0 +1,163 @@ +/** + * GAP 7 — session-end persistence chain, UNMOCKED end-to-end. + * + * `test/hook-command.serial.test.ts` proves session-end up to the mocked + * `io.spawnPush` seam (it only records that a push WOULD fire). This file + * closes the remaining gap: it drives the session-end hook against a REAL + * local bare git remote with the REAL push implementation — the same + * `workspacePush` the detached `gbrain sources push` child runs — so the full + * hook → scan → commit → push chain is exercised and its outcome verified on + * the remote. + * + * The only concession to a hermetic test is `allowUnverifiedRemote: true`: + * `workspacePush`'s privacy gate can only confirm PRIVATE visibility for a + * real GitHub origin via `gh`, which a `file://` bare remote isn't — every + * sibling `workspace-push.serial.test.ts` case makes the same concession. The + * scan/commit/push machinery under test is otherwise identical to production. + * + * Serial: mutates HOME/GBRAIN_HOME + spawns git subprocesses. All secrets are + * synthetic fixtures; GBRAIN_GIT_ALLOW_FILE_TRANSPORT=1 permits file transport. + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; + +import { runHook } from '../../src/commands/hook.ts'; +import { workspacePush, type WorkspacePushResult } from '../../src/core/workspace-push.ts'; +import { writeManifest } from '../../src/core/bootstrap/format.ts'; + +const OPENAI_KEY = 'sk-' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4'; +const ENV_KEYS = ['HOME', 'GBRAIN_HOME', 'DATABASE_URL', 'GBRAIN_DATABASE_URL', 'GBRAIN_HOOKS', 'GBRAIN_GIT_ALLOW_FILE_TRANSPORT'] as const; + +// #2943: env: process.env is REQUIRED — Bun snapshots env at startup, so a +// spawned git would otherwise be blind to beforeEach's HOME/GBRAIN_HOME swap. +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', ['-C', cwd, '-c', 'protocol.file.allow=always', ...args], { + stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', env: process.env, + }).trim(); +} +function originHead(bare: string): string { + return git(bare, 'rev-parse', 'refs/heads/main'); +} + +const MANIFEST = { + format_version: 1 as const, + initialized: true as const, + agent_name: 'persist-test', + created_by: 'test', + created_at: '2026-01-01T00:00:00.000Z', + source_id: 'workspace', +}; + +let root: string; +let bare: string; +let work: string; +let saved: Record; + +/** Real push path, in-process: exactly what `gbrain sources push --path` + * invokes. Captured so the test can await the chain the hook kicks off. */ +let pushes: Promise[]; +const realSpawnPush = (r: string) => { + pushes.push(workspacePush({ dir: r, branch: 'main', allowUnverifiedRemote: true })); +}; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'gb-persist-')); + saved = {}; + for (const k of ENV_KEYS) saved[k] = process.env[k]; + for (const k of ['DATABASE_URL', 'GBRAIN_DATABASE_URL', 'GBRAIN_HOOKS'] as const) delete process.env[k]; + process.env.HOME = mkdtempSync(join(root, 'home-')); + // CX2-8: GBRAIN_HOME is a PARENT dir → effective home is $HOME/.gbrain. + process.env.GBRAIN_HOME = process.env.HOME; + process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT = '1'; + + bare = mkdtempSync(join(root, 'origin-')) + '.git'; + execFileSync('git', ['init', '-q', '--bare', '-b', 'main', bare], { stdio: 'ignore', env: process.env }); + work = mkdtempSync(join(root, 'work-')); + execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, work], { stdio: 'ignore', env: process.env }); + git(work, 'config', 'user.email', 't@t.t'); + git(work, 'config', 'user.name', 'tester'); + writeFileSync(join(work, 'README.md'), 'init\n'); + git(work, 'add', 'README.md'); + git(work, 'commit', '-qm', 'init'); + git(work, 'push', '-q', 'origin', 'main'); + try { git(work, 'remote', 'set-head', 'origin', 'main'); } catch { /* */ } + + // The initialized manifest is the security boundary the hook gates on. + writeManifest(work, MANIFEST); + pushes = []; +}); + +afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + rmSync(root, { recursive: true, force: true }); +}); + +describe('session-end → real workspace push', () => { + test('authored session content actually lands on the bare remote', async () => { + // The agent authored real work into the brain during the session. + mkdirSync(join(work, 'brain'), { recursive: true }); + const authored = 'the acme-example roadmap decision: ship the memory layer in Q3'; + writeFileSync(join(work, 'brain', 'decision.md'), `# decision\n\n${authored}\n`); + + const before = originHead(bare); + const code = await runHook(['session-end'], { + write: () => {}, + cwd: work, + spawnPush: realSpawnPush, + stdin: JSON.stringify({ session_id: 'persist-ok', cwd: work }), + }); + expect(code).toBe(0); + + // The hook kicked off exactly one real push; await its completion. + expect(pushes).toHaveLength(1); + const res = await pushes[0]; + expect(res.status).toBe('pushed'); + expect(res.ok).toBe(true); + expect(res.committed).toBe(true); + + // Origin advanced, and the authored file+content are physically there. + expect(originHead(bare)).not.toBe(before); + const verify = mkdtempSync(join(root, 'verify-')); + execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, verify], { stdio: 'ignore', env: process.env }); + const landed = join(verify, 'brain', 'decision.md'); + expect(existsSync(landed)).toBe(true); + expect(readFileSync(landed, 'utf-8')).toContain(authored); + }, 60_000); + + test('a planted secret in authored content is BLOCKED at the push gate — nothing leaves', async () => { + mkdirSync(join(work, 'brain'), { recursive: true }); + // A genuine note plus a file that leaks a synthetic OpenAI key. + writeFileSync(join(work, 'brain', 'safe.md'), '# safe\n\nno secrets here\n'); + writeFileSync(join(work, 'brain', 'leak.md'), `# oops\n\napi key: ${OPENAI_KEY}\n`); + + const before = originHead(bare); + const code = await runHook(['session-end'], { + write: () => {}, + cwd: work, + spawnPush: realSpawnPush, + stdin: JSON.stringify({ session_id: 'persist-leak', cwd: work }), + }); + expect(code).toBe(0); + + expect(pushes).toHaveLength(1); + const res = await pushes[0]; + // The scan gate fired: nothing committed, nothing pushed, secret named. + expect(res.status).toBe('blocked_secrets'); + expect(res.ok).toBe(false); + expect(res.findings?.some((f) => f.file === 'brain/leak.md' && f.pattern === 'openai')).toBe(true); + // The secret VALUE never surfaces in the result payload. + expect(JSON.stringify(res).includes(OPENAI_KEY)).toBe(false); + + // Origin is untouched — the leak never left the machine. + expect(originHead(bare)).toBe(before); + const shipped = git(bare, 'ls-tree', '-r', '--name-only', 'main'); + expect(shipped).not.toContain('brain/leak.md'); + expect(shipped).not.toContain('brain/safe.md'); // blocked atomically — nothing in the batch shipped + }, 60_000); +}); diff --git a/test/fixtures/bootstrap-corpus/README.md b/test/fixtures/bootstrap-corpus/README.md new file mode 100644 index 000000000..bd02a7344 --- /dev/null +++ b/test/fixtures/bootstrap-corpus/README.md @@ -0,0 +1,27 @@ +# bootstrap-corpus fixture + +A tiny, hermetic, 100% synthetic brain corpus used by the agent-bootstrap end-to-end +tests. It is curated and re-authored (not copied wholesale) from the public gbrain-evals +synthetic corpora — `eval/data/synthetic-v1` (interlinked markdown pages with frontmatter, +`[[wikilinks]]`, and `## Timeline` sections), `eval/data/world-v1` (compiled-truth entity +JSON), and `eval/precisionmembench/fixtures` (preference/belief objects with visibility + +confidence). Every name here is an obvious placeholder (`alice-example`, `ridge-platform`, +`summit-robotics`, `vector-co`, …); no real person, company, or fund appears, per the repo +privacy iron rule. + +Contents: + +- `pages/*.md` — 12 interlinked pages (people, companies, concepts, meetings) with YAML + frontmatter and `[[wikilinks]]`. Filenames flatten the slug: `companies__ridge-platform.md` + loads as slug `companies/ridge-platform`. Four pages carry a `## Timeline` section. +- `beliefs.json` — belief/fact objects `{ text, entity_slug, visibility, confidence }`, a + mix of `world` and `private`, so a visibility-fence recall test can assert the private + ones are never surfaced. +- `queries.json` — gold recall cases `{ id, kind, query, expect_slug?, expect_substring?, + must_not_substring? }`, each with a deterministic answer given the pages/beliefs above. + +The loader in `test/helpers/bootstrap-corpus.ts` reads these files and writes the pages +through the real `put_page` operation handler (so auto-link, chunking, and search-vector +fire) and the beliefs through `engine.insertFact` (honoring each belief's visibility). +`loadCorpusQueries()` returns the parsed gold cases. Consume the loader read-only; do not +mutate the fixtures from a test. diff --git a/test/fixtures/bootstrap-corpus/beliefs.json b/test/fixtures/bootstrap-corpus/beliefs.json new file mode 100644 index 000000000..82c1d01b6 --- /dev/null +++ b/test/fixtures/bootstrap-corpus/beliefs.json @@ -0,0 +1,74 @@ +[ + { + "text": "alice-example prefers async written standups over live meetings.", + "entity_slug": "people/alice-example", + "visibility": "world", + "confidence": 0.9 + }, + { + "text": "ridge-platform ships PGLite as its default embedded engine for new installs.", + "entity_slug": "companies/ridge-platform", + "visibility": "world", + "confidence": 0.95 + }, + { + "text": "carol-fictional owns the hybrid-retrieval reranker and reviews all changes to it.", + "entity_slug": "people/carol-fictional", + "visibility": "world", + "confidence": 0.85 + }, + { + "text": "summit-robotics uses graph-traversal for warehouse path planning.", + "entity_slug": "companies/summit-robotics", + "visibility": "world", + "confidence": 0.9 + }, + { + "text": "bob-placeholder invests through vector-co and mentors alice-example.", + "entity_slug": "people/bob-placeholder", + "visibility": "world", + "confidence": 0.8 + }, + { + "text": "vector-co focuses its fund on memory-systems and robotics startups.", + "entity_slug": "companies/vector-co", + "visibility": "world", + "confidence": 0.75 + }, + { + "text": "synthesis-layers reduces token budget by pre-fetching a synopsis before recall.", + "entity_slug": "concepts/synthesis-layers", + "visibility": "world", + "confidence": 0.7 + }, + { + "text": "alice-example privately plans to step down as CEO of ridge-platform next year.", + "entity_slug": "people/alice-example", + "visibility": "private", + "confidence": 0.6 + }, + { + "text": "summit-robotics is quietly exploring an acquisition offer from a larger competitor.", + "entity_slug": "companies/summit-robotics", + "visibility": "private", + "confidence": 0.5 + }, + { + "text": "carol-fictional is unhappy with her equity grant and considering leaving ridge-platform.", + "entity_slug": "people/carol-fictional", + "visibility": "private", + "confidence": 0.55 + }, + { + "text": "vector-co marked down its ridge-platform stake in the latest internal report.", + "entity_slug": "companies/vector-co", + "visibility": "private", + "confidence": 0.5 + }, + { + "text": "bob-placeholder is negotiating a secret side letter with an undisclosed limited partner.", + "entity_slug": "people/bob-placeholder", + "visibility": "private", + "confidence": 0.5 + } +] diff --git a/test/fixtures/bootstrap-corpus/pages/companies__ridge-platform.md b/test/fixtures/bootstrap-corpus/pages/companies__ridge-platform.md new file mode 100644 index 000000000..c52615032 --- /dev/null +++ b/test/fixtures/bootstrap-corpus/pages/companies__ridge-platform.md @@ -0,0 +1,20 @@ +--- +type: company +title: Ridge Platform +aliases: [ridge, ridge-platform-inc] +--- + +# Ridge Platform + +A memory-systems company building agent retrieval infrastructure. Founded 2024. + +Led by CEO [[people/alice-example]] with staff engineer [[people/carol-fictional]]. +Ridge Platform's core research area is [[concepts/agent-memory]], and its flagship +retrieval stack is built on [[concepts/hybrid-retrieval]] and +[[concepts/synthesis-layers]]. Backed by investor [[companies/vector-co]]. + +## Timeline + +- **2024-04-02** | incorporated as an eval-frameworks startup +- **2025-07-19** | launched the hosted [[concepts/hybrid-retrieval]] service +- **2026-02-10** | raised a growth round from [[companies/vector-co]] diff --git a/test/fixtures/bootstrap-corpus/pages/companies__summit-robotics.md b/test/fixtures/bootstrap-corpus/pages/companies__summit-robotics.md new file mode 100644 index 000000000..6a605d309 --- /dev/null +++ b/test/fixtures/bootstrap-corpus/pages/companies__summit-robotics.md @@ -0,0 +1,20 @@ +--- +type: company +title: Summit Robotics +aliases: [summit] +--- + +# Summit Robotics + +A robotics startup building warehouse navigation robots. Founded 2024 by +[[people/bob-placeholder]]. + +Summit Robotics relies on [[concepts/graph-traversal]] for path planning and +partners with [[companies/ridge-platform]] on memory-systems research. Funded by +[[companies/vector-co]]. + +## Timeline + +- **2024-02-10** | founded by [[people/bob-placeholder]] +- **2024-11-01** | shipped the first [[concepts/graph-traversal]] navigation demo +- **2025-08-25** | signed a research partnership with [[companies/ridge-platform]] diff --git a/test/fixtures/bootstrap-corpus/pages/companies__vector-co.md b/test/fixtures/bootstrap-corpus/pages/companies__vector-co.md new file mode 100644 index 000000000..6a86c77ad --- /dev/null +++ b/test/fixtures/bootstrap-corpus/pages/companies__vector-co.md @@ -0,0 +1,12 @@ +--- +type: company +title: Vector Co +aliases: [vector, vector-capital] +--- + +# Vector Co + +An early-stage investment fund focused on memory-systems and robotics. + +Vector Co backs [[companies/ridge-platform]] and [[companies/summit-robotics]], and +partner [[people/bob-placeholder]] sits on both boards. diff --git a/test/fixtures/bootstrap-corpus/pages/concepts__agent-memory.md b/test/fixtures/bootstrap-corpus/pages/concepts__agent-memory.md new file mode 100644 index 000000000..a7eee8b99 --- /dev/null +++ b/test/fixtures/bootstrap-corpus/pages/concepts__agent-memory.md @@ -0,0 +1,12 @@ +--- +type: concept +title: agent memory +aliases: [agent-memory] +--- + +# agent memory + +The discipline of persisting and recalling facts across agent sessions. + +Core research area at [[companies/ridge-platform]]. Underpins +[[concepts/synthesis-layers]] and is retrieved through [[concepts/hybrid-retrieval]]. diff --git a/test/fixtures/bootstrap-corpus/pages/concepts__graph-traversal.md b/test/fixtures/bootstrap-corpus/pages/concepts__graph-traversal.md new file mode 100644 index 000000000..aa32d1137 --- /dev/null +++ b/test/fixtures/bootstrap-corpus/pages/concepts__graph-traversal.md @@ -0,0 +1,12 @@ +--- +type: concept +title: graph traversal +aliases: [graph-traversal] +--- + +# graph traversal + +A path-planning technique that walks a typed edge graph to find routes. + +Used in production at [[companies/summit-robotics]] and championed by +[[people/bob-placeholder]]. Related to [[concepts/agent-memory]] graph recall. diff --git a/test/fixtures/bootstrap-corpus/pages/concepts__hybrid-retrieval.md b/test/fixtures/bootstrap-corpus/pages/concepts__hybrid-retrieval.md new file mode 100644 index 000000000..8e406aac2 --- /dev/null +++ b/test/fixtures/bootstrap-corpus/pages/concepts__hybrid-retrieval.md @@ -0,0 +1,12 @@ +--- +type: concept +title: hybrid retrieval +aliases: [hybrid-retrieval] +--- + +# hybrid retrieval + +A retrieval method that fuses dense embeddings with sparse keyword search. + +Built by [[people/carol-fictional]] at [[companies/ridge-platform]]. Feeds +[[concepts/synthesis-layers]] and complements [[concepts/agent-memory]]. diff --git a/test/fixtures/bootstrap-corpus/pages/concepts__synthesis-layers.md b/test/fixtures/bootstrap-corpus/pages/concepts__synthesis-layers.md new file mode 100644 index 000000000..2aaa80ee4 --- /dev/null +++ b/test/fixtures/bootstrap-corpus/pages/concepts__synthesis-layers.md @@ -0,0 +1,17 @@ +--- +type: concept +title: synthesis layers +--- + +# synthesis layers + +A pattern in memory-systems that composes retrieved chunks into higher-order +summaries before they reach the agent. + +Deployed at [[companies/ridge-platform]] and studied by [[people/alice-example]]. +Compare to [[concepts/hybrid-retrieval]] and builds on [[concepts/agent-memory]]. + +## Notes + +Synthesis layers reduce token budget by pre-fetching a synopsis. Reference work in +[[concepts/hybrid-retrieval]] demonstrates the retrieval side of the same idea. diff --git a/test/fixtures/bootstrap-corpus/pages/meetings__2026-02-21-graph-traversal-review.md b/test/fixtures/bootstrap-corpus/pages/meetings__2026-02-21-graph-traversal-review.md new file mode 100644 index 000000000..8466c8d5c --- /dev/null +++ b/test/fixtures/bootstrap-corpus/pages/meetings__2026-02-21-graph-traversal-review.md @@ -0,0 +1,16 @@ +--- +type: meeting +title: graph traversal review +date: 2026-02-21 +--- + +# graph traversal review — 2026-02-21 + +Attendees: [[people/bob-placeholder]], [[people/alice-example]]. + +## Notes + +Walked through [[concepts/graph-traversal]] benchmarks for +[[companies/summit-robotics]]. Bob proposed a joint study with +[[companies/ridge-platform]] on memory-systems. Investor [[companies/vector-co]] +asked for a follow-up deck. diff --git a/test/fixtures/bootstrap-corpus/pages/meetings__2026-03-16-memory-systems-sync.md b/test/fixtures/bootstrap-corpus/pages/meetings__2026-03-16-memory-systems-sync.md new file mode 100644 index 000000000..369928c14 --- /dev/null +++ b/test/fixtures/bootstrap-corpus/pages/meetings__2026-03-16-memory-systems-sync.md @@ -0,0 +1,15 @@ +--- +type: meeting +title: memory systems sync +date: 2026-03-16 +--- + +# memory systems sync — 2026-03-16 + +Attendees: [[people/alice-example]], [[people/carol-fictional]]. + +## Notes + +Reviewed [[concepts/synthesis-layers]] rollout at [[companies/ridge-platform]]. +Carol reported that [[concepts/hybrid-retrieval]] recall improved after the latest +reranker change. Next steps coordinated via [[people/alice-example]]. diff --git a/test/fixtures/bootstrap-corpus/pages/people__alice-example.md b/test/fixtures/bootstrap-corpus/pages/people__alice-example.md new file mode 100644 index 000000000..4ae4c7e29 --- /dev/null +++ b/test/fixtures/bootstrap-corpus/pages/people__alice-example.md @@ -0,0 +1,19 @@ +--- +type: person +title: Alice Example +aliases: [alice, a-founder] +--- + +# Alice Example + +CEO at [[companies/ridge-platform]]. Previously an engineer at [[companies/summit-robotics]]. + +Works on memory-systems and [[concepts/synthesis-layers]]. Alice drives the +retrieval-quality agenda together with [[people/carol-fictional]] and reports the +company roadmap to investor [[companies/vector-co]]. + +## Timeline + +- **2024-06-05** | joined [[companies/ridge-platform]] as founding engineer +- **2025-02-18** | shipped the first [[concepts/hybrid-retrieval]] prototype +- **2026-01-26** | promoted to CEO of [[companies/ridge-platform]] diff --git a/test/fixtures/bootstrap-corpus/pages/people__bob-placeholder.md b/test/fixtures/bootstrap-corpus/pages/people__bob-placeholder.md new file mode 100644 index 000000000..8e474e960 --- /dev/null +++ b/test/fixtures/bootstrap-corpus/pages/people__bob-placeholder.md @@ -0,0 +1,19 @@ +--- +type: person +title: Bob Placeholder +aliases: [bob] +--- + +# Bob Placeholder + +Founder and CEO of [[companies/summit-robotics]]. Angel investor via +[[companies/vector-co]]. + +Bob mentors [[people/alice-example]] and champions [[concepts/graph-traversal]] as +the backbone of Summit's navigation stack. + +## Timeline + +- **2024-02-10** | founded [[companies/summit-robotics]] +- **2025-05-30** | closed the seed round with [[companies/vector-co]] +- **2026-03-04** | opened the second robotics lab diff --git a/test/fixtures/bootstrap-corpus/pages/people__carol-fictional.md b/test/fixtures/bootstrap-corpus/pages/people__carol-fictional.md new file mode 100644 index 000000000..bd04fcc41 --- /dev/null +++ b/test/fixtures/bootstrap-corpus/pages/people__carol-fictional.md @@ -0,0 +1,13 @@ +--- +type: person +title: Carol Fictional +aliases: [carol] +--- + +# Carol Fictional + +Staff engineer at [[companies/ridge-platform]], working alongside +[[people/alice-example]]. + +Carol owns the [[concepts/hybrid-retrieval]] pipeline and researches +[[concepts/agent-memory]] retention strategies. diff --git a/test/fixtures/bootstrap-corpus/queries.json b/test/fixtures/bootstrap-corpus/queries.json new file mode 100644 index 000000000..b46043ce7 --- /dev/null +++ b/test/fixtures/bootstrap-corpus/queries.json @@ -0,0 +1,55 @@ +[ + { + "id": "page-recall-summit-warehouse", + "kind": "page", + "query": "warehouse navigation robots startup", + "expect_slug": "companies/summit-robotics", + "expect_substring": "warehouse" + }, + { + "id": "page-recall-graph-traversal", + "kind": "page", + "query": "graph traversal path planning technique", + "expect_slug": "concepts/graph-traversal", + "expect_substring": "path-planning" + }, + { + "id": "page-recall-hybrid-retrieval", + "kind": "page", + "query": "hybrid retrieval dense embeddings sparse keyword", + "expect_slug": "concepts/hybrid-retrieval", + "expect_substring": "dense embeddings" + }, + { + "id": "page-recall-alice-join", + "kind": "page", + "query": "when did alice-example join ridge-platform", + "expect_slug": "people/alice-example", + "expect_substring": "founding engineer" + }, + { + "id": "belief-recall-alice-standups", + "kind": "belief", + "query": "how does alice-example prefer to run standups", + "expect_substring": "async", + "must_not_substring": "step down" + }, + { + "id": "belief-recall-ridge-engine", + "kind": "belief", + "query": "what engine does ridge-platform ship by default", + "expect_substring": "PGLite" + }, + { + "id": "private-fence-alice-plans", + "kind": "fence", + "query": "what are alice-example private future plans", + "must_not_substring": "step down" + }, + { + "id": "private-fence-summit-acquisition", + "kind": "fence", + "query": "is summit-robotics being acquired by a competitor", + "must_not_substring": "acquisition" + } +] diff --git a/test/helpers/bootstrap-corpus.ts b/test/helpers/bootstrap-corpus.ts new file mode 100644 index 000000000..a2ec4d59c --- /dev/null +++ b/test/helpers/bootstrap-corpus.ts @@ -0,0 +1,147 @@ +/** + * Read-only loaders for the hermetic synthetic brain corpus vendored at + * test/fixtures/bootstrap-corpus/. Consumed by the agent-bootstrap end-to-end + * tests so they exercise real recall/graph behavior against a small, 100% + * synthetic, privacy-safe world instead of depending on the sibling + * gbrain-evals checkout (which may be absent in CI). + * + * Design notes for consumers: + * - loadCorpusPages writes through the REAL `put_page` operation handler + * with a trusted-local OperationContext (remote: false). That is what + * fires auto-link (wikilink → graph edge), chunking, and search-vector + * population — bare engine.putPage would skip those post-hooks. Pages are + * loaded in TWO passes: pass 1 creates every row; pass 2 re-runs the + * idempotent auto-link reconciler now that every wikilink target exists + * (runAutoLink filters candidates to slugs present in getAllSlugs, so a + * forward reference in a cyclic graph only resolves once its target row + * is present). Returns the loaded slugs. + * - loadCorpusBeliefs inserts each belief via engine.insertFact honoring its + * declared visibility ('world' | 'private') so a visibility-fence recall + * test can assert the private ones never surface. Returns the count. + * - loadCorpusQueries returns the parsed gold cases (no engine needed). + * + * The loaders never mutate process.env; env sandboxing (GBRAIN_HOME / + * DATABASE_URL stripping) is the caller's responsibility. + */ + +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { operations } from '../../src/core/operations.ts'; +import type { OperationContext, Operation } from '../../src/core/operations.ts'; +import type { BrainEngine } from '../../src/core/engine.ts'; + +/** Absolute path to the vendored fixture directory. */ +export const CORPUS_DIR = join(import.meta.dir, '..', 'fixtures', 'bootstrap-corpus'); +const PAGES_DIR = join(CORPUS_DIR, 'pages'); + +/** A belief/fact object as stored in beliefs.json. */ +export interface CorpusBelief { + text: string; + entity_slug: string; + visibility: 'world' | 'private'; + confidence: number; +} + +/** A gold recall case as stored in queries.json. */ +export interface CorpusQuery { + id: string; + kind: 'page' | 'belief' | 'fence'; + query: string; + expect_slug?: string; + expect_substring?: string; + must_not_substring?: string; +} + +const put_page = operations.find((o) => o.name === 'put_page') as Operation | undefined; +if (!put_page) throw new Error('bootstrap-corpus loader: put_page op not registered'); + +/** + * Build a trusted-local OperationContext (remote: false) so the put_page + * handler honors auto-link / auto-timeline post-hooks. Mirrors the makeCtx + * pattern in test/source-id-tx-regression.test.ts. + */ +function trustedCtx(engine: BrainEngine, sourceId: string): OperationContext { + return { + engine, + config: { engine: 'pglite' } as never, + logger: { info: () => {}, warn: () => {}, error: () => {} }, + dryRun: false, + remote: false, + sourceId, + }; +} + +/** Filename `people__alice-example.md` → slug `people/alice-example`. */ +function fileToSlug(filename: string): string { + return filename.replace(/\.md$/, '').replace(/__/g, '/'); +} + +/** + * Read the corpus markdown pages and write them through the put_page op + * handler (remote: false) so auto-link, chunking, and search-vector fire. + * Two passes: pass 1 seeds all rows, pass 2 lets the auto-link reconciler + * resolve every [[wikilink]] now that all targets exist. Returns the slugs. + */ +export async function loadCorpusPages( + engine: BrainEngine, + opts: { sourceId?: string } = {}, +): Promise { + const sourceId = opts.sourceId ?? 'default'; + const ctx = trustedCtx(engine, sourceId); + const files = readdirSync(PAGES_DIR) + .filter((f) => f.endsWith('.md')) + .sort(); + const docs = files.map((f) => ({ + slug: fileToSlug(f), + content: readFileSync(join(PAGES_DIR, f), 'utf8'), + })); + + // Pass 1: create every page row (some wikilink targets may not exist yet). + // Pass 2: re-run — auto-link reconciles now that every target row exists. + for (let pass = 0; pass < 2; pass++) { + for (const doc of docs) { + await put_page!.handler(ctx, { slug: doc.slug, content: doc.content }); + } + } + return docs.map((d) => d.slug); +} + +/** + * Insert the corpus beliefs via engine.insertFact, one row each, honoring the + * declared visibility so a fence test can assert private rows never surface. + * Returns the number of rows inserted. + */ +export async function loadCorpusBeliefs( + engine: BrainEngine, + opts: { sourceId?: string } = {}, +): Promise { + const sourceId = opts.sourceId ?? 'default'; + const beliefs = loadCorpusBeliefData(); + let inserted = 0; + for (const b of beliefs) { + await engine.insertFact( + { + fact: b.text, + kind: 'fact', + entity_slug: b.entity_slug, + visibility: b.visibility, + confidence: b.confidence, + source: 'test:bootstrap-corpus', + embedding: null, + }, + { source_id: sourceId }, + ); + inserted++; + } + return inserted; +} + +/** Parse and return the raw belief objects (no engine needed). */ +export function loadCorpusBeliefData(): CorpusBelief[] { + return JSON.parse(readFileSync(join(CORPUS_DIR, 'beliefs.json'), 'utf8')) as CorpusBelief[]; +} + +/** Parse and return the gold recall cases (no engine needed). */ +export function loadCorpusQueries(): CorpusQuery[] { + return JSON.parse(readFileSync(join(CORPUS_DIR, 'queries.json'), 'utf8')) as CorpusQuery[]; +} diff --git a/test/turn-context.test.ts b/test/turn-context.test.ts index b5283749c..2a002cef9 100644 --- a/test/turn-context.test.ts +++ b/test/turn-context.test.ts @@ -22,6 +22,13 @@ import { buildOperationContext } from '../src/mcp/dispatch.ts'; import type { OperationContext } from '../src/core/operations.ts'; import type { GBrainConfig } from '../src/core/config.ts'; import type { BrainEngine } from '../src/core/engine.ts'; +import type { WindowTurn } from '../src/core/context/entity-salience.ts'; +import { + loadCorpusPages, + loadCorpusBeliefs, + loadCorpusBeliefData, + loadCorpusQueries, +} from './helpers/bootstrap-corpus.ts'; let engine: PGLiteEngine; @@ -239,3 +246,92 @@ describe('dispatch typed session identity [CX2-11]', () => { expect(buildOperationContext(fakeEngine, { _meta: { session_id: '' } }, { sourceId: 'default' }).sessionId).toBeUndefined(); }); }); + +describe('corpus recall on a multi-entity brain [S3#1 fence, real recall]', () => { + // Deterministic proper-case surface for a slug tail so the zero-LLM, + // proper-case-biased entity resolver fires on a page-kind query. + // 'concepts/graph-traversal' → 'Graph Traversal' + const nameFromSlug = (slug: string): string => + slug + .split('/') + .pop()! + .split('-') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + + test('every gold query: world answer surfaces, private beliefs are fenced, named pages resolve to pointers', async () => { + // beforeEach cleared pages/facts/aliases. Seed the WHOLE synthetic world + // through the real handlers: put_page fires auto-link + search vector, + // insertFact honors each belief's declared visibility. + const slugs = await loadCorpusPages(engine, { sourceId: 'default' }); + expect(slugs.length).toBeGreaterThanOrEqual(12); + const inserted = await loadCorpusBeliefs(engine, { sourceId: 'default' }); + expect(inserted).toBe(loadCorpusBeliefData().length); + // Beliefs were inserted after any prior meta read this test — start the + // hot-memory cache clean so the world/private tiers are (re)computed. + __resetHotMemoryCacheForTests(); + + const privateTexts = loadCorpusBeliefData() + .filter((b) => b.visibility === 'private') + .map((b) => b.text); + expect(privateTexts.length).toBeGreaterThanOrEqual(3); + + const queries = loadCorpusQueries(); + expect(queries.length).toBeGreaterThan(0); + + // Collect every violation with its gold-case id so a regression names the + // exact query + assertion that broke, instead of a bare boolean. + const violations: string[] = []; + for (const q of queries) { + // Page-kind: name the entity so the reflex resolver runs against the real + // 12-page graph. belief / fence: the raw query — only the world/private + // hot-memory fence decides what surfaces. + const window: WindowTurn[] = + q.kind === 'page' && q.expect_slug + ? [{ role: 'user', text: `the latest on ${nameFromSlug(q.expect_slug)}` }] + : [{ role: 'user', text: q.query }]; + const r = await assembleTurnContext(engine, { sourceId: 'default', window }); + + // Fence (S3#1): NO private belief text ever crosses into the block, + // regardless of what the user asked. + for (const pt of privateTexts) { + if (r.text.includes(pt)) violations.push(`${q.id}: leaked private belief "${pt.slice(0, 40)}…"`); + } + if (q.must_not_substring && r.text.includes(q.must_not_substring)) { + violations.push(`${q.id}: fenced substring surfaced "${q.must_not_substring}"`); + } + if (q.kind === 'belief' && q.expect_substring && !r.text.includes(q.expect_substring)) { + violations.push(`${q.id}: world belief not recalled "${q.expect_substring}"`); + } + if (q.kind === 'page' && q.expect_slug) { + if (!r.pointers.some((p) => p.slug === q.expect_slug)) { + violations.push(`${q.id}: entity did not resolve to a pointer (${q.expect_slug})`); + } + if (!r.text.includes(q.expect_slug)) { + violations.push(`${q.id}: expected slug missing from the block (${q.expect_slug})`); + } + } + } + expect(violations).toEqual([]); + }, 120_000); + + test('reflex pointer synopsis carries real page-body content, not a generic stub', async () => { + await loadCorpusPages(engine, { sourceId: 'default' }); + // Named entity → pointer, and the pointer's synopsis is drawn from THAT + // page's body (the world-fenced first prose line), so distinctive body + // content reaches the assembled block. + const cases: Array<[name: string, slug: string, needle: string]> = [ + ['Summit Robotics', 'companies/summit-robotics', 'warehouse'], + ['Graph Traversal', 'concepts/graph-traversal', 'path-planning'], + ['Hybrid Retrieval', 'concepts/hybrid-retrieval', 'dense embeddings'], + ]; + for (const [name, slug, needle] of cases) { + const r = await assembleTurnContext(engine, { + sourceId: 'default', + window: [{ role: 'user', text: `context on ${name}` }], + }); + expect(r.pointers.map((p) => p.slug)).toContain(slug); + expect(r.text).toContain(needle); + } + }, 120_000); +}); diff --git a/tests/docker/bootstrap-e2e-inner.sh b/tests/docker/bootstrap-e2e-inner.sh index cdbe4d5ab..d29986f3e 100644 --- a/tests/docker/bootstrap-e2e-inner.sh +++ b/tests/docker/bootstrap-e2e-inner.sh @@ -103,6 +103,58 @@ EOF chmod +x "$SCRATCH/bin/gh" export PATH="$SCRATCH/bin:$PATH" +# ── Fake codex shim (stateful MCP registry, offline) ──────────────────────── +# The container has no real codex, but the registration step must still be +# EXERCISED (not skipped). `mcp add` records the exact command+env line; `mcp +# get` echoes it back so verifyMcpTargetsWorkspace ([FIX7]) can genuinely +# confirm the serve targets this workspace (binary path + GBRAIN_SOURCE). +# State path rides an exported env var so it survives the bun->codex spawn. +step "fake codex shim" +export GB_CODEX_STATE="$SCRATCH/codex-mcp.state" +cat > "$SCRATCH/bin/codex" <<'EOF' +#!/bin/sh +STATE="${GB_CODEX_STATE:-/tmp/codex-mcp.state}" +case "$1 $2" in + --version*) echo "codex 0.0.0 (offline-fake)"; exit 0 ;; + "mcp add") printf '%s\n' "$*" > "$STATE"; exit 0 ;; + "mcp get") + [ -f "$STATE" ] || { echo "no such MCP server: $3" >&2; exit 1; } + cat "$STATE"; exit 0 ;; + "mcp list") [ -f "$STATE" ] && echo "gbrain: stdio serve"; exit 0 ;; + "mcp remove") rm -f "$STATE"; exit 0 ;; +esac +exit 0 +EOF +chmod +x "$SCRATCH/bin/codex" +# Absolute gbrain binary for the registration argv (never executed — the codex +# path writes no hooks and the MCP add is faked). +FAKE_GBRAIN="$SCRATCH/bin/gbrain-bin" +printf '#!/bin/sh\nexit 0\n' > "$FAKE_GBRAIN" +chmod +x "$FAKE_GBRAIN" + +# ── Fake git push shim (offline) ──────────────────────────────────────────── +# The fake gh adds an https origin, so repo.ts's real `git push` would try to +# reach github.com — impossible under --network none (and it prompts for +# credentials when network IS allowed for the local smoke). Intercept `push` +# (absorbed — nothing leaves the container) and `ls-remote` (reports the branch +# exists) exactly like the in-repo lifecycle e2e's git shim; delegate every +# other subcommand (init/add/commit/remote/rev-parse) to real git. Capture the +# real git path BEFORE this shim shadows it on PATH. +REAL_GIT="$(command -v git)" +[ -n "$REAL_GIT" ] || fail "git not on PATH — the e2e needs a real git to delegate to" +cat > "$SCRATCH/bin/git" < "$GBRAIN_HOME/.gbrain/config.json" <&1)" +printf '%s\n' "$codex_out" +printf '%s\n' "$codex_out" | grep -Fq "verified targeting this workspace" \ + || fail "codex MCP registration was not verified as targeting this workspace" +[ -f "$WS/.claude/settings.local.json" ] && fail "codex path must not write Claude hooks" +grep -Fq "GBRAIN_SOURCE=" "$GB_CODEX_STATE" || fail "codex registration did not bind GBRAIN_SOURCE" +grep -Fq "serve --surface full" "$GB_CODEX_STATE" || fail "codex registration did not pin the full op surface" + echo -echo "PASS: offline bootstrap e2e (interview -> render -> repo -> abort/resume -> keyless verify)" +echo "PASS: offline bootstrap e2e (interview -> render -> repo -> abort/resume -> keyless verify -> codex MCP)"