diff --git a/tests/e2e/blank-supabase-netlog.mjs b/tests/e2e/blank-supabase-netlog.mjs
new file mode 100644
index 0000000..8010e9d
--- /dev/null
+++ b/tests/e2e/blank-supabase-netlog.mjs
@@ -0,0 +1,42 @@
+/**
+ * Preload for tests/e2e/blank-supabase.mjs (loaded via NODE_OPTIONS=--import).
+ *
+ * Wraps globalThis.fetch in the gitmem server process and appends one JSON line
+ * per request to $GITMEM_NETLOG: method, host, path, status, and the size of the
+ * response body as the process received it (decoded — an upper bound on wire bytes
+ * when the response was gzip'd). Request bodies are never logged; auth headers
+ * are never logged.
+ */
+import { appendFileSync } from "node:fs";
+
+const LOG = process.env.GITMEM_NETLOG;
+const realFetch = globalThis.fetch;
+
+if (LOG && realFetch) {
+ globalThis.fetch = async function netlogFetch(input, init) {
+ const url = new URL(typeof input === "string" ? input : input.url ?? String(input));
+ const method = (init?.method || (typeof input === "object" && input.method) || "GET").toUpperCase();
+ const started = Date.now();
+ let response;
+ try {
+ response = await realFetch(input, init);
+ } catch (err) {
+ appendFileSync(LOG, JSON.stringify({ t: started, method, host: url.host, path: url.pathname, status: 0, bytes: 0, error: String(err).slice(0, 200) }) + "\n");
+ throw err;
+ }
+ const contentLength = response.headers.get("content-length");
+ const encoding = response.headers.get("content-encoding");
+ response
+ .clone()
+ .arrayBuffer()
+ .then((buf) => {
+ appendFileSync(LOG, JSON.stringify({
+ t: started, ms: Date.now() - started, method, host: url.host, path: url.pathname,
+ query: url.search.slice(0, 160), status: response.status, bytes: buf.byteLength,
+ content_length: contentLength ? Number(contentLength) : null, encoding,
+ }) + "\n");
+ })
+ .catch(() => {});
+ return response;
+ };
+}
diff --git a/tests/e2e/blank-supabase.mjs b/tests/e2e/blank-supabase.mjs
new file mode 100644
index 0000000..a3a37bb
--- /dev/null
+++ b/tests/e2e/blank-supabase.mjs
@@ -0,0 +1,476 @@
+#!/usr/bin/env node
+/**
+ * Blank-Supabase venue driver (GIT-97 / GIT-98).
+ *
+ * Runs the BUILT server (dist/index.js) over stdio, Pro tier, against a Supabase
+ * project that has schema/setup.sql and nothing else — no edge functions — which
+ * is what every customer project looks like. Each run gets an isolated HOME and
+ * GITMEM_DIR. Every fetch the server makes is logged by blank-supabase-netlog.mjs.
+ *
+ * Embeddings come from a local fake Ollama endpoint (deterministic bag-of-words
+ * hashing into 1536 dims), so no embedding API key is needed and all measured
+ * network traffic is venue traffic.
+ *
+ * Usage (build first: npm run build):
+ * VENUE_ENV=/path/outside/repo/.env node tests/e2e/blank-supabase.mjs flow --label main --out
+ * VENUE_ENV=/path/outside/repo/.env node tests/e2e/blank-supabase.mjs egress --label pr31 --out [--rows 250] [--usage 5]
+ *
+ * flow session_start -> list_threads -> recall -> confirm_scars -> create_thread
+ * -> list_threads -> session_close -> health, with a local threads.json that
+ * deliberately differs from the remote thread table.
+ * egress bytes received from the venue on a cold start, a warm start, and around
+ * two session_starts (GIT-98 disk cache). --usage N seeds N scars with
+ * >=3 usage rows so refresh_scar_behavioral_scores() has work to do.
+ *
+ * VENUE_ENV is a KEY=VALUE file OUTSIDE the repo defining SUPABASE_URL,
+ * SUPABASE_SERVICE_ROLE_KEY and VENUE_REF. Credentials are never committed.
+ * The driver WIPES the gitmem tables of that project before seeding. It refuses
+ * to run unless SUPABASE_URL is exactly https://.supabase.co and the
+ * ref is not on the deny list below.
+ *
+ * Exit status: 0 = every 4xx/5xx from /rest/v1/ or /functions/v1/ is on
+ * EXPECTED_FAILURES; 1 = at least one unexpected failure; 2 = usage/config error.
+ */
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
+import { createServer } from "node:http";
+import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, dirname, resolve } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+const DENY_REFS = new Set(["cjptxyezuxdiinufgrrm"]); // production GitMem — never touch
+
+/**
+ * Known venue failures. Any other 4xx/5xx from /rest/v1/ or /functions/v1/
+ * fails the run. Each entry names the ticket that removes it — delete the entry
+ * when that ticket merges, so this list only shrinks. An entry allows a failure;
+ * it does not require one (e.g. the GIT-73 race is intermittent).
+ */
+const EXPECTED_FAILURES = [
+ { ticket: "GIT-73", what: "metrics/session_start FK race", method: "POST", path: /^\/rest\/v1\/gitmem_query_metrics$/, status: 409 },
+ { ticket: "GIT-105", what: "knowledge-triple thread id into a uuid column", method: "POST", path: /^\/rest\/v1\/knowledge_triples$/, status: 400 },
+ { ticket: "GIT-106", what: "scar_enforcement_variants.active does not exist", method: "GET", path: /^\/rest\/v1\/scar_enforcement_variants$/, status: 400 },
+ { ticket: "GIT-84", what: "unprefixed scar_usage table (remove once GIT-84 merges)", method: null, path: /^\/rest\/v1\/scar_usage$/, status: 404 },
+ { ticket: "GIT-97", what: "ww-mcp edge function absent on customer projects (remove once PR #30 merges)", method: "POST", path: /^\/functions\/v1\/ww-mcp$/, status: 404 },
+];
+const HERE = dirname(fileURLToPath(import.meta.url));
+const REPO = resolve(HERE, "../..");
+const SERVER = join(REPO, "dist/index.js");
+const NETLOG_PRELOAD = join(HERE, "blank-supabase-netlog.mjs");
+const DIM = 1536;
+const PROJECT = "blank-venue";
+
+// ---------------------------------------------------------------- args / env
+const mode = process.argv[2];
+const arg = (name, dflt) => {
+ const i = process.argv.indexOf(`--${name}`);
+ return i > 0 ? process.argv[i + 1] : dflt;
+};
+const label = arg("label", "run");
+const outDir = resolve(arg("out", join(tmpdir(), `blank-supabase-${label}`)));
+const seedRows = Number(arg("rows", "250"));
+/** Configuration problems end the run with a plain message, never a stack trace. */
+function configError(message) {
+ console.error(`[driver] ${message}`);
+ process.exit(2);
+}
+if (!["flow", "egress"].includes(mode)) {
+ configError("usage: blank-supabase.mjs flow|egress --label --out [--rows N] [--usage N]");
+}
+
+const venueEnvPath = process.env.VENUE_ENV;
+if (!venueEnvPath) {
+ configError("VENUE_ENV is not set. Point it at a KEY=VALUE file outside the repo with SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY and VENUE_REF.");
+}
+if (!existsSync(venueEnvPath)) configError(`VENUE_ENV file not found: ${venueEnvPath}`);
+let venue;
+try {
+ venue = Object.fromEntries(
+ readFileSync(venueEnvPath, "utf8")
+ .split("\n").filter((l) => l.includes("=") && !l.trimStart().startsWith("#"))
+ .map((l) => [l.slice(0, l.indexOf("=")).trim(), l.slice(l.indexOf("=") + 1).trim()])
+ );
+} catch (err) {
+ configError(`Cannot read VENUE_ENV file ${venueEnvPath}: ${err.message}`);
+}
+const { SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, VENUE_REF } = venue;
+const missingKeys = ["SUPABASE_URL", "SUPABASE_SERVICE_ROLE_KEY", "VENUE_REF"].filter((k) => !venue[k]);
+if (missingKeys.length) configError(`VENUE_ENV file ${venueEnvPath} is missing: ${missingKeys.join(", ")}`);
+if (DENY_REFS.has(VENUE_REF) || SUPABASE_URL !== `https://${VENUE_REF}.supabase.co`) {
+ configError(`Refusing to run against ${SUPABASE_URL} (ref ${VENUE_REF}). Only a disposable venue whose URL is https://.supabase.co is allowed; production is denied.`);
+}
+if (!existsSync(SERVER)) configError(`Build first (npm run build): ${SERVER} is missing`);
+mkdirSync(outDir, { recursive: true });
+const VENUE_HOST = new URL(SUPABASE_URL).host;
+console.log(`[driver] target venue: ${SUPABASE_URL} (ref ${VENUE_REF}) mode=${mode} label=${label}`);
+
+// ---------------------------------------------------------------- venue REST (driver-side seeding)
+const rest = async (method, path, body, extraHeaders = {}) => {
+ const res = await fetch(`${SUPABASE_URL}/rest/v1/${path}`, {
+ method,
+ headers: {
+ apikey: SUPABASE_SERVICE_ROLE_KEY,
+ Authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}`,
+ "Content-Type": "application/json",
+ Prefer: "return=minimal",
+ ...extraHeaders,
+ },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+ if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${(await res.text()).slice(0, 300)}`);
+ return res;
+};
+const WIPE_ORDER = [
+ "gitmem_scar_usage", "gitmem_decisions", "gitmem_threads", "knowledge_triples",
+ "gitmem_query_metrics", "scar_enforcement_variants", "gitmem_learnings", "gitmem_sessions",
+];
+async function wipeVenue() {
+ for (const t of WIPE_ORDER) await rest("DELETE", `${t}?id=not.is.null`);
+}
+async function count(table, filter = "") {
+ const res = await rest("HEAD", `${table}?select=id${filter}`, undefined, { Prefer: "count=exact", Range: "0-0" });
+ return Number((res.headers.get("content-range") || "*/-1").split("/")[1]);
+}
+
+// ---------------------------------------------------------------- fake embeddings (Ollama /api/embed shape)
+function fnv(s) {
+ let h = 0x811c9dc5;
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
+ return h >>> 0;
+}
+function embed(text) {
+ const v = new Array(DIM).fill(0);
+ const words = String(text).toLowerCase().match(/[a-z0-9]+/g) || ["empty"];
+ for (const w of words) for (let k = 0; k < 3; k++) {
+ const h = fnv(`${k}:${w}`);
+ v[h % DIM] += (h & 1) ? 1 : -1;
+ }
+ const n = Math.hypot(...v) || 1;
+ return v.map((x) => Math.round((x / n) * 1e6) / 1e6);
+}
+async function startFakeOllama() {
+ let calls = 0;
+ const server = createServer((req, res) => {
+ let body = "";
+ req.on("data", (c) => (body += c));
+ req.on("end", () => {
+ calls++;
+ const { input } = JSON.parse(body || "{}");
+ const inputs = Array.isArray(input) ? input : [input];
+ res.setHeader("content-type", "application/json");
+ res.end(JSON.stringify({ model: "fake", embeddings: inputs.map(embed) }));
+ });
+ });
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
+ return { url: `http://127.0.0.1:${server.address().port}`, close: () => server.close(), calls: () => calls };
+}
+
+// ---------------------------------------------------------------- server process
+function isolatedHome(tag) {
+ const home = mkdtempSync(join(tmpdir(), `gitmem-venue-${tag}-`));
+ const gitmemDir = join(home, ".gitmem");
+ mkdirSync(gitmemDir, { recursive: true });
+ return { home, gitmemDir };
+}
+const STRIP = /^(OPENAI_API_KEY|OPENROUTER_API_KEY|GITMEM_|SUPABASE_|OLLAMA_|NODE_OPTIONS$|CLAUDE_CODE_ENTRYPOINT$)/;
+async function startServer({ home, gitmemDir, ollamaUrl, netlog, stderrSink }) {
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !STRIP.test(k)));
+ Object.assign(env, {
+ HOME: home,
+ GITMEM_DIR: gitmemDir,
+ GITMEM_TIER: "pro",
+ SUPABASE_URL,
+ SUPABASE_SERVICE_ROLE_KEY,
+ GITMEM_EMBEDDING_PROVIDER: "ollama",
+ OLLAMA_URL: ollamaUrl,
+ GITMEM_OLLAMA_MODEL: "fake",
+ GITMEM_EMBEDDING_DIM: String(DIM),
+ GITMEM_DEFAULT_PROJECT: PROJECT,
+ GITMEM_NETLOG: netlog,
+ NODE_OPTIONS: `--import=${pathToFileURL(NETLOG_PRELOAD).href}`,
+ NO_COLOR: "1",
+ });
+ const transport = new StdioClientTransport({ command: process.execPath, args: [SERVER], env, cwd: home, stderr: "pipe" });
+ transport.stderr?.on("data", (d) => stderrSink.push(d.toString()));
+ const client = new Client({ name: "gitmem-blank-venue-driver", version: "1.0.0" }, { capabilities: {} });
+ await client.connect(transport);
+ return {
+ call: async (name, args = {}) => {
+ const r = await client.callTool({ name, arguments: args }, undefined, { timeout: 120_000 });
+ return (r.content || []).map((c) => c.text || "").join("\n");
+ },
+ close: async () => { try { await client.close(); } catch {} try { await transport.close(); } catch {} },
+ };
+}
+const readNet = (file) => (existsSync(file) ? readFileSync(file, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []);
+async function waitQuiet(file, { minMs = 4000, quietMs = 4000, maxMs = 90_000 } = {}) {
+ const t0 = Date.now();
+ let last = -1, lastChange = Date.now();
+ while (Date.now() - t0 < maxMs) {
+ const n = readNet(file).length;
+ if (n !== last) { last = n; lastChange = Date.now(); }
+ if (Date.now() - t0 >= minMs && Date.now() - lastChange >= quietMs) return;
+ await new Promise((r) => setTimeout(r, 250));
+ }
+}
+const venueReqs = (reqs) => reqs.filter((r) => r.host === VENUE_HOST);
+const sumBytes = (reqs) => venueReqs(reqs).reduce((a, r) => a + (r.bytes || 0), 0);
+const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
+
+// Every venue request from every server process in this run, for the failure check.
+const allVenueRequests = [];
+const expectedFor = (r) => EXPECTED_FAILURES.find((e) =>
+ e.status === r.status && e.path.test(r.path) && (e.method === null || e.method === r.method));
+function checkFailures(reqs) {
+ const failures = reqs.filter((r) => r.status >= 400 && /^\/(rest|functions)\/v1\//.test(r.path));
+ const unexpected = failures.filter((r) => !expectedFor(r))
+ .map((r) => `${r.method} ${r.path}${r.query} -> ${r.status}`);
+ const expectedSeen = {};
+ for (const r of failures) {
+ const e = expectedFor(r);
+ if (e) expectedSeen[`${e.ticket} ${r.method} ${r.path} ${r.status}`] = (expectedSeen[`${e.ticket} ${r.method} ${r.path} ${r.status}`] || 0) + 1;
+ }
+ return { unexpected, expected_seen: expectedSeen };
+}
+
+// ---------------------------------------------------------------- FLOW (runs 1 & 2)
+async function flow() {
+ await wipeVenue();
+ const now = Date.now();
+ const iso = (msAgo) => new Date(now - msAgo).toISOString();
+
+ // A prior closed session, so there is a "last session" to load.
+ const priorSession = "11111111-1111-4111-8111-111111111111";
+ await rest("POST", "gitmem_sessions", [{
+ id: priorSession, session_title: "Seeded prior session", agent: "cli", project: PROJECT,
+ decisions: [], open_threads: [], closing_reflection: { what_worked: "seeded" },
+ created_at: iso(3600_000), updated_at: iso(3600_000),
+ }]);
+
+ // Learnings with embeddings, so recall has something to find.
+ const scars = [
+ ["Supabase migrations must be dry-run before push", "Run db push --dry-run and read the plan before applying any migration to a shared database."],
+ ["Edge functions are not shipped to customer projects", "Anything under /functions/v1 exists only where someone deployed it; customer Supabase projects have none."],
+ ["Service role keys never go in logs", "Redact service role keys from stdout, stderr and CI logs; write them to a mode-600 env file."],
+ ["Verify deployment after merge", "Done is not deployed is not verified working: pull on the target, restart, and check it is running."],
+ ["Thread counts must agree across surfaces", "session_start and list_threads must read threads from the same source of truth."],
+ ];
+ await rest("POST", "gitmem_learnings", scars.map(([title, description], i) => ({
+ learning_type: "scar", title, description, severity: i < 2 ? "high" : "medium",
+ counter_arguments: ["You might think it is fine to skip — but it is not."], project: PROJECT,
+ embedding: JSON.stringify(embed(`${title} ${description}`)), is_active: true,
+ })));
+
+ // Remote threads (source of truth) — 3 open.
+ const remoteThreads = ["Remote thread A: rotate venue credentials", "Remote thread B: document blank-venue setup", "Remote thread C: follow up on per-row delta sync"];
+ await rest("POST", "gitmem_threads", remoteThreads.map((text, i) => ({
+ thread_id: `t-remote0${i + 1}`, text, status: "active", project: PROJECT,
+ source_session: priorSession, last_touched_at: iso(600_000 * (i + 1)),
+ })));
+
+ // Local threads.json that DIFFERS from the remote table: 2 local-only threads, none of the remote ones.
+ const { home, gitmemDir } = isolatedHome(`${label}-flow`);
+ const localThreads = [
+ { id: "t-local001", text: "Stale local file: renew the TLS certificate on the staging box", status: "open", created_at: iso(86400_000) },
+ { id: "t-local002", text: "Stale local file: benchmark ivfflat recall at ten thousand rows", status: "open", created_at: iso(86400_000) },
+ ];
+ writeFileSync(join(gitmemDir, "threads.json"), JSON.stringify(localThreads, null, 2));
+
+ const seeded = {
+ remote_threads_open: await count("gitmem_threads", "&status=eq.active"),
+ local_threads_json: localThreads.length,
+ learnings: await count("gitmem_learnings"),
+ sessions: await count("gitmem_sessions"),
+ };
+ console.log("[driver] seeded:", seeded);
+
+ const ollama = await startFakeOllama();
+ const netlog = join(outDir, `netlog-${label}-flow.jsonl`);
+ writeFileSync(netlog, "");
+ const stderr = [];
+ const transcript = [];
+ const srv = await startServer({ home, gitmemDir, ollamaUrl: ollama.url, netlog, stderrSink: stderr });
+ const step = async (name, args) => {
+ const t0 = Date.now();
+ let text, error;
+ try { text = await srv.call(name, args); } catch (e) { error = String(e); text = ""; }
+ transcript.push({ step: name, args, ms: Date.now() - t0, error, text });
+ console.log(`[driver] ${name}: ${error ? "ERROR " + error : "ok"} (${Date.now() - t0}ms)`);
+ return text;
+ };
+
+ await waitQuiet(netlog, { minMs: 3000, quietMs: 3000 });
+ const ss = await step("session_start", { project: PROJECT, agent_identity: "cli", force: true });
+ const sessionId = (ss.match(UUID) || []).find((u) => u !== priorSession);
+ const ssThreadCount = Number((ss.match(/Threads \((\d+)\)/) || [])[1] ?? 0);
+ // Same-moment comparison: list_threads immediately after session_start, before anything writes a thread.
+ const lt0 = await step("list_threads", { project: PROJECT });
+ const lt0Open = Number((lt0.match(/(\d+) open/) || [])[1] ?? -1);
+
+ const rc = await step("recall", { plan: "Supabase migrations must be dry-run before push; verify deployment after merge, pull on the target, restart", project: PROJECT, match_count: 3 });
+ // recall prints short ids ("id:448fede2"); resolve them to full UUIDs from the venue.
+ const allIds = await rest("GET", "gitmem_learnings?select=id", undefined, { Prefer: "" }).then((r) => r.json());
+ const shortIds = [...new Set([...rc.matchAll(/id:([0-9a-f]{8})\b/g)].map((m) => m[1]))];
+ const scarIds = shortIds.map((s) => allIds.find((r) => r.id.startsWith(s))?.id).filter(Boolean);
+
+ const cs = await step("confirm_scars", {
+ confirmations: scarIds.map((id, i) => ({
+ scar_id: id,
+ decision: i === 0 ? "APPLYING" : "N_A",
+ evidence: i === 0
+ ? "Ran the dry-run first and read the migration plan before applying it to the blank venue project."
+ : "This scar concerns a different failure mode than the blank-venue verification being run here.",
+ relevance: i === 0 ? "high" : "low",
+ })),
+ });
+
+ const ct = await step("create_thread", { text: `Driver-created thread (${label}) ${new Date(now).toISOString()}` });
+ const lt = await step("list_threads", { project: PROJECT });
+ const ltOpen = Number((lt.match(/(\d+) open/) || [])[1] ?? -1);
+
+ const sc = await step("session_close", {
+ session_id: sessionId, close_type: "standard", human_corrections: "none",
+ closing_reflection: {
+ what_broke: "nothing (driver)", what_took_longer: "n/a", do_differently: "n/a", what_worked: "driver",
+ wrong_assumption: "n/a", scars_applied: [], institutional_memory_items: "n/a",
+ collaborative_dynamic: "n/a", rapport_notes: "n/a",
+ },
+ });
+ await waitQuiet(netlog, { minMs: 2000, quietMs: 3000 });
+ const health = await step("health", { failure_limit: 20 });
+ await waitQuiet(netlog, { minMs: 1000, quietMs: 2000 });
+ await srv.close();
+ ollama.close();
+
+ const reqs = readNet(netlog);
+ const vr = venueReqs(reqs);
+ allVenueRequests.push(...vr);
+ const errText = stderr.join("");
+ const failed = [...health.matchAll(/\((\d+) failed\)/g)].reduce((a, m) => a + Number(m[1]), 0);
+ const summary = {
+ label, mode, venue: SUPABASE_URL, seeded, session_id: sessionId,
+ session_start_thread_count: ssThreadCount,
+ list_threads_open_right_after_session_start: lt0Open,
+ list_threads_open_after_create_thread: ltOpen,
+ thread_counts_match: ssThreadCount === lt0Open,
+ session_start_matches_local_file: ssThreadCount === seeded.local_threads_json,
+ recall_scar_ids: scarIds.length,
+ remote_threads_after: await count("gitmem_threads"),
+ remote_sessions_after: await count("gitmem_sessions"),
+ remote_scar_usage_after: await count("gitmem_scar_usage"),
+ health_failed_total: failed,
+ health_text: health,
+ functions_v1_requests: vr.filter((r) => r.path.startsWith("/functions/v1/")).map((r) => `${r.method} ${r.path} -> ${r.status}`),
+ rest_requests: vr.length,
+ non_2xx_venue: vr.filter((r) => r.status < 200 || r.status >= 300).map((r) => `${r.method} ${r.path}${r.query} -> ${r.status}`),
+ stderr_has_mcp_404: /MCP HTTP error: 404/.test(errText),
+ stderr_has_failed_last_session: /\[session_start\] Failed to load last session/.test(errText),
+ stderr_error_lines: errText.split("\n").filter((l) => /error|fail|warn/i.test(l)).slice(0, 60),
+ embed_calls: ollama.calls(),
+ tool_errors: transcript.filter((s) => s.error || /^\{"error"/.test(s.text)).map((s) => `${s.step}: ${s.error || s.text.slice(0, 200)}`),
+ };
+ writeFileSync(join(outDir, `stderr-${label}-flow.log`), errText);
+ writeFileSync(join(outDir, `transcript-${label}-flow.json`), JSON.stringify(transcript, null, 2));
+ writeFileSync(join(outDir, `summary-${label}-flow.json`), JSON.stringify(summary, null, 2));
+ return summary;
+}
+
+// ---------------------------------------------------------------- EGRESS (run 3)
+async function egress() {
+ await wipeVenue();
+ const rows = [];
+ for (let i = 0; i < seedRows; i++) {
+ const title = `Egress learning ${i}: ${["deploy", "migration", "cache", "thread", "session"][i % 5]} behaviour ${i}`;
+ rows.push({
+ learning_type: ["scar", "pattern", "win"][i % 3], title,
+ description: `Seeded learning ${i} for the GIT-98 egress measurement. `.repeat(3),
+ severity: "medium", project: PROJECT, is_active: true,
+ embedding: JSON.stringify(embed(title)),
+ });
+ }
+ for (let i = 0; i < rows.length; i += 50) await rest("POST", "gitmem_learnings", rows.slice(i, i + 50));
+ console.log(`[driver] seeded learnings: ${await count("gitmem_learnings")}`);
+ // --usage N: give N learnings >=3 recent gitmem_scar_usage rows each, the threshold at which
+ // refresh_scar_behavioral_scores() rewrites them (and bumps updated_at). A real Pro store with
+ // usage history looks like this; a store where usage never lands looks like --usage 0.
+ const usageScars = Number(arg("usage", "0"));
+ if (usageScars > 0) {
+ const ids = await rest("GET", `gitmem_learnings?select=id&learning_type=eq.scar&limit=${usageScars}`, undefined, { Prefer: "" }).then((r) => r.json());
+ const surfaced = new Date(Date.now() - 86400_000).toISOString();
+ await rest("POST", "gitmem_scar_usage", ids.flatMap(({ id }) => [0, 1, 2].map((k) => ({
+ scar_id: id, agent: "cli", reference_type: k === 0 ? "none" : "acknowledged", surfaced_at: surfaced,
+ }))));
+ console.log(`[driver] seeded gitmem_scar_usage: ${await count("gitmem_scar_usage")} rows over ${ids.length} scars`);
+ }
+
+ const { home, gitmemDir } = isolatedHome(`${label}-egress`); // shared across all starts: the disk cache must persist
+ const ollama = await startFakeOllama();
+ const starts = [];
+ const run = async (name, { sessionStart = false } = {}) => {
+ const netlog = join(outDir, `netlog-${label}-${name}.jsonl`);
+ writeFileSync(netlog, "");
+ const stderr = [];
+ const srv = await startServer({ home, gitmemDir, ollamaUrl: ollama.url, netlog, stderrSink: stderr });
+ await waitQuiet(netlog);
+ const startupBytes = sumBytes(readNet(netlog));
+ const startupReqs = venueReqs(readNet(netlog)).length;
+ if (sessionStart) {
+ await srv.call("session_start", { project: PROJECT, agent_identity: "cli", force: true });
+ await waitQuiet(netlog, { minMs: 2000 });
+ }
+ await srv.close();
+ const reqs = venueReqs(readNet(netlog));
+ allVenueRequests.push(...reqs);
+ const errText = stderr.join("");
+ writeFileSync(join(outDir, `stderr-${label}-${name}.log`), errText);
+ const s = {
+ start: name,
+ startup_bytes: startupBytes,
+ startup_requests: startupReqs,
+ total_bytes: sumBytes(reqs),
+ total_requests: reqs.length,
+ largest: [...reqs].sort((a, b) => b.bytes - a.bytes).slice(0, 4).map((r) => `${r.method} ${r.path}${r.query.slice(0, 80)} ${r.status} ${r.bytes}B${r.encoding ? " (" + r.encoding + ")" : ""}`),
+ cache_log: errText.split("\n").filter((l) => /Vector cache|Loaded \d+ learnings|bulk download|Loading ALL/.test(l)),
+ };
+ starts.push(s);
+ console.log(`[driver] ${name}: startup ${startupBytes} B / ${startupReqs} req; total ${s.total_bytes} B / ${s.total_requests} req`);
+ return s;
+ };
+ // Which learnings did a session_start rewrite? (refresh_scar_behavioral_scores bumps updated_at)
+ const maxUpdated = () => rest("GET", "gitmem_learnings?select=updated_at&order=updated_at.desc&limit=1", undefined, { Prefer: "" })
+ .then((r) => r.json()).then((rows) => rows[0]?.updated_at ?? null);
+ const changedSince = (ts) => rest("GET", `gitmem_learnings?select=id,title,decay_multiplier,updated_at&updated_at=gt.${encodeURIComponent(ts)}&order=updated_at.desc`, undefined, { Prefer: "" })
+ .then((r) => r.json());
+ const rounds = [];
+ await run("start1-cold");
+ await run("start2-warm");
+ for (const round of [1, 2]) {
+ const before = await maxUpdated();
+ await run(`start${round * 2 + 1}-with-session_start-${round}`, { sessionStart: true });
+ const changed = await changedSince(before);
+ rounds.push({
+ round,
+ max_updated_at_before: before,
+ max_updated_at_after: await maxUpdated(),
+ rows_rewritten: changed.map((r) => ({ id: r.id.slice(0, 8), title: r.title.slice(0, 40), decay_multiplier: r.decay_multiplier })),
+ });
+ await run(`start${round * 2 + 2}-after-session_start-${round}`);
+ }
+ ollama.close();
+ const summary = { label, mode, venue: SUPABASE_URL, seeded_learnings: seedRows, rounds, starts };
+ writeFileSync(join(outDir, `summary-${label}-egress.json`), JSON.stringify(summary, null, 2));
+ return summary;
+}
+
+const result = mode === "flow" ? await flow() : await egress();
+const failureCheck = checkFailures(allVenueRequests);
+writeFileSync(join(outDir, `failure-check-${label}-${mode}.json`), JSON.stringify(failureCheck, null, 2));
+console.log(JSON.stringify({ ...result, health_text: undefined, stderr_error_lines: undefined, failure_check: failureCheck }, null, 2));
+if (failureCheck.unexpected.length > 0) {
+ console.error(`[driver] FAIL: ${failureCheck.unexpected.length} unexpected venue failure(s) — not on EXPECTED_FAILURES:`);
+ for (const u of failureCheck.unexpected) console.error(` ${u}`);
+ process.exit(1);
+}
+console.error(`[driver] PASS: no unexpected venue failures (${Object.keys(failureCheck.expected_seen).length} known kinds seen)`);
+process.exit(0);