From 5113e7af1da3e4765e40d1251f9a3071bdc7059a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 20:29:57 -0700 Subject: [PATCH 01/13] fix: count { success: false } results as failures in the effect tracker (GIT-104) track() recorded any resolved promise as a success. Several tracked writers catch their own errors and resolve anyway, so `health` reported failed writes as succeeded. On a blank Supabase, scar_usage 404s and knowledge_triples 400s were both counted as successes. - track(): a resolved value of the form { success: false } is recorded as a failure (message from error, then message, then display). The value is still returned to the caller. Rejections are unchanged. - Tracked writers that swallowed errors now let the tracker see them: - writeTriples attempts every triple, then rejects with a summary if any failed. - generateVariantsForScar rejects if a variant insert failed. - Cache writes under track() use a rejecting writeResult(). setResult() keeps swallowing for its direct callers. - The session_close transcript task returns the save result. - recordScarUsageBatch reports the error text on failure. Tests: +11 (6 effect-tracker outcome accounting, 5 tracked-writer failure surfacing). Co-Authored-By: Claude Opus 5 --- src/services/cache.ts | 50 ++++++---- src/services/effect-tracker.ts | 67 +++++++++---- src/services/triple-writer.ts | 23 +++-- src/services/variant-generation.ts | 8 ++ src/tools/record-scar-usage-batch.ts | 1 + src/tools/session-close.ts | 2 + src/types/index.ts | 2 + tests/unit/services/effect-tracker.test.ts | 95 +++++++++++++++++++ .../tracked-writers-surface-failures.test.ts | 84 ++++++++++++++++ 9 files changed, 287 insertions(+), 45 deletions(-) create mode 100644 tests/unit/services/effect-tracker.test.ts create mode 100644 tests/unit/services/tracked-writers-surface-failures.test.ts diff --git a/src/services/cache.ts b/src/services/cache.ts index 1b84179..f2f7966 100644 --- a/src/services/cache.ts +++ b/src/services/cache.ts @@ -180,29 +180,37 @@ export class CacheService { * Set cached result */ async setResult(key: string, data: T, ttlMs: number): Promise { - if (!this.enabled) return; - try { - const now = Date.now(); - const entry: CacheEntry = { - key, - created_at: now, - expires_at: now + ttlMs, - data, - }; - - const filename = this.keyToFilename(key); - const filepath = join(this.resultsDir, filename); - const content = JSON.stringify(entry, null, 2); - - writeFileSync(filepath, content, { mode: 0o600 }); - console.error(`[cache] SET: ${key} (TTL: ${ttlMs}ms)`); + await this.writeResult(key, data, ttlMs); } catch (error) { console.warn(`[cache] Error writing ${key}:`, error); // Don't disable cache on write errors - might be transient } } + /** + * Like setResult, but rejects on a write error. Used under the effect + * tracker so cache write failures are counted (GIT-104). + */ + private async writeResult(key: string, data: T, ttlMs: number): Promise { + if (!this.enabled) return; + + const now = Date.now(); + const entry: CacheEntry = { + key, + created_at: now, + expires_at: now + ttlMs, + data, + }; + + const filename = this.keyToFilename(key); + const filepath = join(this.resultsDir, filename); + const content = JSON.stringify(entry, null, 2); + + writeFileSync(filepath, content, { mode: 0o600 }); + console.error(`[cache] SET: ${key} (TTL: ${ttlMs}ms)`); + } + /** * Convenience method: get or fetch scar search results */ @@ -224,7 +232,7 @@ export class CacheService { // Cache the result (tracked, fire-and-forget) getEffectTracker().track("cache_set", "scar_search", () => - this.setResult(key, data, TTL.SCAR_SEARCH) + this.writeResult(key, data, TTL.SCAR_SEARCH) ); return { data, cache_hit: false }; @@ -250,7 +258,7 @@ export class CacheService { // Cache the result (tracked, fire-and-forget) getEffectTracker().track("cache_set", "decisions", () => - this.setResult(key, data, TTL.DECISIONS) + this.writeResult(key, data, TTL.DECISIONS) ); return { data, cache_hit: false }; @@ -276,7 +284,7 @@ export class CacheService { // Cache the result (tracked, fire-and-forget) getEffectTracker().track("cache_set", "wins", () => - this.setResult(key, data, TTL.WINS) + this.writeResult(key, data, TTL.WINS) ); return { data, cache_hit: false }; @@ -314,7 +322,7 @@ export class CacheService { const data = await fetcher(); getEffectTracker().track("cache_set", "sessions", () => - this.setResult(key, data, TTL.SESSIONS) + this.writeResult(key, data, TTL.SESSIONS) ); return { data, cache_hit: false }; } @@ -337,7 +345,7 @@ export class CacheService { const data = await fetcher(); getEffectTracker().track("cache_set", "scar_usage", () => - this.setResult(key, data, TTL.SCAR_USAGE) + this.writeResult(key, data, TTL.SCAR_USAGE) ); return { data, cache_hit: false }; } diff --git a/src/services/effect-tracker.ts b/src/services/effect-tracker.ts index 0df8005..9d3a1ca 100644 --- a/src/services/effect-tracker.ts +++ b/src/services/effect-tracker.ts @@ -91,6 +91,27 @@ class RingBuffer { } } +// --------------------------------------------------------------------------- +// Result inspection +// --------------------------------------------------------------------------- + +/** + * If a resolved value reports its own failure (`{ success: false }`), return a + * message describing it; otherwise null. Prefers the value's error, then + * message, then display text. + */ +function reportedFailure(value: unknown): string | null { + if (typeof value !== "object" || value === null) return null; + const v = value as Record; + if (v.success !== false) return null; + for (const key of ["error", "message", "display"] as const) { + const field = v[key]; + if (field instanceof Error) return field.message; + if (typeof field === "string" && field.length > 0) return field; + } + return "resolved with success: false"; +} + // --------------------------------------------------------------------------- // EffectTracker // --------------------------------------------------------------------------- @@ -106,7 +127,10 @@ export class EffectTracker { * Wrap an async fire-and-forget operation for tracking. * * - On success: records success + duration, returns the result. - * - On failure: records failure + error message, returns undefined. + * - On a result of the form `{ success: false, ... }`: records a failure + * (GIT-104 — many tools catch their own errors and resolve with that + * shape), and still returns the result to the caller. + * - On rejection: records failure + error message, returns undefined. * The error is **not** rethrown — this is intentionally fire-and-forget. */ track( @@ -118,29 +142,36 @@ export class EffectTracker { const stats = this.getOrCreatePath(path); stats.attempted++; + const recordFailure = (errorMsg: string) => { + const duration = Date.now() - start; + stats.failed++; + stats.totalDurationMs += duration; + stats.lastFailure = { + error: errorMsg, + timestamp: new Date().toISOString(), + }; + this.failures.push({ + path, + target, + error: errorMsg, + timestamp: new Date().toISOString(), + durationMs: duration, + }); + }; + return fn().then( (result) => { - const duration = Date.now() - start; + const reported = reportedFailure(result); + if (reported !== null) { + recordFailure(reported); + return result; + } stats.succeeded++; - stats.totalDurationMs += duration; + stats.totalDurationMs += Date.now() - start; return result; }, (error) => { - const duration = Date.now() - start; - const errorMsg = error instanceof Error ? error.message : String(error); - stats.failed++; - stats.totalDurationMs += duration; - stats.lastFailure = { - error: errorMsg, - timestamp: new Date().toISOString(), - }; - this.failures.push({ - path, - target, - error: errorMsg, - timestamp: new Date().toISOString(), - durationMs: duration, - }); + recordFailure(error instanceof Error ? error.message : String(error)); return undefined; }, ); diff --git a/src/services/triple-writer.ts b/src/services/triple-writer.ts index 97c14f8..81d8410 100644 --- a/src/services/triple-writer.ts +++ b/src/services/triple-writer.ts @@ -276,7 +276,11 @@ export function extractDecisionTriples(params: DecisionTripleParams): TripleCand /** * Write triples to knowledge_triples table. - * Fire-and-forget: errors logged but never thrown to caller. + * + * Every candidate is attempted even if an earlier one fails. If any failed, + * rejects afterwards with a summary so the caller's effect tracker records the + * failure (GIT-104) — callers run this under getEffectTracker().track(), which + * never rethrows. */ export async function writeTriples(candidates: TripleCandidate[]): Promise { if (candidates.length === 0 || !hasSupabase()) { @@ -284,6 +288,7 @@ export async function writeTriples(candidates: TripleCandidate[]): Promise 0) { + throw new Error(`${errors.length}/${candidates.length} triples failed: ${errors[0]}`); + } + return written; } @@ -325,7 +336,7 @@ export async function writeTriples(candidates: TripleCandidate[]): Promise {}). + * Fire-and-forget — run under getEffectTracker().track(); rejects if any triple failed. */ export function writeTriplesForLearning(params: LearningTripleParams): Promise { const triples = extractLearningTriples(params); @@ -334,7 +345,7 @@ export function writeTriplesForLearning(params: LearningTripleParams): Promise {}). + * Fire-and-forget — run under getEffectTracker().track(); rejects if any triple failed. */ export function writeTriplesForDecision(params: DecisionTripleParams): Promise { const triples = extractDecisionTriples(params); @@ -430,7 +441,7 @@ export function extractThreadResolutionTriples(params: ThreadResolutionTriplePar /** * Generate and write triples for a newly created thread. - * Fire-and-forget — call with .catch(() => {}). + * Fire-and-forget — run under getEffectTracker().track(); rejects if any triple failed. */ export function writeTriplesForThreadCreation(params: ThreadCreationTripleParams): Promise { const triples = extractThreadCreationTriples(params); @@ -439,7 +450,7 @@ export function writeTriplesForThreadCreation(params: ThreadCreationTripleParams /** * Generate and write triples for a resolved thread. - * Fire-and-forget — call with .catch(() => {}). + * Fire-and-forget — run under getEffectTracker().track(); rejects if any triple failed. */ export function writeTriplesForThreadResolution(params: ThreadResolutionTripleParams): Promise { const triples = extractThreadResolutionTriples(params); diff --git a/src/services/variant-generation.ts b/src/services/variant-generation.ts index ef997e3..9bfa80b 100644 --- a/src/services/variant-generation.ts +++ b/src/services/variant-generation.ts @@ -335,4 +335,12 @@ export async function generateVariantsForScar(scar: ScarData): Promise { console.error( `[variant-generation] ${generationSource}: created ${created}/2 variants for scar ${scar.id} (pipeline ${PIPELINE_VERSION})` ); + + // Surface partial failure to the effect tracker (GIT-104) instead of + // resolving as if both variants were written. + const firstFailure = results.find((r): r is PromiseRejectedResult => r.status === "rejected"); + if (firstFailure) { + const reason = firstFailure.reason instanceof Error ? firstFailure.reason.message : String(firstFailure.reason); + throw new Error(`created ${created}/${variants.length} variants: ${reason}`); + } } diff --git a/src/tools/record-scar-usage-batch.ts b/src/tools/record-scar-usage-batch.ts index b50c5ab..2a914aa 100644 --- a/src/tools/record-scar-usage-batch.ts +++ b/src/tools/record-scar-usage-batch.ts @@ -206,6 +206,7 @@ export async function recordScarUsageBatch( return { success: false, + error: error instanceof Error ? error.message : String(error), usage_ids: usageIds, resolved_count: resolvedCount, failed_count: params.scars.length - resolvedCount, diff --git a/src/tools/session-close.ts b/src/tools/session-close.ts index 42fc99e..59633ec 100644 --- a/src/tools/session-close.ts +++ b/src/tools/session-close.ts @@ -1384,6 +1384,8 @@ export async function sessionClose( }) .catch((err) => console.error("[session_close] Transcript processing failed:", err instanceof Error ? err.message : err)); } + // Return the result so a { success: false } save is counted as a failure (GIT-104). + return saveResult; }); } } diff --git a/src/types/index.ts b/src/types/index.ts index 6bd49ec..61654e7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -480,6 +480,8 @@ export interface RecordScarUsageBatchResult { resolved_count: number; failed_count: number; failed_identifiers?: string[]; + /** Why the batch failed (set when success is false) */ + error?: string; display?: string; performance: PerformanceData; } diff --git a/tests/unit/services/effect-tracker.test.ts b/tests/unit/services/effect-tracker.test.ts new file mode 100644 index 0000000..16a67dd --- /dev/null +++ b/tests/unit/services/effect-tracker.test.ts @@ -0,0 +1,95 @@ +/** + * Unit tests for EffectTracker.track() outcome accounting (GIT-104). + * + * Many tracked operations catch their own errors and resolve with + * { success: false }. Those must count as failures, not successes. + */ + +import { describe, it, expect } from "vitest"; +import { EffectTracker } from "../../../src/services/effect-tracker.js"; + +function statsFor(tracker: EffectTracker, path: string) { + return tracker.getHealthReport().byPath[path]; +} + +describe("EffectTracker.track outcome accounting", () => { + it("counts a resolved { success: false } as a failure and still returns the value", async () => { + const tracker = new EffectTracker(); + const value = { success: false, error: "Supabase upsert error: 404 - table not found" }; + + const returned = await tracker.track("scar_usage", "batch", async () => value); + + expect(returned).toBe(value); + expect(statsFor(tracker, "scar_usage")).toMatchObject({ attempted: 1, succeeded: 0, failed: 1 }); + const report = tracker.getHealthReport(); + expect(report.overall.paths_with_failures).toEqual(["scar_usage"]); + expect(report.recentFailures[0]).toMatchObject({ + path: "scar_usage", + target: "batch", + error: "Supabase upsert error: 404 - table not found", + }); + }); + + it("uses message, then display, when a { success: false } result has no error", async () => { + const tracker = new EffectTracker(); + await tracker.track("p", "message", async () => ({ success: false, message: "from message" })); + await tracker.track("p", "display", async () => ({ success: false, display: "from display" })); + await tracker.track("p", "error-object", async () => ({ success: false, error: new Error("from Error") })); + await tracker.track("p", "bare", async () => ({ success: false })); + + const errors = tracker.getHealthReport().recentFailures.map((f) => [f.target, f.error]); + expect(errors).toEqual([ + ["bare", "resolved with success: false"], + ["error-object", "from Error"], + ["display", "from display"], + ["message", "from message"], + ]); + expect(statsFor(tracker, "p")).toMatchObject({ attempted: 4, succeeded: 0, failed: 4 }); + }); + + it("counts a resolved { success: true } as a success", async () => { + const tracker = new EffectTracker(); + const value = { success: true, usage_ids: ["a"] }; + + const returned = await tracker.track("scar_usage", "batch", async () => value); + + expect(returned).toBe(value); + expect(statsFor(tracker, "scar_usage")).toMatchObject({ attempted: 1, succeeded: 1, failed: 0 }); + }); + + it("counts plain values (including undefined, null and numbers) as successes", async () => { + const tracker = new EffectTracker(); + await tracker.track("p", "undefined", async () => undefined); + await tracker.track("p", "null", async () => null); + await tracker.track("p", "number", async () => 0); + await tracker.track("p", "object-without-success", async () => ({ id: "x" })); + await tracker.track("p", "success-not-boolean-false", async () => ({ success: 0 })); + + expect(statsFor(tracker, "p")).toMatchObject({ attempted: 5, succeeded: 5, failed: 0 }); + expect(tracker.getHealthReport().recentFailures).toEqual([]); + }); + + it("counts a rejection as a failure and resolves undefined without rethrowing", async () => { + const tracker = new EffectTracker(); + + const returned = await tracker.track("triple_write", "thread_creation", async () => { + throw new Error("Supabase upsert error: 400 - invalid input syntax for type uuid"); + }); + + expect(returned).toBeUndefined(); + expect(statsFor(tracker, "triple_write")).toMatchObject({ attempted: 1, succeeded: 0, failed: 1 }); + expect(tracker.getHealthReport().recentFailures[0].error).toBe( + "Supabase upsert error: 400 - invalid input syntax for type uuid" + ); + }); + + it("formatSummary reports a { success: false } result as failed", async () => { + const tracker = new EffectTracker(); + await tracker.track("scar_usage", "session_close_batch", async () => ({ success: false, error: "404" })); + + const summary = tracker.formatSummary(); + expect(summary).toContain("scar_usage"); + expect(summary).toContain("0/1 succeeded (1 failed)"); + expect(summary).toContain("[scar_usage/session_close_batch] 404"); + }); +}); diff --git a/tests/unit/services/tracked-writers-surface-failures.test.ts b/tests/unit/services/tracked-writers-surface-failures.test.ts new file mode 100644 index 0000000..e3f5dd6 --- /dev/null +++ b/tests/unit/services/tracked-writers-surface-failures.test.ts @@ -0,0 +1,84 @@ +/** + * GIT-104: writers that run under getEffectTracker().track() must let the + * tracker see their failures. writeTriples and generateVariantsForScar used to + * catch every write error and resolve normally, so `health` counted them as + * successes. + */ + +import { describe, it, expect, vi } from "vitest"; + +// Implementations are reset between tests by the config (clearMocks/restoreMocks). +const directUpsert = vi.fn(); + +vi.mock("../../../src/services/supabase-client.js", () => ({ + directUpsert: (...args: unknown[]) => directUpsert(...args), + isConfigured: () => true, +})); +vi.mock("../../../src/services/tier.js", () => ({ + hasSupabase: () => true, + getTableName: (base: string) => `gitmem_${base}`, +})); +vi.mock("../../../src/services/license.js", () => ({ + getProConfig: () => ({}), +})); + +const { writeTriples } = await import("../../../src/services/triple-writer.js"); +const { generateVariantsForScar } = await import("../../../src/services/variant-generation.js"); +const { EffectTracker } = await import("../../../src/services/effect-tracker.js"); + +const candidate = (object: string) => ({ + subject: "Session: s1", + predicate: "created_thread", + object, + half_life_days: 30, + source_type: "thread", + source_id: "t-abc12345", + project: "test-project", + created_by: "cli", +}); + +describe("writeTriples", () => { + it("attempts every triple, then rejects if any failed", async () => { + directUpsert + .mockRejectedValueOnce(new Error("Supabase upsert error: 400 - invalid input syntax for type uuid")) + .mockResolvedValueOnce({ id: "ok" }); + + await expect(writeTriples([candidate("a"), candidate("b")])).rejects.toThrow( + "1/2 triples failed: Supabase upsert error: 400" + ); + expect(directUpsert).toHaveBeenCalledTimes(2); + }); + + it("resolves with the count when every triple is written", async () => { + directUpsert.mockResolvedValue({ id: "ok" }); + await expect(writeTriples([candidate("a"), candidate("b")])).resolves.toBe(2); + }); + + it("is recorded as a failure by the effect tracker", async () => { + directUpsert.mockImplementation(async () => { throw new Error("400"); }); + const tracker = new EffectTracker(); + + await tracker.track("triple_write", "thread_creation", () => writeTriples([candidate("a")])); + + expect(tracker.getHealthReport().byPath.triple_write).toMatchObject({ succeeded: 0, failed: 1 }); + }); +}); + +describe("generateVariantsForScar", () => { + const scar = { id: "scar-1", title: "t", description: "d" }; + + it("rejects when a variant insert fails", async () => { + directUpsert + .mockResolvedValueOnce({ id: "v1" }) + .mockRejectedValueOnce(new Error("column scar_enforcement_variants.active does not exist")); + + await expect(generateVariantsForScar(scar)).rejects.toThrow( + "created 1/2 variants: column scar_enforcement_variants.active does not exist" + ); + }); + + it("resolves when both variants are written", async () => { + directUpsert.mockResolvedValue({ id: "v" }); + await expect(generateVariantsForScar(scar)).resolves.toBeUndefined(); + }); +}); From 5496e010303728b310bf3476dd274e4025b8af83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 20:34:44 -0700 Subject: [PATCH 02/13] fix: route the usage table through getTableName and make setup.sql re-applicable (GIT-84) Scar usage was written to and read from the literal table "scar_usage". That table does not exist on a customer store (setup.sql creates gitmem_scar_usage), so every usage write 404'd and the loss went unnoticed. On a blank Supabase, gitmem_scar_usage stayed at 0 rows after recall, confirm_scars and session_close. - record-scar-usage, record-scar-usage-batch, analytics and behavioral-decay now use getTableName("scar_usage"). The two storage.upsert("scar_usage") sites are unchanged: the storage layer already applies the prefix. - Guard test: fails on a bare table-name literal passed to directUpsert, directQuery, directQueryAll or directPatch, in a `table:` option, or in a REST URL. It also fails if storage is handed an already-prefixed name. Literals that setup.sql creates under exactly that name are allowlisted pending a ruling. - setup.sql: - refresh_scar_behavioral_scores() only rewrites rows whose decay_multiplier actually changes. Otherwise, once usage rows land, it bumps updated_at on every session_start and invalidates the GIT-98 disk cache. - Adds the missing DROP POLICY IF EXISTS for gitmem_licenses and gitmem_license_activations, so a second run no longer fails at statement 72. - schema/migrations/GIT-84-ntegs-scar-usage-rename.sql: NOT EXECUTED. Needs human review. nTEG's install (prefix orchestra_) keeps usage in plain scar_usage; this renames it to orchestra_scar_usage and leaves a security_invoker compatibility view. Tests: +8 (4 table-name guard, 4 setup.sql re-apply safety). Co-Authored-By: Claude Opus 5 --- .../GIT-84-ntegs-scar-usage-rename.sql | 104 ++++++++++++++++ schema/setup.sql | 10 +- src/services/analytics.ts | 2 +- src/services/behavioral-decay.ts | 3 +- src/tools/record-scar-usage-batch.ts | 2 +- src/tools/record-scar-usage.ts | 4 +- tests/unit/schema-setup-reapply.test.ts | 57 +++++++++ tests/unit/table-name-literals.test.ts | 115 ++++++++++++++++++ 8 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 schema/migrations/GIT-84-ntegs-scar-usage-rename.sql create mode 100644 tests/unit/schema-setup-reapply.test.ts create mode 100644 tests/unit/table-name-literals.test.ts diff --git a/schema/migrations/GIT-84-ntegs-scar-usage-rename.sql b/schema/migrations/GIT-84-ntegs-scar-usage-rename.sql new file mode 100644 index 0000000..73d0fbe --- /dev/null +++ b/schema/migrations/GIT-84-ntegs-scar-usage-rename.sql @@ -0,0 +1,104 @@ +-- ============================================================================ +-- GIT-84 — nTEG production only: rename the usage table to match the prefix +-- ============================================================================ +-- +-- ⚠️ HUMAN REVIEW REQUIRED. NOT EXECUTED ANYWHERE. NOT FOR CUSTOMER STORES. +-- +-- WHO NEEDS THIS +-- Only nTEG's own install, which runs with GITMEM_TABLE_PREFIX=orchestra_ but +-- whose usage table is named plain `scar_usage`. Customer stores created from +-- schema/setup.sql already have `gitmem_scar_usage` and need nothing. +-- +-- WHY +-- Before GIT-84 the code wrote and read the literal table "scar_usage". After +-- GIT-84 it uses getTableName("scar_usage"), which on nTEG's install resolves +-- to `orchestra_scar_usage`. Upgrading nTEG's install without this migration +-- makes every usage write and read 404 there — the same silent loss GIT-84 +-- fixes for customers. +-- +-- WHAT IT DOES (one transaction; aborts on any surprise) +-- 1. Asserts public.scar_usage is a table and public.orchestra_scar_usage does +-- not exist yet. +-- 2. Renames scar_usage -> orchestra_scar_usage. Indexes, constraints, RLS +-- policies, grants and triggers move with the table. +-- 3. Creates a compatibility VIEW named scar_usage over the renamed table, so +-- anything still using the old name (older gitmem clients, SQL functions, +-- edge functions, dashboards) keeps working unchanged. security_invoker +-- makes the view enforce the underlying table's RLS for the caller; without +-- it the view would run as its owner and bypass RLS. +-- 4. Lists (RAISE NOTICE) every function in public whose body mentions +-- scar_usage, for the reviewer to update at leisure. PL/pgSQL resolves +-- table names at execution time, so those functions keep working through +-- the view in the meantime. +-- +-- WHAT IT DOES NOT DO +-- - It does not rewrite nTEG's refresh_scar_behavioral_scores(). setup.sql's +-- copy gained an `IS DISTINCT FROM` guard in GIT-84 so unchanged rows are not +-- rewritten (which invalidated GIT-98's disk cache on every session_start). +-- nTEG's copy reads orchestra_* tables and was not inspected; apply the same +-- guard there by hand after reviewing its current definition (section 5). +-- +-- BEFORE RUNNING +-- - Take a backup / confirm PITR. +-- - Run against a branch or staging copy first. +-- - Check the NOTICE output from step 4 and any edge functions that query +-- /rest/v1/scar_usage (they will keep working through the view). +-- +-- ROLLBACK +-- BEGIN; +-- DROP VIEW public.scar_usage; +-- ALTER TABLE public.orchestra_scar_usage RENAME TO scar_usage; +-- COMMIT; +-- ============================================================================ + +BEGIN; + +-- 1. Preconditions +DO $$ +BEGIN + IF to_regclass('public.scar_usage') IS NULL THEN + RAISE EXCEPTION 'GIT-84: public.scar_usage not found — wrong database, or already migrated'; + END IF; + IF (SELECT relkind FROM pg_class WHERE oid = 'public.scar_usage'::regclass) <> 'r' THEN + RAISE EXCEPTION 'GIT-84: public.scar_usage is not a plain table (already migrated to a view?)'; + END IF; + IF to_regclass('public.orchestra_scar_usage') IS NOT NULL THEN + RAISE EXCEPTION 'GIT-84: public.orchestra_scar_usage already exists — refusing to overwrite'; + END IF; +END $$; + +-- 2. Rename (policies, indexes, constraints, grants, triggers follow the table) +ALTER TABLE public.scar_usage RENAME TO orchestra_scar_usage; + +-- 3. Compatibility alias under the old name. A single-table SELECT * view is +-- auto-updatable, so INSERT/UPDATE/DELETE through it still work. +CREATE VIEW public.scar_usage + WITH (security_invoker = true) + AS SELECT * FROM public.orchestra_scar_usage; + +COMMENT ON VIEW public.scar_usage IS + 'GIT-84 compatibility alias for orchestra_scar_usage. Drop once nothing reads the old name.'; + +-- Match the table's grants for the Supabase API roles (RLS still applies via security_invoker). +GRANT SELECT, INSERT, UPDATE, DELETE ON public.scar_usage TO service_role; + +-- 4. Report functions that still name the old table (informational) +DO $$ +DECLARE r record; +BEGIN + FOR r IN + SELECT p.oid::regprocedure AS fn + FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + AND p.prosrc ~ '\mscar_usage\M' + LOOP + RAISE NOTICE 'GIT-84: function % mentions scar_usage (works via the view; update when convenient)', r.fn; + END LOOP; +END $$; + +-- 5. (Manual, after review) Apply the GIT-84 guard to nTEG's +-- refresh_scar_behavioral_scores(): add to its UPDATE ... WHERE clause +-- AND l.decay_multiplier IS DISTINCT FROM +-- so rows whose multiplier does not change are not rewritten. + +COMMIT; diff --git a/schema/setup.sql b/schema/setup.sql index badd255..70cd62e 100644 --- a/schema/setup.sql +++ b/schema/setup.sql @@ -404,7 +404,12 @@ BEGIN updated_at = NOW() FROM usage_stats us WHERE l.id = us.scar_id - AND COALESCE(l.is_active, true) = true; + AND COALESCE(l.is_active, true) = true + -- Only touch rows whose multiplier actually changes. Rewriting unchanged + -- rows bumped updated_at on every session_start, which invalidates every + -- client's on-disk embedding cache (GIT-98) for no reason. + AND l.decay_multiplier IS DISTINCT FROM + GREATEST(0.1, 1.0 - (us.times_dismissed::FLOAT / us.times_surfaced::FLOAT) * 0.8); GET DIAGNOSTICS v_updated = ROW_COUNT; @@ -529,9 +534,12 @@ CREATE TABLE IF NOT EXISTS gitmem_license_activations ( ALTER TABLE gitmem_licenses ENABLE ROW LEVEL SECURITY; ALTER TABLE gitmem_license_activations ENABLE ROW LEVEL SECURITY; +-- Drop-then-create so setup.sql can be re-applied (CREATE POLICY has no IF NOT EXISTS) +DROP POLICY IF EXISTS "Service role full access" ON gitmem_licenses; CREATE POLICY "Service role full access" ON gitmem_licenses FOR ALL USING (auth.role() = 'service_role'); +DROP POLICY IF EXISTS "Service role full access" ON gitmem_license_activations; CREATE POLICY "Service role full access" ON gitmem_license_activations FOR ALL USING (auth.role() = 'service_role'); diff --git a/src/services/analytics.ts b/src/services/analytics.ts index a1a637e..e42e827 100644 --- a/src/services/analytics.ts +++ b/src/services/analytics.ts @@ -201,7 +201,7 @@ export async function queryScarUsageByDateRange( surfaced_at: `gte.${startDate}`, }; - return directQueryAll("scar_usage", { + return directQueryAll(getTableName("scar_usage"), { select: "scar_id,scar_title,scar_severity,agent,reference_type,execution_successful,surfaced_at", filters, order: "surfaced_at.desc", diff --git a/src/services/behavioral-decay.ts b/src/services/behavioral-decay.ts index f29071f..652e715 100644 --- a/src/services/behavioral-decay.ts +++ b/src/services/behavioral-decay.ts @@ -12,6 +12,7 @@ */ import { isConfigured, safeInFilter } from "./supabase-client.js"; +import { getTableName } from "./tier.js"; const SUPABASE_URL = process.env.SUPABASE_URL || ""; const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_KEY || ""; @@ -98,7 +99,7 @@ export async function fetchDismissalCounts( try { // Query scar_usage for the given scar IDs (last 90 days) - const url = new URL(`${SUPABASE_REST_URL}/scar_usage`); + const url = new URL(`${SUPABASE_REST_URL}/${getTableName("scar_usage")}`); url.searchParams.set("select", "scar_id,reference_type"); url.searchParams.set("scar_id", safeInFilter(scarIds)); url.searchParams.set("surfaced_at", `gte.${new Date(Date.now() - 90 * 86400000).toISOString()}`); diff --git a/src/tools/record-scar-usage-batch.ts b/src/tools/record-scar-usage-batch.ts index b50c5ab..01c3783 100644 --- a/src/tools/record-scar-usage-batch.ts +++ b/src/tools/record-scar-usage-batch.ts @@ -161,7 +161,7 @@ export async function recordScarUsageBatch( // Insert all usage records in parallel const insertPromises = usageRecords.map(async (record) => { const result = await supabase.directUpsert<{ id: string }>( - "scar_usage", + getTableName("scar_usage"), record ); return result?.id || null; diff --git a/src/tools/record-scar-usage.ts b/src/tools/record-scar-usage.ts index b6e5146..40ca688 100644 --- a/src/tools/record-scar-usage.ts +++ b/src/tools/record-scar-usage.ts @@ -10,7 +10,7 @@ import { v4 as uuidv4 } from "uuid"; import { wrapDisplay } from "../services/display-protocol.js"; import * as supabase from "../services/supabase-client.js"; -import { hasSupabase } from "../services/tier.js"; +import { hasSupabase, getTableName } from "../services/tier.js"; import { getStorage } from "../services/storage.js"; import { detectAgent } from "../services/agent-detection.js"; import { getCurrentSession } from "../services/session-state.js"; @@ -61,7 +61,7 @@ export async function recordScarUsage( if (hasSupabase()) { const upsertStart = Date.now(); - await supabase.directUpsert("scar_usage", usageData); + await supabase.directUpsert(getTableName("scar_usage"), usageData); breakdown.upsert = { latency_ms: Date.now() - upsertStart, source: "supabase", diff --git a/tests/unit/schema-setup-reapply.test.ts b/tests/unit/schema-setup-reapply.test.ts new file mode 100644 index 0000000..af11f8e --- /dev/null +++ b/tests/unit/schema-setup-reapply.test.ts @@ -0,0 +1,57 @@ +/** + * schema/setup.sql must be safe to apply twice (GIT-84). + * + * Customers re-run setup.sql to pick up schema changes. CREATE POLICY and + * CREATE TRIGGER have no IF NOT EXISTS, so each needs a DROP ... IF EXISTS + * first; one missing pair (gitmem_licenses) made the second run fail at + * statement 72 on a real Supabase. + * + * Also pins the GIT-84 guard on refresh_scar_behavioral_scores(): rows whose + * decay_multiplier would not change must not be rewritten, because the rewrite + * bumps updated_at and invalidates every client's GIT-98 disk cache. + */ + +import { describe, it, expect } from "vitest"; +import { readFileSync } from "fs"; +import { join } from "path"; + +const SQL = readFileSync(join(__dirname, "../../schema/setup.sql"), "utf-8") + // strip -- comments so commented-out statements don't count + .replace(/--[^\n]*/g, ""); + +const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase(); + +describe("schema/setup.sql re-apply safety (GIT-84)", () => { + it("drops every policy before creating it", () => { + const created = [...SQL.matchAll(/CREATE POLICY\s+("[^"]+")\s+ON\s+([\w.]+)/gi)].map((m) => `${m[1]} ON ${m[2]}`); + expect(created.length).toBeGreaterThan(0); + const missing = created.filter((p) => { + const [name, table] = p.split(" ON "); + const drop = new RegExp(`DROP POLICY IF EXISTS\\s+${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s+ON\\s+${table}\\s*;`, "i"); + const dropAt = SQL.search(drop); + const createAt = SQL.search(new RegExp(`CREATE POLICY\\s+${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s+ON\\s+${table}\\b`, "i")); + return dropAt < 0 || dropAt > createAt; + }); + expect(missing).toEqual([]); + }); + + it("drops every trigger before creating it", () => { + const created = [...SQL.matchAll(/CREATE TRIGGER\s+(\w+)[\s\S]*?\bON\s+([\w.]+)/gi)].map((m) => [m[1], m[2]]); + expect(created.length).toBeGreaterThan(0); + const missing = created.filter(([name, table]) => !new RegExp(`DROP TRIGGER IF EXISTS\\s+${name}\\s+ON\\s+${table}\\s*;`, "i").test(SQL)); + expect(missing).toEqual([]); + }); + + it("creates tables and indexes with IF NOT EXISTS", () => { + const bare = [...SQL.matchAll(/CREATE\s+(?:UNIQUE\s+)?(TABLE|INDEX)\s+(?!IF NOT EXISTS)(\w+)/gi)].map((m) => `${m[1]} ${m[2]}`); + expect(bare).toEqual([]); + }); + + it("refresh_scar_behavioral_scores() only rewrites rows whose multiplier changes", () => { + const fn = SQL.slice(SQL.search(/CREATE OR REPLACE FUNCTION refresh_scar_behavioral_scores/i)); + const update = norm(fn.slice(0, fn.indexOf("GET DIAGNOSTICS"))); + const expr = "greatest(0.1, 1.0 - (us.times_dismissed::float / us.times_surfaced::float) * 0.8)"; + expect(update).toContain(`set decay_multiplier = ${expr}`); + expect(update).toContain(`and l.decay_multiplier is distinct from ${expr}`); + }); +}); diff --git a/tests/unit/table-name-literals.test.ts b/tests/unit/table-name-literals.test.ts new file mode 100644 index 0000000..569f046 --- /dev/null +++ b/tests/unit/table-name-literals.test.ts @@ -0,0 +1,115 @@ +/** + * Guard: Supabase table names must go through getTableName() (GIT-84). + * + * The user schema is prefixed (gitmem_ by default, GITMEM_TABLE_PREFIX to + * override). A bare literal like "scar_usage" passed straight to PostgREST + * silently 404s on every customer project — that is how scar usage was lost. + * + * Checked: + * - directUpsert / directQuery / directQueryAll / directPatch whose table + * argument is a string literal + * - `table: "..."` option objects (listRecords and friends) + * - REST URLs built as `${SUPABASE_REST_URL}/name` or `/rest/v1/name` + * The storage layer (getStorage().upsert/query/get) takes a COLLECTION name + * and applies the prefix itself, so there the rule is reversed: passing a + * prefixed name or getTableName() would double-prefix. + * + * Known literals awaiting a ruling are allowlisted below, each with a reason. + * Removing an entry is the point — the list should only shrink. + */ + +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync, statSync } from "fs"; +import { join, relative } from "path"; + +const SRC = join(__dirname, "../../src"); + +function collectTsFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) out.push(...collectTsFiles(full)); + else if (entry.endsWith(".ts") && !entry.endsWith(".test.ts") && !entry.endsWith(".d.ts")) out.push(full); + } + return out; +} + +/** + * Literal table names that setup.sql creates under exactly that name (or that + * live outside the user schema). Changing the code alone would break them, so + * they stay literal until ruled on. Keyed by table name. + */ +const ALLOWED_LITERALS: Record = { + gitmem_query_metrics: "setup.sql creates it with a hard-coded gitmem_ prefix — ruling needed (GIT-84 sweep)", + knowledge_triples: "setup.sql creates it unprefixed — ruling needed (GIT-84 sweep)", + scar_enforcement_variants: "setup.sql creates it unprefixed; column drift tracked in GIT-106", + variant_assignments: "not created by setup.sql at all — ruling needed (GIT-84 sweep)", + variant_performance_metrics: "not created by setup.sql at all — ruling needed (GIT-84 sweep)", + community_feedback: "gitmem's own feedback endpoint (feedback-remote.ts), not the user schema", +}; + +/** Files that build REST URLs for diagnostics with a literal prefix — ruling needed (GIT-84 sweep). */ +const ALLOWED_URL_FILES = new Set(["src/commands/check.ts"]); + +const DIRECT_CALL = /\b(directUpsert|directQuery|directQueryAll|directPatch)\s*(?:<(?:[^<>]|<[^<>]*>)*>)?\s*\(\s*(["'`])([A-Za-z_][\w]*)\2/g; +const TABLE_OPTION = /\btable:\s*(["'`])([A-Za-z_][\w]*)\1/g; +const REST_URL = /(?:SUPABASE_REST_URL\}|\/rest\/v1)\/([A-Za-z_][\w]*)/g; +const STORAGE_CALL = /\b(?:storage|getStorage\(\))\s*\.\s*(upsert|query|get)\s*(?:<(?:[^<>]|<[^<>]*>)*>)?\s*\(\s*([^,)]+)/g; + +interface Finding { file: string; line: number; text: string } + +function scan(): { bare: Finding[]; doublePrefixed: Finding[]; allowlistedSeen: Set } { + const bare: Finding[] = []; + const doublePrefixed: Finding[] = []; + const allowlistedSeen = new Set(); + + for (const full of collectTsFiles(SRC)) { + const file = relative(join(SRC, ".."), full); + const src = readFileSync(full, "utf-8"); + const lineOf = (i: number) => src.slice(0, i).split("\n").length; + const flag = (name: string, i: number, text: string) => { + if (name in ALLOWED_LITERALS) { allowlistedSeen.add(name); return; } + bare.push({ file, line: lineOf(i), text }); + }; + + for (const m of src.matchAll(DIRECT_CALL)) flag(m[3], m.index!, `${m[1]}("${m[3]}")`); + for (const m of src.matchAll(TABLE_OPTION)) flag(m[2], m.index!, `table: "${m[2]}"`); + for (const m of src.matchAll(REST_URL)) { + if (m[1] === "rpc") continue; // RPC function names are not tables + if (ALLOWED_URL_FILES.has(file)) continue; + flag(m[1], m.index!, `REST /${m[1]}`); + } + for (const m of src.matchAll(STORAGE_CALL)) { + const arg = m[2].trim(); + if (/getTableName\s*\(/.test(arg) || /^["'`]gitmem_/.test(arg)) { + doublePrefixed.push({ file, line: lineOf(m.index!), text: `storage.${m[1]}(${arg})` }); + } + } + } + return { bare, doublePrefixed, allowlistedSeen }; +} + +describe("Supabase table names go through getTableName() (GIT-84)", () => { + const { bare, doublePrefixed, allowlistedSeen } = scan(); + const fmt = (fs: Finding[]) => fs.map((f) => `${f.file}:${f.line} ${f.text}`).join("\n"); + + it("passes no bare table-name literal to directUpsert/directQuery/directQueryAll/directPatch, table: options or REST URLs", () => { + expect(fmt(bare), "wrap these in getTableName()").toBe(""); + }); + + it("never hands the storage layer an already-prefixed name (it prefixes collections itself)", () => { + expect(fmt(doublePrefixed), "pass the bare collection name to storage").toBe(""); + }); + + it("keeps the allowlist honest: every entry is still in use", () => { + const stale = Object.keys(ALLOWED_LITERALS).filter((name) => !allowlistedSeen.has(name)); + expect(stale, "remove allowlist entries that no longer occur").toEqual([]); + }); + + it("would catch the GIT-84 bug (self-test on a synthetic source)", () => { + const sample = `await supabase.directUpsert<{ id: string }>(\n "scar_usage",\n record\n);`; + const hits = [...sample.matchAll(DIRECT_CALL)].map((m) => m[3]); + expect(hits).toEqual(["scar_usage"]); + expect([...`new URL(\`\${SUPABASE_REST_URL}/scar_usage\`)`.matchAll(REST_URL)].map((m) => m[1])).toEqual(["scar_usage"]); + }); +}); From b0a5c6cc525990c18600549d7db6ed0fd3dfb492 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 21:31:02 -0700 Subject: [PATCH 03/13] chore: move the nTEG-only scar_usage migration out of the public package (GIT-84) schema/ ships in the npm package, so the nTEG-specific rename migration would have gone to every customer. It now lives in the private orchestra repo (ops/migrations/). nTEG's own upgrade still needs that privately held migration before it runs this release. Co-Authored-By: Claude Opus 5 --- .../GIT-84-ntegs-scar-usage-rename.sql | 104 ------------------ 1 file changed, 104 deletions(-) delete mode 100644 schema/migrations/GIT-84-ntegs-scar-usage-rename.sql diff --git a/schema/migrations/GIT-84-ntegs-scar-usage-rename.sql b/schema/migrations/GIT-84-ntegs-scar-usage-rename.sql deleted file mode 100644 index 73d0fbe..0000000 --- a/schema/migrations/GIT-84-ntegs-scar-usage-rename.sql +++ /dev/null @@ -1,104 +0,0 @@ --- ============================================================================ --- GIT-84 — nTEG production only: rename the usage table to match the prefix --- ============================================================================ --- --- ⚠️ HUMAN REVIEW REQUIRED. NOT EXECUTED ANYWHERE. NOT FOR CUSTOMER STORES. --- --- WHO NEEDS THIS --- Only nTEG's own install, which runs with GITMEM_TABLE_PREFIX=orchestra_ but --- whose usage table is named plain `scar_usage`. Customer stores created from --- schema/setup.sql already have `gitmem_scar_usage` and need nothing. --- --- WHY --- Before GIT-84 the code wrote and read the literal table "scar_usage". After --- GIT-84 it uses getTableName("scar_usage"), which on nTEG's install resolves --- to `orchestra_scar_usage`. Upgrading nTEG's install without this migration --- makes every usage write and read 404 there — the same silent loss GIT-84 --- fixes for customers. --- --- WHAT IT DOES (one transaction; aborts on any surprise) --- 1. Asserts public.scar_usage is a table and public.orchestra_scar_usage does --- not exist yet. --- 2. Renames scar_usage -> orchestra_scar_usage. Indexes, constraints, RLS --- policies, grants and triggers move with the table. --- 3. Creates a compatibility VIEW named scar_usage over the renamed table, so --- anything still using the old name (older gitmem clients, SQL functions, --- edge functions, dashboards) keeps working unchanged. security_invoker --- makes the view enforce the underlying table's RLS for the caller; without --- it the view would run as its owner and bypass RLS. --- 4. Lists (RAISE NOTICE) every function in public whose body mentions --- scar_usage, for the reviewer to update at leisure. PL/pgSQL resolves --- table names at execution time, so those functions keep working through --- the view in the meantime. --- --- WHAT IT DOES NOT DO --- - It does not rewrite nTEG's refresh_scar_behavioral_scores(). setup.sql's --- copy gained an `IS DISTINCT FROM` guard in GIT-84 so unchanged rows are not --- rewritten (which invalidated GIT-98's disk cache on every session_start). --- nTEG's copy reads orchestra_* tables and was not inspected; apply the same --- guard there by hand after reviewing its current definition (section 5). --- --- BEFORE RUNNING --- - Take a backup / confirm PITR. --- - Run against a branch or staging copy first. --- - Check the NOTICE output from step 4 and any edge functions that query --- /rest/v1/scar_usage (they will keep working through the view). --- --- ROLLBACK --- BEGIN; --- DROP VIEW public.scar_usage; --- ALTER TABLE public.orchestra_scar_usage RENAME TO scar_usage; --- COMMIT; --- ============================================================================ - -BEGIN; - --- 1. Preconditions -DO $$ -BEGIN - IF to_regclass('public.scar_usage') IS NULL THEN - RAISE EXCEPTION 'GIT-84: public.scar_usage not found — wrong database, or already migrated'; - END IF; - IF (SELECT relkind FROM pg_class WHERE oid = 'public.scar_usage'::regclass) <> 'r' THEN - RAISE EXCEPTION 'GIT-84: public.scar_usage is not a plain table (already migrated to a view?)'; - END IF; - IF to_regclass('public.orchestra_scar_usage') IS NOT NULL THEN - RAISE EXCEPTION 'GIT-84: public.orchestra_scar_usage already exists — refusing to overwrite'; - END IF; -END $$; - --- 2. Rename (policies, indexes, constraints, grants, triggers follow the table) -ALTER TABLE public.scar_usage RENAME TO orchestra_scar_usage; - --- 3. Compatibility alias under the old name. A single-table SELECT * view is --- auto-updatable, so INSERT/UPDATE/DELETE through it still work. -CREATE VIEW public.scar_usage - WITH (security_invoker = true) - AS SELECT * FROM public.orchestra_scar_usage; - -COMMENT ON VIEW public.scar_usage IS - 'GIT-84 compatibility alias for orchestra_scar_usage. Drop once nothing reads the old name.'; - --- Match the table's grants for the Supabase API roles (RLS still applies via security_invoker). -GRANT SELECT, INSERT, UPDATE, DELETE ON public.scar_usage TO service_role; - --- 4. Report functions that still name the old table (informational) -DO $$ -DECLARE r record; -BEGIN - FOR r IN - SELECT p.oid::regprocedure AS fn - FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = 'public' - AND p.prosrc ~ '\mscar_usage\M' - LOOP - RAISE NOTICE 'GIT-84: function % mentions scar_usage (works via the view; update when convenient)', r.fn; - END LOOP; -END $$; - --- 5. (Manual, after review) Apply the GIT-84 guard to nTEG's --- refresh_scar_behavioral_scores(): add to its UPDATE ... WHERE clause --- AND l.decay_multiplier IS DISTINCT FROM --- so rows whose multiplier does not change are not rewritten. - -COMMIT; From 63cc263a043f10cec94192a7cd1d6990cefb89ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 21:32:54 -0700 Subject: [PATCH 04/13] fix: make scar variant A/B testing dev-tier only (GIT-106) setup.sql doesn't provision what the variant paths need: - scar_enforcement_variants has no `active` column - variant_assignments and variant_performance_metrics don't exist So on a customer (pro) store every variant read and write failed. - hasVariants() is now dev-only. - Variant generation in create_learning now consults hasVariants(). Before, it ran on every scar on any Supabase tier. - The variant_performance_metrics write in recall states its hasVariants() gate explicitly. It was already nested inside that block. Tests: +7 (the predicate on free, pro and dev; recall assignment and the metrics write skipped on pro and run on dev; create_learning generation skipped on pro and run on dev). The two create_learning test tier mocks gain hasVariants. Co-Authored-By: Claude Opus 5 --- src/services/tier.ts | 14 +- src/tools/create-learning.ts | 7 +- src/tools/recall.ts | 5 +- .../unit/tools/create-learning-errors.test.ts | 1 + .../create-learning-field-persistence.test.ts | 1 + tests/unit/tools/variants-dev-only.test.ts | 146 ++++++++++++++++++ 6 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 tests/unit/tools/variants-dev-only.test.ts diff --git a/src/services/tier.ts b/src/services/tier.ts index 7594b79..1b8f34f 100644 --- a/src/services/tier.ts +++ b/src/services/tier.ts @@ -101,9 +101,17 @@ export function hasCompliance(): boolean { return getTier() === "dev"; } -/** Whether scar variant A/B testing is active (pro, dev — needs Supabase for assignment storage) */ +/** + * Whether scar variant A/B testing is active (dev only). + * + * GIT-106: the variant tables (scar_enforcement_variants.active, + * variant_assignments, variant_performance_metrics) are not provisioned by + * schema/setup.sql, so on a customer (pro) store every variant read and write + * failed. Every variant path — assignment in recall, generation in + * create_learning, and the performance-metrics write — gates on this. + */ export function hasVariants(): boolean { - return getTier() !== "free"; + return getTier() === "dev"; } /** Whether transcript storage/retrieval is available (dev only) */ @@ -126,7 +134,7 @@ export function hasProInsights(): boolean { return getTier() !== "free"; } -/** Whether detailed performance metrics recording is active (pro, dev — aligned with hasVariants) */ +/** Whether detailed performance metrics recording is active (pro, dev) */ export function hasMetrics(): boolean { return getTier() !== "free"; } diff --git a/src/tools/create-learning.ts b/src/tools/create-learning.ts index 9b3fbe9..b65a660 100644 --- a/src/tools/create-learning.ts +++ b/src/tools/create-learning.ts @@ -17,7 +17,7 @@ import { flushCache } from "../services/startup.js"; import { writeTriplesForLearning } from "../services/triple-writer.js"; import { generateVariantsForScar } from "../services/variant-generation.js"; import { getEffectTracker } from "../services/effect-tracker.js"; -import { hasSupabase, getTableName } from "../services/tier.js"; +import { hasSupabase, hasVariants, getTableName } from "../services/tier.js"; import { getStorage } from "../services/storage.js"; import { getProject } from "../services/session-state.js"; import { @@ -253,8 +253,9 @@ export async function createLearning( }) ); - // Auto-generate A/B testing variants for scars (tracked fire-and-forget) - if (params.learning_type === "scar") { + // Auto-generate A/B testing variants for scars (tracked fire-and-forget). + // Dev tier only (GIT-106): customer schemas do not provision the variant tables. + if (params.learning_type === "scar" && hasVariants()) { getEffectTracker().track("variant_generation", "learning", () => generateVariantsForScar({ id: learningId, diff --git a/src/tools/recall.ts b/src/tools/recall.ts index 2b4710d..698df10 100644 --- a/src/tools/recall.ts +++ b/src/tools/recall.ts @@ -597,8 +597,9 @@ export async function recall(params: RecallParams): Promise { variantResults.set(scarId, variantInfo); } - // Record enforcement metrics for variants (dev only) - if (hasMetrics()) { + // Record enforcement metrics for variants. Nested under hasVariants() (dev + // only, GIT-106): variant_performance_metrics is not provisioned by setup.sql. + if (hasVariants() && hasMetrics()) { const metricsPromises = results .filter(({ variantInfo }) => variantInfo.has_variants && variantInfo.variant) .map(async ({ scarId, variantInfo }) => { diff --git a/tests/unit/tools/create-learning-errors.test.ts b/tests/unit/tools/create-learning-errors.test.ts index 2641dc1..3db0a6b 100644 --- a/tests/unit/tools/create-learning-errors.test.ts +++ b/tests/unit/tools/create-learning-errors.test.ts @@ -20,6 +20,7 @@ vi.mock("../../../src/services/tier.js", () => ({ hasSupabase: vi.fn(() => true), getTableName: vi.fn((base: string) => `orchestra_${base}`), hasProInsights: () => false, + hasVariants: () => false, })); vi.mock("../../../src/services/embedding.js", () => ({ diff --git a/tests/unit/tools/create-learning-field-persistence.test.ts b/tests/unit/tools/create-learning-field-persistence.test.ts index 451c0d7..6275ade 100644 --- a/tests/unit/tools/create-learning-field-persistence.test.ts +++ b/tests/unit/tools/create-learning-field-persistence.test.ts @@ -35,6 +35,7 @@ vi.mock("../../../src/services/tier.js", () => ({ hasSupabase: vi.fn(() => true), getTableName: vi.fn((base: string) => `orchestra_${base}`), hasProInsights: () => false, + hasVariants: () => false, })); vi.mock("../../../src/services/embedding.js", () => ({ diff --git a/tests/unit/tools/variants-dev-only.test.ts b/tests/unit/tools/variants-dev-only.test.ts new file mode 100644 index 0000000..0bc6bc7 --- /dev/null +++ b/tests/unit/tools/variants-dev-only.test.ts @@ -0,0 +1,146 @@ +/** + * GIT-106: scar variant A/B testing is dev-tier only. + * + * The variant tables are not provisioned by schema/setup.sql + * (scar_enforcement_variants has no `active` column; variant_assignments and + * variant_performance_metrics do not exist), so on a customer (pro) store every + * variant read and write failed. All three variant paths must consult + * hasVariants(), and hasVariants() must be false on pro. + * + * Uses the REAL tier module, driven by GITMEM_TIER, so the predicate itself is + * under test rather than a mock of it. + */ + +import { describe, it, expect, vi, beforeEach, afterAll } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +const directUpsert = vi.fn(async () => ({ id: "row-id" })); +const cachedScarSearch = vi.fn(); +const getOrAssignVariant = vi.fn(); +const generateVariantsForScar = vi.fn(async () => undefined); + +vi.mock("../../../src/services/supabase-client.js", () => ({ + isConfigured: () => true, + directUpsert: (...a: unknown[]) => directUpsert(...a), + cachedScarSearch: (...a: unknown[]) => cachedScarSearch(...a), + upsertRecord: async () => undefined, + fetchRelatedTriples: async () => new Map(), +})); + +// Force recall onto the Supabase branch (not the local vector cache). +vi.mock("../../../src/services/local-vector-search.js", () => ({ + isLocalSearchReady: () => false, + localScarSearch: async () => [], +})); + +vi.mock("../../../src/services/variant-assignment.js", () => ({ + getOrAssignVariant: (...a: unknown[]) => getOrAssignVariant(...a), + formatVariantEnforcement: (_v: unknown, title: string) => title, +})); + +vi.mock("../../../src/services/variant-generation.js", () => ({ + generateVariantsForScar: (...a: unknown[]) => generateVariantsForScar(...a), +})); + +// create_learning collaborators that would otherwise reach the network or disk. +vi.mock("../../../src/services/embedding.js", () => ({ + embed: async () => null, + isEmbeddingAvailable: () => false, +})); +vi.mock("../../../src/services/startup.js", () => ({ flushCache: async () => undefined })); +vi.mock("../../../src/services/triple-writer.js", () => ({ writeTriplesForLearning: async () => 0 })); + +const { resetTier, hasVariants } = await import("../../../src/services/tier.js"); +const { recall } = await import("../../../src/tools/recall.js"); +const { createLearning } = await import("../../../src/tools/create-learning.js"); +const { setGitmemDir, clearGitmemDirCache } = await import("../../../src/services/gitmem-dir.js"); + +const ORIGINAL_TIER = process.env.GITMEM_TIER; +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitmem-git106-")); + +function useTier(tier: "free" | "pro" | "dev") { + process.env.GITMEM_TIER = tier; + resetTier(); +} + +beforeEach(() => { + setGitmemDir(tmpDir); + cachedScarSearch.mockResolvedValue({ + results: [{ + id: "scar-1", title: "Scar one", description: "about deploys", severity: "high", + counter_arguments: [], applies_when: [], similarity: 0.9, + }], + cache_hit: false, + }); + getOrAssignVariant.mockResolvedValue({ + has_variants: true, + variant: { id: "variant-1", variant_name: "traditional", enforcement_config: { type: "imperative", steps: [] } }, + }); +}); + +afterAll(() => { + if (ORIGINAL_TIER === undefined) delete process.env.GITMEM_TIER; + else process.env.GITMEM_TIER = ORIGINAL_TIER; + resetTier(); + clearGitmemDirCache(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +const variantMetricWrites = () => + directUpsert.mock.calls.filter(([table]) => table === "variant_performance_metrics"); + +describe("hasVariants() is dev-only (GIT-106)", () => { + it.each([ + ["free", false], + ["pro", false], + ["dev", true], + ] as const)("%s tier → %s", (tier, expected) => { + useTier(tier); + expect(hasVariants()).toBe(expected); + }); +}); + +describe("recall: variant assignment and variant_performance_metrics (GIT-106)", () => { + it("skips both on pro", async () => { + useTier("pro"); + const result = await recall({ plan: "deploy to production" }); + + expect(result.scars?.length ?? 0).toBeGreaterThan(0); // recall itself still works + expect(getOrAssignVariant).not.toHaveBeenCalled(); + expect(variantMetricWrites()).toHaveLength(0); + }); + + it("runs both on dev (control)", async () => { + useTier("dev"); + await recall({ plan: "deploy to production" }); + await new Promise((r) => setTimeout(r, 0)); // metrics write is fire-and-forget + + expect(getOrAssignVariant).toHaveBeenCalled(); + expect(variantMetricWrites().length).toBeGreaterThan(0); + }); +}); + +describe("create_learning: variant generation (GIT-106)", () => { + const scar = { + learning_type: "scar" as const, + title: "Variant gating test scar", + description: "A scar created to check that variant generation is tier-gated.", + severity: "medium" as const, + counter_arguments: ["You might think X — but Y", "Another counter"], + project: "test-project", + }; + + it("does not generate variants on pro", async () => { + useTier("pro"); + await createLearning(scar); + expect(generateVariantsForScar).not.toHaveBeenCalled(); + }); + + it("generates variants on dev (control)", async () => { + useTier("dev"); + await createLearning(scar); + expect(generateVariantsForScar).toHaveBeenCalledTimes(1); + }); +}); From 252529d710acf9e7a1881bf06eb19306f77fa14d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 21:33:49 -0700 Subject: [PATCH 05/13] fix: stop selecting scar_title/scar_severity from the usage table (GIT-108) gitmem_scar_usage has never had those columns, so the analytics, session_start insight and session_close blindspot reads failed with 400 "column does not exist" once GIT-84 pointed them at the real table. The query now selects only scar_id, agent, reference_type, execution_successful and surfaced_at, and returns title and severity as null. The existing enrichScarUsageTitles() fills them from the learnings table by id; every caller already runs it. Tests: +2. The select is checked against the columns setup.sql actually creates, and enrichment fills title and severity from learnings. Co-Authored-By: Claude Opus 5 --- src/services/analytics.ts | 14 ++-- .../analytics-scar-usage-columns.test.ts | 67 +++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 tests/unit/services/analytics-scar-usage-columns.test.ts diff --git a/src/services/analytics.ts b/src/services/analytics.ts index e42e827..f0327c4 100644 --- a/src/services/analytics.ts +++ b/src/services/analytics.ts @@ -201,11 +201,15 @@ export async function queryScarUsageByDateRange( surfaced_at: `gte.${startDate}`, }; - return directQueryAll(getTableName("scar_usage"), { - select: "scar_id,scar_title,scar_severity,agent,reference_type,execution_successful,surfaced_at", + // GIT-108: the usage table has no scar_title/scar_severity columns (selecting + // them was a 400 on every store). They come back null here and are filled + // from the learnings table by enrichScarUsageTitles(), which every caller runs. + const rows = await directQueryAll>(getTableName("scar_usage"), { + select: "scar_id,agent,reference_type,execution_successful,surfaced_at", filters, order: "surfaced_at.desc", }); + return rows.map((r) => ({ ...r, scar_title: null, scar_severity: null })); } ); @@ -244,8 +248,10 @@ export async function queryRepeatMistakes( } /** - * Resolve scar titles and severities from the learnings table for scar_usage - * records that have null/missing title data. + * Resolve scar titles and severities from the learnings table. + * + * Required after queryScarUsageByDateRange(): the usage table stores only + * scar_id, so every record arrives with null title/severity (GIT-108). */ export async function enrichScarUsageTitles( usages: ScarUsageRecord[] diff --git a/tests/unit/services/analytics-scar-usage-columns.test.ts b/tests/unit/services/analytics-scar-usage-columns.test.ts new file mode 100644 index 0000000..c3dde75 --- /dev/null +++ b/tests/unit/services/analytics-scar-usage-columns.test.ts @@ -0,0 +1,67 @@ +/** + * GIT-108: analytics must only select columns the usage table actually has. + * + * queryScarUsageByDateRange selected scar_title and scar_severity, which + * gitmem_scar_usage has never had, so the analytics and blindspot reads + * returned a 400 (column does not exist) on every store. Title and severity now + * come from the learnings table via enrichScarUsageTitles(). + */ + +import { describe, it, expect, vi } from "vitest"; +import { readFileSync } from "fs"; +import { join } from "path"; + +const directQueryAll = vi.fn(); +const directQuery = vi.fn(); + +vi.mock("../../../src/services/supabase-client.js", () => ({ + directQueryAll: (...a: unknown[]) => directQueryAll(...a), + directQuery: (...a: unknown[]) => directQuery(...a), + safeInFilter: (ids: string[]) => `in.(${ids.join(",")})`, +})); +vi.mock("../../../src/services/cache.js", () => ({ + getCache: () => ({ + getOrFetchScarUsage: async (_p: unknown, _d: unknown, _a: unknown, fetch: () => Promise) => ({ data: await fetch() }), + }), +})); + +const { queryScarUsageByDateRange, enrichScarUsageTitles } = await import("../../../src/services/analytics.js"); + +/** Columns of gitmem_scar_usage as setup.sql creates them (CREATE TABLE + ADD COLUMN migrations). */ +function scarUsageColumns(): Set { + const sql = readFileSync(join(__dirname, "../../../schema/setup.sql"), "utf-8"); + const create = sql.match(/CREATE TABLE IF NOT EXISTS gitmem_scar_usage \(([\s\S]*?)\n\);/)![1]; + const cols = create.split("\n").map((l) => l.trim().match(/^([a-z_]+)\s+[A-Z]/)?.[1]).filter(Boolean) as string[]; + for (const m of sql.matchAll(/ALTER TABLE gitmem_scar_usage ADD COLUMN IF NOT EXISTS ([a-z_]+)/g)) cols.push(m[1]); + return new Set(cols); +} + +describe("analytics scar-usage query (GIT-108)", () => { + it("selects only columns that gitmem_scar_usage has", async () => { + directQueryAll.mockResolvedValue([]); + await queryScarUsageByDateRange("2026-01-01T00:00:00Z", "2026-12-31T00:00:00Z", "default"); + + const [table, opts] = directQueryAll.mock.calls[0] as [string, { select: string }]; + expect(table).toBe("gitmem_scar_usage"); + const selected = opts.select.split(","); + const known = scarUsageColumns(); + expect(known.has("scar_id")).toBe(true); // sanity: the parser found the table + expect(selected.filter((c) => !known.has(c))).toEqual([]); + expect(selected).not.toContain("scar_title"); + expect(selected).not.toContain("scar_severity"); + }); + + it("returns null title/severity for enrichment to fill from learnings", async () => { + directQueryAll.mockResolvedValue([ + { scar_id: "s1", agent: "cli", reference_type: "acknowledged", execution_successful: null, surfaced_at: "2026-09-01T00:00:00Z" }, + ]); + directQuery.mockResolvedValue([{ id: "s1", title: "Dry-run migrations first", severity: "high" }]); + + const raw = await queryScarUsageByDateRange("2026-01-01T00:00:00Z", "2026-12-31T00:00:00Z", "default"); + expect(raw[0]).toMatchObject({ scar_id: "s1", scar_title: null, scar_severity: null }); + + const enriched = await enrichScarUsageTitles(raw); + expect(directQuery).toHaveBeenCalledWith("gitmem_learnings", expect.objectContaining({ select: "id,title,severity" })); + expect(enriched[0]).toMatchObject({ scar_title: "Dry-run migrations first", scar_severity: "high" }); + }); +}); From 0d64f4565f4e88a0bc716a3abe6879378808689a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 21:43:18 -0700 Subject: [PATCH 06/13] fix: store applied memories and relevance in metrics metadata; hook cache owner-only (GIT-109) updateRelevanceData upserted a memories_applied column that gitmem_query_metrics has never had, and matched free-text Q6 titles against UUIDs, so no relevance was ever recorded. The upsert also omitted tool_name (NOT NULL), so it could not have succeeded anyway. - updateRelevanceData PATCHes the existing metric row and merges metadata.memories_applied (UUIDs) and metadata.memory_relevance ({uuid: high|low|noise}) into the metadata JSONB every schema since 1.8.0 has. Existing metadata keys are preserved. - hook-scars.json holds learning text and was world-readable (0644). It is now written 0600 and chmod'ed, which also tightens files from earlier installs, matching the vector disk cache. session_close's new input (APPLYING confirmations plus ratings) is wired in the next commit. Tests: +4 Co-Authored-By: Claude Opus 5 --- src/services/metrics.ts | 39 +++--- src/services/startup.ts | 9 +- .../unit/services/relevance-metadata.test.ts | 114 ++++++++++++++++++ 3 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 tests/unit/services/relevance-metadata.test.ts diff --git a/src/services/metrics.ts b/src/services/metrics.ts index 6a091ad..1150542 100644 --- a/src/services/metrics.ts +++ b/src/services/metrics.ts @@ -352,11 +352,21 @@ export async function withMetrics( /** * Update metrics with relevance data (called at session close) * + * For every metric row of the session that surfaced memories, records which of + * those memories were applied and how relevant each confirmed one was rated. + * Stored inside the row's existing `metadata` JSONB as `memories_applied` + * (UUIDs) and `memory_relevance` ({ uuid: "high" | "low" | "noise" }). + * + * GIT-109: this used to upsert a `memories_applied` column that + * gitmem_query_metrics has never had. It now PATCHes the existing row and + * merges into `metadata`, so no column beyond the 1.8.0 schema is written. + * * Wrapped by Effect Tracker — failures are visible in health reports. */ export async function updateRelevanceData( sessionId: string, - memoriesApplied: string[] + memoriesApplied: string[], + relevanceById: Record = {} ): Promise { if (!hasSupabase()) return; const tracker = getEffectTracker(); @@ -364,28 +374,27 @@ export async function updateRelevanceData( // Get all metrics for this session that surfaced memories const metrics = await supabase.listRecords<{ id: string; - memories_surfaced?: string[]; + memories_surfaced?: string[] | null; + metadata?: Record | null; }>({ table: "gitmem_query_metrics", + columns: "id,memories_surfaced,metadata", filters: { session_id: sessionId }, }); if (!metrics || !Array.isArray(metrics)) return; - // Update each metric with applied memories for (const metric of metrics) { - if (metric.memories_surfaced && Array.isArray(metric.memories_surfaced)) { - const applied = metric.memories_surfaced.filter((id: string) => - memoriesApplied.includes(id) - ); - - if (applied.length > 0) { - await supabase.directUpsert("gitmem_query_metrics", { - id: metric.id, - memories_applied: applied, - }); - } - } + const surfaced = Array.isArray(metric.memories_surfaced) ? metric.memories_surfaced : []; + const applied = surfaced.filter((id) => memoriesApplied.includes(id)); + const relevance = Object.fromEntries( + surfaced.filter((id) => id in relevanceById).map((id) => [id, relevanceById[id]]) + ); + if (applied.length === 0 && Object.keys(relevance).length === 0) continue; + + await supabase.directPatch("gitmem_query_metrics", { id: `eq.${metric.id}` }, { + metadata: { ...(metric.metadata ?? {}), memories_applied: applied, memory_relevance: relevance }, + }); } }); } diff --git a/src/services/startup.ts b/src/services/startup.ts index 6a127ed..3cd2232 100644 --- a/src/services/startup.ts +++ b/src/services/startup.ts @@ -50,7 +50,8 @@ let backgroundRefreshInterval: ReturnType | null = null; * cache. This writes a stripped-down scar file (no embeddings) that hook * processes can read for keyword search. */ -function persistScarsForHooks(scars: ScarWithEmbedding[]): void { +/** @internal exported for tests */ +export function persistScarsForHooks(scars: ScarWithEmbedding[]): void { try { const gitmemDir = path.join(getGitmemDir(), "cache"); if (!fs.existsSync(gitmemDir)) { @@ -71,8 +72,12 @@ function persistScarsForHooks(scars: ScarWithEmbedding[]): void { self_check_criteria: s.self_check_criteria, })); + // Owner-only, like the vector disk cache: this holds learning text (GIT-109). + // chmod as well, because `mode` only applies when the file is first created + // and installs before this fix already have a 0644 file. const cachePath = path.join(gitmemDir, "hook-scars.json"); - fs.writeFileSync(cachePath, JSON.stringify(stripped)); + fs.writeFileSync(cachePath, JSON.stringify(stripped), { mode: 0o600 }); + fs.chmodSync(cachePath, 0o600); console.error(`[startup] Persisted ${stripped.length} scars to ${cachePath} for hook processes`); } catch (error) { // Non-fatal — hooks will just return nothing diff --git a/tests/unit/services/relevance-metadata.test.ts b/tests/unit/services/relevance-metadata.test.ts new file mode 100644 index 0000000..a09e3c0 --- /dev/null +++ b/tests/unit/services/relevance-metadata.test.ts @@ -0,0 +1,114 @@ +/** + * GIT-109: relevance data is stored inside gitmem_query_metrics.metadata. + * + * updateRelevanceData used to upsert a `memories_applied` column the table has + * never had, and compared free-text Q6 titles against UUIDs, so nothing was + * ever recorded. It now PATCHes the existing row's `metadata` JSONB — a column + * every schema since 1.8.0 has — and session_close feeds it the structured + * confirm_scars decisions and relevance ratings. + * + * Also: hook-scars.json holds learning text and must be owner-only (0600). + */ + +import { describe, it, expect, vi } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +const listRecords = vi.fn(); +const directPatch = vi.fn(async () => []); +const directUpsert = vi.fn(async () => ({})); + +vi.mock("../../../src/services/supabase-client.js", () => ({ + isConfigured: () => true, + listRecords: (...a: unknown[]) => listRecords(...a), + directPatch: (...a: unknown[]) => directPatch(...a), + directUpsert: (...a: unknown[]) => directUpsert(...a), +})); +vi.mock("../../../src/services/tier.js", async (importOriginal) => ({ + ...(await importOriginal()), + hasSupabase: () => true, +})); + +const { updateRelevanceData } = await import("../../../src/services/metrics.js"); +const { buildRelevanceInput } = await import("../../../src/tools/session-close.js"); +const { persistScarsForHooks } = await import("../../../src/services/startup.js"); +const { setGitmemDir, clearGitmemDirCache } = await import("../../../src/services/gitmem-dir.js"); + +const A = "aaaaaaaa-0000-4000-8000-000000000001"; +const B = "bbbbbbbb-0000-4000-8000-000000000002"; +const C = "cccccccc-0000-4000-8000-000000000003"; + +describe("updateRelevanceData (GIT-109)", () => { + it("PATCHes metadata on the existing row and never writes a memories_applied column", async () => { + listRecords.mockResolvedValue([ + { id: "m1", memories_surfaced: [A, B], metadata: { project: "p", match_count: 3 } }, + { id: "m2", memories_surfaced: [C], metadata: {} }, + { id: "m3", memories_surfaced: null, metadata: null }, + ]); + + await updateRelevanceData("session-1", [A], { A_unrelated: "high", [A]: "high", [B]: "noise" }); + + expect(listRecords).toHaveBeenCalledWith(expect.objectContaining({ + table: "gitmem_query_metrics", + columns: "id,memories_surfaced,metadata", + filters: { session_id: "session-1" }, + })); + expect(directUpsert).not.toHaveBeenCalled(); + // Only m1 surfaced anything applied or rated; m2/m3 are left alone. + expect(directPatch).toHaveBeenCalledTimes(1); + const [table, match, body] = directPatch.mock.calls[0] as [string, Record, Record]; + expect(table).toBe("gitmem_query_metrics"); + expect(match).toEqual({ id: "eq.m1" }); + expect(Object.keys(body)).toEqual(["metadata"]); + expect(body.metadata).toEqual({ + project: "p", + match_count: 3, // existing metadata preserved + memories_applied: [A], + memory_relevance: { [A]: "high", [B]: "noise" }, + }); + }); +}); + +describe("buildRelevanceInput (GIT-109)", () => { + const surfaced = [ + { scar_id: A, scar_title: "Dry-run migrations first", scar_severity: "high", surfaced_at: "t", source: "recall" as const }, + { scar_id: B, scar_title: "Verify after deploy", scar_severity: "medium", surfaced_at: "t", source: "recall" as const }, + { scar_id: C, scar_title: "Keys never in logs", scar_severity: "low", surfaced_at: "t", source: "recall" as const }, + ]; + const conf = (scar_id: string, decision: "APPLYING" | "N_A" | "REFUTED", relevance?: "high" | "low" | "noise") => + ({ scar_id, scar_title: "", decision, evidence: "x".repeat(60), confirmed_at: "t", relevance }); + + it("counts APPLYING confirmations as applied and keeps every rating", () => { + const out = buildRelevanceInput([conf(A, "APPLYING", "high"), conf(B, "N_A", "noise")], surfaced, []); + expect(out.appliedIds).toEqual([A]); + expect(out.relevanceById).toEqual({ [A]: "high", [B]: "noise" }); + }); + + it("resolves Q6 scars_applied entries by exact title or UUID, ignoring unmatched text", () => { + const out = buildRelevanceInput([], surfaced, ["verify after deploy", C, "something vague"]); + expect(out.appliedIds.sort()).toEqual([B, C].sort()); + expect(out.relevanceById).toEqual({}); + }); +}); + +describe("hook-scars.json is owner-only (GIT-109)", () => { + it("is written 0600, and an existing 0644 file is tightened", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gitmem-hookscars-")); + try { + setGitmemDir(dir); + const file = path.join(dir, "cache", "hook-scars.json"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, "[]", { mode: 0o644 }); + fs.chmodSync(file, 0o644); + + persistScarsForHooks([{ id: A, title: "t", description: "d", severity: "high" }]); + + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + expect(JSON.parse(fs.readFileSync(file, "utf8"))[0].id).toBe(A); + } finally { + clearGitmemDirCache(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); From 1ec20a359a9dc10bfa6d5e9e7de976c45fb2c3e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 21:43:18 -0700 Subject: [PATCH 07/13] fix: never write a column or table the store lacks; stop losing closes on setup.sql stores Customers often upgrade the package without re-running setup.sql. Several paths wrote objects that setup.sql never creates. PostgREST rejects the whole write on one unknown column, so: - session_close sent task_observations, children and claude_code_session_id, which exist only on nTEG's store. On a blank Supabase with 1.9.0, a CLI/desktop close that found a Claude Code transcript, or carried observations, got a 400 and the session was lost (close reported FAILED). - absorb_observations upserted sessions.task_observations and failed every time. - archive_learning PATCHed learnings.archived_at, so the learning was never archived. - transcript capture uploaded to a session-transcripts bucket and wrote gitmem_transcript_chunks, neither of which setup.sql creates. The tier can't tell a customer store from nTEG's (both validate as pro), so the store is asked. store-columns.ts runs a read-only one-row SELECT of the named columns, cached per process. An inconclusive probe is treated as absent: skipping an optional field is safe, sending one is not. - filterToStoreSessionColumns drops production-only session columns this store lacks. - absorb_observations, archive_learning and transcript capture check before writing. - nTEG's store keeps every field it has today. Also wires GIT-109 into session_close. APPLYING confirm_scars decisions and their relevance ratings feed updateRelevanceData. Q6 entries count only when they name a surfaced scar by UUID or exact title. Tests: +8 store-columns. Three tool suites that model nTEG's store now mock the probe as "present", and the session_close mock gains getConfirmations. Co-Authored-By: Claude Opus 5 --- src/services/session-columns.ts | 46 ++++++++ src/services/store-columns.ts | 73 ++++++++++++ src/tools/absorb-observations.ts | 18 ++- src/tools/archive-learning.ts | 10 +- src/tools/session-close.ts | 56 +++++++-- tests/unit/services/store-columns.test.ts | 110 ++++++++++++++++++ tests/unit/tools/absorb-observations.test.ts | 9 ++ tests/unit/tools/archive-learning.test.ts | 9 ++ .../unit/tools/session-close-supabase.test.ts | 10 ++ 9 files changed, 326 insertions(+), 15 deletions(-) create mode 100644 src/services/store-columns.ts create mode 100644 tests/unit/services/store-columns.test.ts diff --git a/src/services/session-columns.ts b/src/services/session-columns.ts index bf46439..6d62f05 100644 --- a/src/services/session-columns.ts +++ b/src/services/session-columns.ts @@ -1,3 +1,5 @@ +import { supportedColumns } from "./store-columns.js"; + /** * The persisted column set for the sessions table (GIT-74/R17). * @@ -72,6 +74,50 @@ export const SESSION_COLUMNS: ReadonlySet = new Set([ "task_observations", ]); +/** + * The production-only subset of SESSION_COLUMNS — present on nTEG's store, + * absent from schema/setup.sql and therefore from every customer store. + */ +export const PRODUCTION_ONLY_SESSION_COLUMNS: ReadonlySet = new Set([ + "blocked_by", + "children", + "claude_code_session_id", + "compacted", + "compacted_at", + "compacted_summary", + "handover_linear_slug", + "insights", + "metrics", + "pre_compaction_summary", + "task_observations", +]); + +/** + * filterToSessionColumns, then drop any production-only column THIS store + * does not have (probed once per process, see store-columns.ts). + * + * Use this for every Supabase write to the sessions table. The static list + * alone is the union of setup.sql and production, so on a customer store a + * close carrying observations, child agents or a Claude Code session id sent a + * column that does not exist — and PostgREST rejected the entire close. + */ +export async function filterToStoreSessionColumns( + data: Record, + table: string +): Promise> { + const filtered = filterToSessionColumns(data); + const optional = Object.keys(filtered).filter((k) => PRODUCTION_ONLY_SESSION_COLUMNS.has(k)); + if (optional.length === 0) return filtered; + + const present = await supportedColumns(table, optional); + const dropped = optional.filter((k) => !present.has(k)); + for (const k of dropped) delete filtered[k]; + if (dropped.length > 0) { + console.error(`[session-columns] Store has no ${dropped.join(", ")} column(s) on ${table}; not writing them`); + } + return filtered; +} + /** * Drop keys the sessions table does not have, so a local-only field cannot * fail an otherwise valid close. diff --git a/src/services/store-columns.ts b/src/services/store-columns.ts new file mode 100644 index 0000000..eb4e602 --- /dev/null +++ b/src/services/store-columns.ts @@ -0,0 +1,73 @@ +/** + * Store capability probe — which optional columns and tables does THIS store have? + * + * Customers provision their store from schema/setup.sql and often upgrade the + * package without re-running it. nTEG's own store carries extra columns and + * tables setup.sql does not define. The tier cannot tell the two apart (nTEG + * validates as "pro" like any customer), so writes of anything outside the + * baseline schema ask the store instead. + * + * Rule this enforces: never write a column or table the store lacks. A single + * unknown key fails a whole PostgREST write — which is how a session close was + * lost on every customer store that recorded observations or found a Claude + * Code transcript. + * + * The probe is a read-only one-row SELECT of just the named columns. Answers + * are cached for the life of the process. A probe that fails for any reason + * other than "column/table does not exist" is not cached and is treated as + * "absent" for that call — skipping an optional field is safe; sending one the + * store may not have is not. + */ + +import { directQuery } from "./supabase-client.js"; + +const known = new Map(); // "table.column" -> present? + +/** PostgREST / Postgres errors meaning "that column or table does not exist". */ +function isMissingObjectError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /\b(42703|42P01|PGRST204|PGRST205)\b|does not exist|Could not find the/.test(message); +} + +async function probe(table: string, columns: string[]): Promise<"present" | "missing" | "unknown"> { + try { + await directQuery(table, { select: columns.join(","), limit: 1 }); + return "present"; + } catch (error) { + return isMissingObjectError(error) ? "missing" : "unknown"; + } +} + +/** + * Return the subset of `candidates` that exist as columns of `table` in this store. + */ +export async function supportedColumns(table: string, candidates: string[]): Promise> { + const unique = [...new Set(candidates)]; + const unresolved = unique.filter((c) => !known.has(`${table}.${c}`)); + + if (unresolved.length > 0) { + const all = await probe(table, unresolved); + if (all === "present") { + for (const c of unresolved) known.set(`${table}.${c}`, true); + } else if (all === "missing") { + // At least one is absent — find out which, one column at a time. + for (const c of unresolved) { + const one = unresolved.length === 1 ? all : await probe(table, [c]); + if (one !== "unknown") known.set(`${table}.${c}`, one === "present"); + } + } + // "unknown": leave uncached; treated as absent below. + } + + return new Set(unique.filter((c) => known.get(`${table}.${c}`) === true)); +} + +/** Whether `table` exists in this store. */ +export async function storeHasTable(table: string): Promise { + return (await supportedColumns(table, ["id"])).has("id"); +} + +/** Forget cached answers (tests; or after a schema change within one process). */ +export function resetStoreColumnCache(): void { + known.clear(); +} diff --git a/src/tools/absorb-observations.ts b/src/tools/absorb-observations.ts index 4bdafe5..86bc11e 100644 --- a/src/tools/absorb-observations.ts +++ b/src/tools/absorb-observations.ts @@ -17,6 +17,7 @@ import { wrapDisplay } from "../services/display-protocol.js"; import { addObservations, getObservations, getCurrentSession } from "../services/session-state.js"; import { hasSupabase, getTableName } from "../services/tier.js"; import * as supabase from "../services/supabase-client.js"; +import { supportedColumns } from "../services/store-columns.js"; import { Timer, recordMetrics, @@ -64,13 +65,18 @@ export async function absorbObservations( // 3. Optionally persist to Supabase (fire-and-forget, non-fatal) const session = getCurrentSession(); + // task_observations exists on nTEG's store but not in setup.sql, so only + // persist where the store has the column. Observations stay in local session + // state either way and go out with session_close where supported. if (hasSupabase() && supabase.isConfigured() && session) { - supabase.directUpsert(getTableName("sessions"), { - id: session.sessionId, - task_observations: getObservations(), - }).catch((err) => { - console.error("[absorb_observations] Supabase persist failed (non-fatal):", err); - }); + const sessionsTable = getTableName("sessions"); + supportedColumns(sessionsTable, ["task_observations"]) + .then((present) => present.has("task_observations") + ? supabase.directUpsert(sessionsTable, { id: session.sessionId, task_observations: getObservations() }) + : undefined) + .catch((err) => { + console.error("[absorb_observations] Supabase persist failed (non-fatal):", err); + }); } const latencyMs = timer.stop(); diff --git a/src/tools/archive-learning.ts b/src/tools/archive-learning.ts index 6cf4c03..898413d 100644 --- a/src/tools/archive-learning.ts +++ b/src/tools/archive-learning.ts @@ -10,6 +10,7 @@ */ import { directPatch, directQuery, isConfigured } from "../services/supabase-client.js"; +import { supportedColumns } from "../services/store-columns.js"; import { hasSupabase, getTableName } from "../services/tier.js"; import { getStorage } from "../services/storage.js"; import { flushCache } from "../services/startup.js"; @@ -123,9 +124,14 @@ export async function archiveLearning(params: ArchiveLearningParams): Promise 0 ? parts : [trimmed]; } +/** + * Which surfaced memories were applied this session, and how relevant each + * confirmed one was rated (GIT-109). + * + * APPLYING confirmations from confirm_scars are authoritative. Q6 + * scars_applied is free text — usually titles — so an entry only counts when it + * names a surfaced scar by UUID or by exact (case-insensitive) title; it used to + * be compared to UUIDs verbatim and so almost never matched. + */ +export function buildRelevanceInput( + confirmations: ScarConfirmation[], + surfaced: SurfacedScar[], + scarsApplied: string[] +): { appliedIds: string[]; relevanceById: Record } { + const applied = new Set(confirmations.filter((c) => c.decision === "APPLYING").map((c) => c.scar_id)); + for (const entry of scarsApplied) { + const key = entry.trim().toLowerCase(); + if (!key) continue; + const match = surfaced.find((s) => s.scar_id.toLowerCase() === key || s.scar_title.trim().toLowerCase() === key); + if (match) applied.add(match.scar_id); + } + const relevanceById: Record = {}; + for (const c of confirmations) if (c.relevance) relevanceById[c.scar_id] = c.relevance; + return { appliedIds: [...applied], relevanceById }; +} + /** * Count scars applied from closing_reflection.scars_applied. */ @@ -1364,9 +1391,15 @@ export async function sessionClose( console.error(`[session_close] Extracted Claude session ID: ${claudeSessionId}`); } - // Phase 2: Upload transcript (fire-and-forget — was blocking ~500-5000ms) + // Phase 2: Upload transcript (fire-and-forget — was blocking ~500-5000ms). + // Only where the store is provisioned for transcripts: setup.sql creates + // neither the storage bucket nor the transcript_chunks table, so on a + // customer store this wrote to objects that do not exist. const transcriptProject = isRetroactive ? "default" : (existingSession?.project as string | undefined); - getEffectTracker().track("transcript", "session_close", async () => { + const transcriptStoreReady = hasSupabase() && await storeHasTable(getTableName("transcript_chunks")); + if (!transcriptStoreReady) { + console.error("[session_close] Store has no transcript_chunks table; transcript not uploaded"); + } else getEffectTracker().track("transcript", "session_close", async () => { const saveResult = await saveTranscript({ session_id: sessionId, transcript: transcriptContent, @@ -1427,8 +1460,14 @@ export async function sessionClose( // LOCAL file record — a different shape, carrying rendering fields like // `display`. One unknown key fails the whole upsert (PGRST204) and the // close reports FAILED, which is honest but fatal on a fresh install. + // + // Overriding rule: production-only columns (observations, child agents, the + // Claude Code session id) are sent only if THIS store has them. On a store + // provisioned from setup.sql they used to fail the entire close with PGRST204. + const sessionsTable = getTableName("sessions"); + const sessionRow = await filterToStoreSessionColumns(sessionData, sessionsTable); await Promise.all([ - supabase.directUpsert(getTableName("sessions"), filterToSessionColumns(sessionData)), + supabase.directUpsert(sessionsTable, sessionRow), blindspotPromise, ]); @@ -1494,9 +1533,12 @@ export async function sessionClose( const latencyMs = timer.stop(); const perfData = buildPerformanceData("session_close", latencyMs, 1); - // Update relevance data for memories applied during session - if (normalizedScarsApplied.length > 0) { - updateRelevanceData(sessionId, normalizedScarsApplied).catch((err) => console.error("[session_close] updateRelevanceData failed:", err instanceof Error ? err.message : err)); + // Update relevance data for memories applied during session (GIT-109). + // Structured confirm_scars decisions are the primary signal; Q6 scars_applied + // entries count when they name a surfaced scar by id or exact title. + const relevanceInput = buildRelevanceInput(getConfirmations(), getSurfacedScars(), normalizedScarsApplied); + if (relevanceInput.appliedIds.length > 0 || Object.keys(relevanceInput.relevanceById).length > 0) { + updateRelevanceData(sessionId, relevanceInput.appliedIds, relevanceInput.relevanceById).catch((err) => console.error("[session_close] updateRelevanceData failed:", err instanceof Error ? err.message : err)); } // Compute timing breakdown from task_completion timestamps. diff --git a/tests/unit/services/store-columns.test.ts b/tests/unit/services/store-columns.test.ts new file mode 100644 index 0000000..b4a1e69 --- /dev/null +++ b/tests/unit/services/store-columns.test.ts @@ -0,0 +1,110 @@ +/** + * Store capability probe (overriding rule: never write a column or table the + * store lacks). + * + * A store provisioned from setup.sql has none of nTEG's production-only + * session columns. Sending one failed the whole session_close upsert + * (PGRST204) — reproduced on a blank Supabase with 1.9.0: a close that carried + * observations or a Claude Code session id was lost. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const directQuery = vi.fn(); +vi.mock("../../../src/services/supabase-client.js", () => ({ + directQuery: (...a: unknown[]) => directQuery(...a), +})); + +const { supportedColumns, storeHasTable, resetStoreColumnCache } = await import("../../../src/services/store-columns.js"); +const { filterToStoreSessionColumns, PRODUCTION_ONLY_SESSION_COLUMNS, SESSION_COLUMNS } = await import( + "../../../src/services/session-columns.js" +); + +const missingColumn = (col: string) => + new Error(`Supabase REST error: 400 - {"code":"42703","message":"column gitmem_sessions.${col} does not exist"}`); + +/** A store whose tables have exactly these columns (plus id). */ +function storeWith(columns: Record) { + directQuery.mockImplementation(async (table: string, opts: { select: string }) => { + if (!(table in columns)) throw new Error(`Supabase REST error: 404 - {"code":"PGRST205","message":"Could not find the table 'public.${table}'"}`); + const have = new Set(["id", ...columns[table]]); + const missing = opts.select.split(",").find((c) => !have.has(c)); + if (missing) throw missingColumn(missing); + return []; + }); +} + +beforeEach(() => { + resetStoreColumnCache(); +}); + +describe("supportedColumns", () => { + it("answers in one read-only request when every column exists, then from cache", async () => { + storeWith({ gitmem_sessions: ["children", "task_observations"] }); + expect(await supportedColumns("gitmem_sessions", ["children", "task_observations"])).toEqual(new Set(["children", "task_observations"])); + expect(directQuery).toHaveBeenCalledTimes(1); + expect(directQuery).toHaveBeenCalledWith("gitmem_sessions", { select: "children,task_observations", limit: 1 }); + + await supportedColumns("gitmem_sessions", ["task_observations"]); + expect(directQuery).toHaveBeenCalledTimes(1); // cached + }); + + it("finds which columns are missing, one at a time", async () => { + storeWith({ gitmem_sessions: ["children"] }); + expect(await supportedColumns("gitmem_sessions", ["children", "task_observations"])).toEqual(new Set(["children"])); + }); + + it("treats a failed probe (not 'does not exist') as absent and does not cache it", async () => { + directQuery.mockRejectedValueOnce(new Error("fetch failed: ECONNRESET")); + expect(await supportedColumns("gitmem_learnings", ["archived_at"])).toEqual(new Set()); + + storeWith({ gitmem_learnings: ["archived_at"] }); + expect(await supportedColumns("gitmem_learnings", ["archived_at"])).toEqual(new Set(["archived_at"])); + }); + + it("storeHasTable is false for a table the store does not have", async () => { + storeWith({ gitmem_sessions: [] }); + expect(await storeHasTable("gitmem_transcript_chunks")).toBe(false); + expect(await storeHasTable("gitmem_sessions")).toBe(true); + }); +}); + +describe("filterToStoreSessionColumns", () => { + const closeRow = { + id: "s1", + session_title: "t", + closing_reflection: { what_worked: "x" }, + close_compliance: { close_type: "standard" }, + task_observations: [{ text: "obs" }], + children: [{ id: "c1" }], + claude_code_session_id: "cc-1", + display: "local-only rendering field", + }; + + it("on a setup.sql store: drops production-only columns, keeps every canonical one", async () => { + storeWith({ gitmem_sessions: [] }); + const row = await filterToStoreSessionColumns(closeRow, "gitmem_sessions"); + expect(row).toEqual({ + id: "s1", + session_title: "t", + closing_reflection: { what_worked: "x" }, + close_compliance: { close_type: "standard" }, + }); + }); + + it("on nTEG's store: keeps the production-only columns it has", async () => { + storeWith({ orchestra_sessions: [...PRODUCTION_ONLY_SESSION_COLUMNS] }); + const row = await filterToStoreSessionColumns(closeRow, "orchestra_sessions"); + expect(row).toMatchObject({ task_observations: [{ text: "obs" }], children: [{ id: "c1" }], claude_code_session_id: "cc-1" }); + expect(row).not.toHaveProperty("display"); + }); + + it("does not probe when the row has no production-only keys", async () => { + await filterToStoreSessionColumns({ id: "s1", session_title: "t" }, "gitmem_sessions"); + expect(directQuery).not.toHaveBeenCalled(); + }); + + it("the production-only set is exactly SESSION_COLUMNS minus setup.sql's columns", () => { + for (const c of PRODUCTION_ONLY_SESSION_COLUMNS) expect(SESSION_COLUMNS.has(c)).toBe(true); + }); +}); diff --git a/tests/unit/tools/absorb-observations.test.ts b/tests/unit/tools/absorb-observations.test.ts index eb646b9..05cee86 100644 --- a/tests/unit/tools/absorb-observations.test.ts +++ b/tests/unit/tools/absorb-observations.test.ts @@ -9,6 +9,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // --- Mock dependencies --- +// These tests model nTEG's store (orchestra_ prefix), which has the +// production-only columns and tables. Customer-store behaviour (columns absent) +// is covered in tests/unit/services/store-columns.test.ts. +vi.mock("../../../src/services/store-columns.js", () => ({ + supportedColumns: async (_table: string, candidates: string[]) => new Set(candidates), + storeHasTable: async () => true, + resetStoreColumnCache: () => {}, +})); + vi.mock("../../../src/services/supabase-client.js", () => ({ isConfigured: vi.fn(() => true), directUpsert: vi.fn(() => Promise.resolve({})), diff --git a/tests/unit/tools/archive-learning.test.ts b/tests/unit/tools/archive-learning.test.ts index 38fc4cd..7a004be 100644 --- a/tests/unit/tools/archive-learning.test.ts +++ b/tests/unit/tools/archive-learning.test.ts @@ -11,6 +11,15 @@ const mockDirectPatch = vi.fn().mockResolvedValue(undefined); const mockDirectQuery = vi.fn().mockResolvedValue([]); const mockIsConfigured = vi.fn(() => true); +// These tests model nTEG's store (orchestra_ prefix), which has the +// production-only columns and tables. Customer-store behaviour (columns absent) +// is covered in tests/unit/services/store-columns.test.ts. +vi.mock("../../../src/services/store-columns.js", () => ({ + supportedColumns: async (_table: string, candidates: string[]) => new Set(candidates), + storeHasTable: async () => true, + resetStoreColumnCache: () => {}, +})); + vi.mock("../../../src/services/supabase-client.js", () => ({ directPatch: (...args: unknown[]) => mockDirectPatch(...args), directQuery: (...args: unknown[]) => mockDirectQuery(...args), diff --git a/tests/unit/tools/session-close-supabase.test.ts b/tests/unit/tools/session-close-supabase.test.ts index 73cd1bd..d0d2872 100644 --- a/tests/unit/tools/session-close-supabase.test.ts +++ b/tests/unit/tools/session-close-supabase.test.ts @@ -20,6 +20,15 @@ vi.mock("../../../src/services/agent-detection.js", () => ({ detectAgent: () => ({ agent: "CLI", entrypoint: "cli", docker: true, hostname: "test" }), })); +// These tests model nTEG's store (orchestra_ prefix), which has the +// production-only columns and tables. Customer-store behaviour (columns absent) +// is covered in tests/unit/services/store-columns.test.ts. +vi.mock("../../../src/services/store-columns.js", () => ({ + supportedColumns: async (_table: string, candidates: string[]) => new Set(candidates), + storeHasTable: async () => true, + resetStoreColumnCache: () => {}, +})); + vi.mock("../../../src/services/supabase-client.js", () => ({ listRecords: vi.fn(), getRecord: vi.fn(), @@ -69,6 +78,7 @@ vi.mock("../../../src/services/storage.js", () => ({ vi.mock("../../../src/services/session-state.js", () => ({ clearCurrentSession: () => {}, getSurfacedScars: () => [], + getConfirmations: () => [], getObservations: () => [], getChildren: () => [], getThreads: () => [], From 5826fc3572499de27e201a61681f75054a8270d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 21:44:02 -0700 Subject: [PATCH 08/13] test: venue driver exercises observations and a Claude Code transcript; checks the close landed and relevance is readable - flow now calls absorb_observations and plants a Claude Code transcript where session_close looks for one. That's the path every real CLI/desktop close takes, and on 1.9.0 it lost the close on a setup.sql store. - The run fails unless: - the session row is persisted with its closing_reflection - (when recall surfaced scars) gitmem_query_metrics.metadata carries the confirmed scar in memories_applied and memory_relevance (GIT-109) - EXPECTED_FAILURES shrinks to GIT-73 and GIT-105. The GIT-84 (scar_usage 404) and GIT-106 (variants 400) entries are removed now that both are fixed in this release. Co-Authored-By: Claude Opus 5 --- tests/e2e/blank-supabase.mjs | 39 +++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/tests/e2e/blank-supabase.mjs b/tests/e2e/blank-supabase.mjs index bdedc4d..9bf7a74 100644 --- a/tests/e2e/blank-supabase.mjs +++ b/tests/e2e/blank-supabase.mjs @@ -36,7 +36,8 @@ 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 { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, realpathSync } from "node:fs"; +import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join, dirname, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -52,8 +53,6 @@ const DENY_REFS = new Set(["cjptxyezuxdiinufgrrm"]); // production GitMem — ne 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 }, ]; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = resolve(HERE, "../.."); @@ -278,6 +277,15 @@ async function flow() { ]; writeFileSync(join(gitmemDir, "threads.json"), JSON.stringify(localThreads, null, 2)); + // A Claude Code transcript where session_close looks for one (~/.claude/projects/ ->). + // Every real CLI/desktop close finds one, so the driver must too. + const claudeSessionId = randomUUID(); + for (const cwd of new Set([home, realpathSync(home)])) { + const dir = join(home, ".claude", "projects", cwd.replace(/\//g, "-")); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${claudeSessionId}.jsonl`), JSON.stringify({ type: "user", session_id: claudeSessionId, message: { role: "user", content: "hi" } }) + "\n"); + } + const seeded = { remote_threads_open: await count("gitmem_threads", "&status=eq.active"), local_threads_json: localThreads.length, @@ -326,6 +334,12 @@ async function flow() { })), }); + // Sub-agent observations: persisted with the session at close. + await step("absorb_observations", { + task_id: "VENUE-1", + observations: [{ source: "Sub-Agent: venue driver", text: "Driver observation for session persistence", severity: "info" }], + }); + 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); @@ -340,6 +354,16 @@ async function flow() { }); await waitQuiet(netlog, { minMs: 2000, quietMs: 3000 }); const health = await step("health", { failure_limit: 20 }); + + // Did the close land? Is relevance readable from the store (GIT-109)? + const closedRows = sessionId + ? await rest("GET", `gitmem_sessions?select=id,closing_reflection&id=eq.${sessionId}`, undefined, { Prefer: "" }).then((r) => r.json()) + : []; + const metricRows = sessionId + ? await rest("GET", `gitmem_query_metrics?select=tool_name,metadata&session_id=eq.${sessionId}`, undefined, { Prefer: "" }).then((r) => r.json()) + : []; + const relevanceRows = metricRows.filter((m) => m.metadata && (m.metadata.memory_relevance || m.metadata.memories_applied)); + const relevance = relevanceRows.map((m) => ({ tool: m.tool_name, memories_applied: m.metadata.memories_applied, memory_relevance: m.metadata.memory_relevance })); await waitQuiet(netlog, { minMs: 1000, quietMs: 2000 }); await srv.close(); ollama.close(); @@ -360,6 +384,9 @@ async function flow() { remote_threads_after: await count("gitmem_threads"), remote_sessions_after: await count("gitmem_sessions"), remote_scar_usage_after: await count("gitmem_scar_usage"), + session_close_persisted: closedRows.length === 1 && closedRows[0].closing_reflection != null, + relevance_readable: relevance.some((r) => scarIds.some((id) => (r.memories_applied || []).includes(id) && r.memory_relevance?.[id])), + relevance, 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}`), @@ -470,6 +497,12 @@ const failureCheck = checkFailures(allVenueRequests); const edgeCalls = allVenueRequests.filter((r) => r.path.startsWith("/functions/v1/")) .map((r) => `${r.method} ${r.path} -> ${r.status}`); if (edgeCalls.length) failureCheck.unexpected.push(...edgeCalls.map((c) => `edge function called (GIT-97): ${c}`)); +if (mode === "flow" && !result.session_close_persisted) { + failureCheck.unexpected.push(`session_close did not persist session ${result.session_id} (closing_reflection missing)`); +} +if (mode === "flow" && result.recall_scar_ids > 0 && !result.relevance_readable) { + failureCheck.unexpected.push("confirm_scars relevance is not readable from gitmem_query_metrics.metadata (GIT-109)"); +} if (mode === "flow" && !result.thread_counts_match) { failureCheck.unexpected.push( `thread panel mismatch (GIT-97): session_start ${result.session_start_thread_count} != list_threads ${result.list_threads_open_right_after_session_start}` From e52f6d53625b82b16e133d5ffa70576b69df96f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 21:48:17 -0700 Subject: [PATCH 09/13] fix: let session_close find recall's metrics row so relevance is actually recorded (GIT-109) recall wrote its metrics row with session_id NULL, so updateRelevanceData, which looked rows up by session_id, only found session_start and session_close. Neither surfaces memories, so nothing was ever patched, while relevance_update still reported success. recall now records the session in metadata.session_id. It doesn't use the session_id column, because that column's FK would race the session row's creation (the GIT-73 race). updateRelevanceData matches either place. Verified on a blank Supabase provisioned from the v1.8.0 setup.sql: after recall, confirm_scars (APPLYING, relevance high) and session_close, the recall row's metadata carries memories_applied and memory_relevance. Co-Authored-By: Claude Opus 5 --- src/services/metrics.ts | 13 +++++++------ src/tools/recall.ts | 4 ++++ tests/unit/services/relevance-metadata.test.ts | 14 +++++++------- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/services/metrics.ts b/src/services/metrics.ts index 1150542..0211b88 100644 --- a/src/services/metrics.ts +++ b/src/services/metrics.ts @@ -371,15 +371,16 @@ export async function updateRelevanceData( if (!hasSupabase()) return; const tracker = getEffectTracker(); await tracker.track("relevance_update", sessionId, async () => { - // Get all metrics for this session that surfaced memories - const metrics = await supabase.listRecords<{ + // Get all metrics for this session that surfaced memories. recall rows + // carry the session in metadata.session_id (no FK race); others in the column. + const metrics = await supabase.directQuery<{ id: string; memories_surfaced?: string[] | null; metadata?: Record | null; - }>({ - table: "gitmem_query_metrics", - columns: "id,memories_surfaced,metadata", - filters: { session_id: sessionId }, + }>("gitmem_query_metrics", { + select: "id,memories_surfaced,metadata", + filters: { or: `(session_id.eq.${sessionId},metadata->>session_id.eq.${sessionId})` }, + limit: 200, }); if (!metrics || !Array.isArray(metrics)) return; diff --git a/src/tools/recall.ts b/src/tools/recall.ts index 698df10..bde03b0 100644 --- a/src/tools/recall.ts +++ b/src/tools/recall.ts @@ -739,6 +739,10 @@ export async function recall(params: RecallParams): Promise { phase_tag: "recall", memories_surfaced: memoriesSurfaced, metadata: { + // GIT-109: lets session_close find this row to record which surfaced + // memories were applied. Kept in metadata rather than the session_id + // column, whose FK would race the session row's creation (cf. GIT-73). + ...(currentSession?.sessionId && { session_id: currentSession.sessionId }), project, match_count: matchCount, cache_hit, diff --git a/tests/unit/services/relevance-metadata.test.ts b/tests/unit/services/relevance-metadata.test.ts index a09e3c0..4f1f468 100644 --- a/tests/unit/services/relevance-metadata.test.ts +++ b/tests/unit/services/relevance-metadata.test.ts @@ -15,13 +15,13 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -const listRecords = vi.fn(); +const directQuery = vi.fn(); const directPatch = vi.fn(async () => []); const directUpsert = vi.fn(async () => ({})); vi.mock("../../../src/services/supabase-client.js", () => ({ isConfigured: () => true, - listRecords: (...a: unknown[]) => listRecords(...a), + directQuery: (...a: unknown[]) => directQuery(...a), directPatch: (...a: unknown[]) => directPatch(...a), directUpsert: (...a: unknown[]) => directUpsert(...a), })); @@ -41,7 +41,7 @@ const C = "cccccccc-0000-4000-8000-000000000003"; describe("updateRelevanceData (GIT-109)", () => { it("PATCHes metadata on the existing row and never writes a memories_applied column", async () => { - listRecords.mockResolvedValue([ + directQuery.mockResolvedValue([ { id: "m1", memories_surfaced: [A, B], metadata: { project: "p", match_count: 3 } }, { id: "m2", memories_surfaced: [C], metadata: {} }, { id: "m3", memories_surfaced: null, metadata: null }, @@ -49,10 +49,10 @@ describe("updateRelevanceData (GIT-109)", () => { await updateRelevanceData("session-1", [A], { A_unrelated: "high", [A]: "high", [B]: "noise" }); - expect(listRecords).toHaveBeenCalledWith(expect.objectContaining({ - table: "gitmem_query_metrics", - columns: "id,memories_surfaced,metadata", - filters: { session_id: "session-1" }, + // recall rows carry the session in metadata.session_id (no FK race), others in the column. + expect(directQuery).toHaveBeenCalledWith("gitmem_query_metrics", expect.objectContaining({ + select: "id,memories_surfaced,metadata", + filters: { or: "(session_id.eq.session-1,metadata->>session_id.eq.session-1)" }, })); expect(directUpsert).not.toHaveBeenCalled(); // Only m1 surfaced anything applied or rated; m2/m3 are left alone. From e803110e7057401f04121ffff2787681ab2f363c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 21:48:17 -0700 Subject: [PATCH 10/13] test: venue driver reports the store capability probe separately and reads relevance where recall stores it - Capability probes are reported, not failed: a read-only GET ?select=&limit=1 (or select=id on a transcript_chunks table) that 400s or 404s is how gitmem learns a column or table is absent. They're matched on exact shape only. EXPECTED_FAILURES, the defect list, is unchanged at GIT-73 and GIT-105. - The relevance check reads metrics by session_id or metadata->>session_id, matching where recall records it. Co-Authored-By: Claude Opus 5 --- tests/e2e/blank-supabase.mjs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/tests/e2e/blank-supabase.mjs b/tests/e2e/blank-supabase.mjs index 9bf7a74..84b343b 100644 --- a/tests/e2e/blank-supabase.mjs +++ b/tests/e2e/blank-supabase.mjs @@ -50,6 +50,27 @@ const DENY_REFS = new Set(["cjptxyezuxdiinufgrrm"]); // production GitMem — ne * 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). */ +/** + * Not defects: the store capability probe (src/services/store-columns.ts) is a + * read-only one-row SELECT that is SUPPOSED to 400/404 when a store lacks an + * optional column or table — that answer is how gitmem avoids writing it. + * Matched on exact shape only: GET ?select=&limit=1. + */ +const OPTIONAL_COLUMNS = new Set([ + // production-only session columns (session-columns.ts PRODUCTION_ONLY_SESSION_COLUMNS) + "blocked_by", "children", "claude_code_session_id", "compacted", "compacted_at", "compacted_summary", + "handover_linear_slug", "insights", "metrics", "pre_compaction_summary", "task_observations", + "archived_at", +]); +function isCapabilityProbe(r) { + if (r.method !== "GET" || !(r.status === 400 || r.status === 404)) return false; + const q = new URLSearchParams(r.query); + if ([...q.keys()].sort().join(",") !== "limit,select" || q.get("limit") !== "1") return false; + const cols = (q.get("select") || "").split(","); + if (cols.length === 1 && cols[0] === "id") return /transcript_chunks$/.test(r.path); // table probe + return cols.every((c) => OPTIONAL_COLUMNS.has(c)); +} + 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 }, @@ -223,7 +244,8 @@ 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 probes = reqs.filter(isCapabilityProbe).map((r) => `${r.method} ${r.path}${r.query} -> ${r.status}`); + const failures = reqs.filter((r) => r.status >= 400 && /^\/(rest|functions)\/v1\//.test(r.path) && !isCapabilityProbe(r)); const unexpected = failures.filter((r) => !expectedFor(r)) .map((r) => `${r.method} ${r.path}${r.query} -> ${r.status}`); const expectedSeen = {}; @@ -231,7 +253,7 @@ function checkFailures(reqs) { 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 }; + return { unexpected, expected_seen: expectedSeen, capability_probes: probes }; } // ---------------------------------------------------------------- FLOW (runs 1 & 2) @@ -360,7 +382,8 @@ async function flow() { ? await rest("GET", `gitmem_sessions?select=id,closing_reflection&id=eq.${sessionId}`, undefined, { Prefer: "" }).then((r) => r.json()) : []; const metricRows = sessionId - ? await rest("GET", `gitmem_query_metrics?select=tool_name,metadata&session_id=eq.${sessionId}`, undefined, { Prefer: "" }).then((r) => r.json()) + // recall rows carry the session in metadata.session_id (GIT-109), others in the column + ? await rest("GET", `gitmem_query_metrics?select=tool_name,metadata&or=(session_id.eq.${sessionId},metadata->>session_id.eq.${sessionId})`, undefined, { Prefer: "" }).then((r) => r.json()) : []; const relevanceRows = metricRows.filter((m) => m.metadata && (m.metadata.memory_relevance || m.metadata.memories_applied)); const relevance = relevanceRows.map((m) => ({ tool: m.tool_name, memories_applied: m.metadata.memories_applied, memory_relevance: m.metadata.memory_relevance })); From 1cca705c4100d7fa161672924145f1a7758dfa8e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 21:49:34 -0700 Subject: [PATCH 11/13] docs: draft CHANGELOG entry for Release B (unversioned) Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ffbf50..0d2a940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] + + +**Re-run `setup.sql` after upgrading. It's safe to run more than once.** If you don't, everything +still works. The one difference: the scar-scoring function keeps its old behaviour, rewriting every +scored scar on each `session_start`. That makes the next process start re-download your embedding +index instead of reading it from disk. Re-running `setup.sql` installs the version that only +rewrites scars whose score actually changed. + +**`health` will probably show more failures than before. That isn't a regression.** Those writes +were already failing; `health` used to count them as successes. The ones that remain are listed +under *Known issues*. + +This release is for Pro stores, meaning your own Supabase project. Before it, gitmem assumed columns +and tables that a project set up from `setup.sql` doesn't have. Anything written to them failed, and +in the worst case a whole session was lost. gitmem now writes only what your project has, whether or +not you've re-run `setup.sql`. + +### Fixed + +- **Session closes were being lost.** If a Claude Code CLI or desktop session found its transcript, + or recorded sub-agent observations, `session_close` sent columns your project doesn't have. The + database rejected the whole write, and the session was never saved (the close did report + `FAILED`). gitmem now checks once per process which optional columns your project has, using a + small read-only query, and writes only those. +- **Scar usage is recorded again.** Usage was written to a table name that doesn't exist on your + project, so every usage record was lost. That also meant the scar-scoring and blindspot features + had nothing to work from. (GIT-84) +- **`confirm_scars` relevance is now saved.** Which surfaced scars you applied, and how relevant you + rated each one, is now stored in the recall row of `gitmem_query_metrics`, inside its existing + `metadata` field (`memories_applied`, `memory_relevance`). Before, it went to a column that doesn't + exist and was matched against the wrong kind of value, so nothing was ever saved. (GIT-109) +- **`archive_learning` now archives.** It also wrote an `archived_at` column your project doesn't + have, so the update was rejected and the scar stayed active. The column is now written only where + it exists; the row's `updated_at` still records when it was archived. +- **Analytics, the `session_start` insights and the `session_close` blindspot check work again.** + They asked the usage table for title and severity columns it has never had. Those now come from + your learnings. (GIT-108) +- **`health` reports failed writes as failures.** It used to count as a success any write that + failed but didn't throw an error. That's why the problems above went unnoticed. (GIT-104) +- **`setup.sql` can be re-run.** A second run used to fail partway through. (GIT-84) + +### Changed + +- **A/B testing of scar enforcement variants is off outside dev tier.** It depends on tables + `setup.sql` doesn't create, so on your project every variant read and write failed. (GIT-106) +- **Transcripts are uploaded only where the project is set up for them.** `setup.sql` creates + neither the Storage bucket nor the table transcript upload needs. +- **`hook-scars.json` is now readable only by your user account** (mode `0600`), like the embedding + cache. It holds learning text. Existing files are tightened on the next start. (GIT-109) +- **The scar-scoring function only rewrites scars whose score changed.** A scored scar stays cached + on disk across sessions unless its score actually moves. This needs the re-run `setup.sql`. + (GIT-84) + +### Known issues + +- **One `metrics` failure at `session_start` can appear in `health`.** A metrics row sometimes + arrives before its session row exists. Nothing is lost except that one metrics row. (GIT-73) +- **A knowledge-graph link for new threads fails in `health`.** It writes a thread id where the + database expects a UUID. (GIT-105) + ## [1.9.0] - 2026-09-20 **No schema change. You do not need to re-run `setup.sql`.** From 31fff6fcdded22ee81c28156df22253e93ad25fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 22:05:09 -0700 Subject: [PATCH 12/13] chore: bump version to v1.10.0 Release B: - GIT-110: session closes no longer lost on customer stores - GIT-84: scar usage recorded; setup.sql re-runnable - GIT-104: health reports hidden failures - GIT-106: variants dev-only - GIT-108: analytics columns - GIT-109: relevance saved in metadata; hook cache 0600 Re-running setup.sql is recommended but not required. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 27 +++++++++++++++++---------- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d2a940..ec122f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - +## [1.10.0] - 2026-09-20 -**Re-run `setup.sql` after upgrading. It's safe to run more than once.** If you don't, everything -still works. The one difference: the scar-scoring function keeps its old behaviour, rewriting every -scored scar on each `session_start`. That makes the next process start re-download your embedding -index instead of reading it from disk. Re-running `setup.sql` installs the version that only -rewrites scars whose score actually changed. +**Sessions that weren't being saved now save.** On your own Supabase project, a `session_close` was +rejected by the database, and the session never saved, whenever it carried sub-agent observations, +child-agent records, or a Claude Code session id. gitmem attaches that id whenever it finds the +session's transcript, which is most Claude Code CLI and desktop sessions. The close reported +`FAILED`, but the session was gone. Those closes now save. (GIT-110) + +**Re-running `setup.sql` is recommended, not required. It's safe to run more than once.** If you +skip it, everything still works. The one difference: the scar-scoring function keeps its old +behaviour, rewriting every scored scar on each `session_start`. That makes the next process start +re-download your embedding index instead of reading it from disk. Re-running `setup.sql` installs +the version that only rewrites scars whose score actually changed. **`health` will probably show more failures than before. That isn't a regression.** Those writes were already failing; `health` used to count them as successes. The ones that remain are listed @@ -26,10 +32,11 @@ not you've re-run `setup.sql`. ### Fixed -- **Session closes were being lost.** If a Claude Code CLI or desktop session found its transcript, - or recorded sub-agent observations, `session_close` sent columns your project doesn't have. The - database rejected the whole write, and the session was never saved (the close did report - `FAILED`). gitmem now checks once per process which optional columns your project has, using a +- **Session closes were being lost (GIT-110).** If a Claude Code CLI or desktop session found its + transcript, or recorded sub-agent observations or child agents, `session_close` sent columns your + project doesn't have. The database rejected the whole write, and the session was never saved (the + close did report `FAILED`). gitmem now checks once per process which optional columns your + project has, using a small read-only query, and writes only those. - **Scar usage is recorded again.** Usage was written to a table name that doesn't exist on your project, so every usage record was lost. That also meant the scar-scoring and blindspot features diff --git a/package-lock.json b/package-lock.json index 9ce6c9b..9b91cac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitmem-mcp", - "version": "1.9.0", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitmem-mcp", - "version": "1.9.0", + "version": "1.10.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/package.json b/package.json index 05200c5..3e0955f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gitmem-mcp", - "version": "1.9.0", + "version": "1.10.0", "mcpName": "io.github.gitmem-dev/gitmem", "description": "Persistent learning memory for AI coding agents. Memory that compounds.", "type": "module", From 3c61aa0d27d7c322e865a41ab6dc4dcdd828c185 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 22:11:15 -0700 Subject: [PATCH 13/13] docs: date the 1.10.0 CHANGELOG entry 2026-09-21 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec122f5..71ee809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.10.0] - 2026-09-20 +## [1.10.0] - 2026-09-21 **Sessions that weren't being saved now save.** On your own Supabase project, a `session_close` was rejected by the database, and the session never saved, whenever it carried sub-agent observations,