diff --git a/docs/audit/2026-09-solidity-audit.md b/docs/audit/2026-09-solidity-audit.md new file mode 100644 index 0000000..168e4e6 --- /dev/null +++ b/docs/audit/2026-09-solidity-audit.md @@ -0,0 +1,1632 @@ +# gitmem solidity audit — September 2026 (GIT-113 Phase 0) + +**Report only.** No source was changed, nothing merged, production untouched. Audited `origin/main` at `f93c3a3` (v1.11.0). Branch `audit/GIT-113-phase-0`. + +**Method.** Seven pattern sweeps over all 114 non-test files in `src/` (33k lines), plus `bin/`, `hooks/` and `scripts/`: A silent failure, B false success, C schema assumptions, D environment assumptions, E concurrency, F tool/CLI contracts, G test blindness. Each sweep lists every instance with `file:line`, customer effect, reach and severity (sections 3–9). Community feedback was read from `community_feedback` via the service-role path and mapped to classes, tickets and regression tests (section 10). + +**Verification.** The findings that lead the ranking were re-checked by hand, and the store-facing ones were probed read-only against the disposable venue (`vujndhcumgroztfkkdus`, a setup.sql store). The log is in section 11. Sweep claims that failed verification are corrected there, not silently dropped. + +**Severity × reach.** Severity: *critical* = the user's memory is lost or a shipped feature is dead, with no signal; *high* = lost or wrong with a signal only on stderr, or wrong in a common setup; *medium* = degraded and visible, or wrong in a less common setup; *low* = cosmetic, cost or theoretical. Reach: how many customers hit it in normal use. + +--- + +## 1. Ranked summary + +| # | Finding | Class | Severity | Reach | Evidence | +|---|---|---|---|---|---| +| 1 | **Remote recall calls an RPC no customer store has.** When the local vector index isn't ready (the first recall after every server start until warm-up finishes, a new or empty store, a failed bulk load, `GITMEM_SEARCH_MODE=remote`), recall calls `match_gitmem_learnings_weighted`. `setup.sql` (both versions) defines only `gitmem_scar_search`, which takes `similarity_threshold`, not `match_threshold`. A unit test pins the wrong name with a fetch stub. | C, F, G | critical | every Pro customer | `supabase-client.ts:693`; `setup.sql:322`; venue probe: **404 PGRST202** | +| 2 | **`init` seeds the memory store in `/.gitmem`; the server and hooks read `~/.gitmem`.** A fresh install's starter scars, `config.json` (project), threads and payload template are never read, and the next `session_start` reports the new store as "found but NOT being read". | D, F | critical | every install via the wizard | `bin/init-wizard.js:25,169,397,440,498`; `gitmem-dir.ts:93-95` | +| 3 | **The auto-retrieve hook is dead on stock macOS.** It runs `timeout 2.5 node …`; macOS ships no `timeout`, the error is sent to `/dev/null \|\| true`, and the hook exits as "nothing relevant". CI's no-jq job runs on Linux, which has `timeout`. | D, G | critical | every Mac hook user without Homebrew coreutils | `hooks/scripts/auto-retrieve-hook.sh:174`; verified absent on the audit Mac | +| 4 | **`session_close`'s thread-sync gate is hollow.** `syncThreadsToSupabase` marks a thread synced after write helpers that swallow their own failures (`null` / `false` / `void`). So `all_synced` is true when every write failed: `threads.json` is pruned, session files and payload are deleted, and the display says COMPLETE. The GIT-70 test fails only the lookup, which is the one call that propagates. | A, B, G | high | Pro closes during any store trouble | `thread-supabase.ts:663-691` ← `:215-218, :267-270, :509-560`; `session-close.ts:1443-1450, 1689-1696` | +| 5 | **A failed refresh or flush empties recall.** `loadScarsFromSupabase` returns `[]` on any error; `flushCache` (run after every `create_learning`) re-initialises the index and `hook-scars.json` with `[]`, logs "N → 0", returns success, and `ensureInitialized` never retries. | A, E | high | Pro, any transient store error after a write | `startup.ts:225-227, 677-690, 80` | +| 6 | **One `currentSession` per process (GIT-103).** The desktop app serves every chat from one process, and MCP requests are not queued. Chat B's `session_start` takes over chat A's identity: A's scars, confirmations and threads land in B's session, and A's close clears B. | E, D | high | every desktop user with two chats | `session-state.ts:56`; `session-start.ts:1230`; `session-close.ts:581-608, 1677` | +| 7 | **`closing-payload.json` is one file per machine.** Two concurrent sessions read, overwrite or delete each other's closing reflection. | E | high | concurrent sessions (CLI + desktop, Cursor windows) | `session-close.ts:976-998, 1695`; `resolve-root.sh:44` | +| 8 | **`pruneStale` deletes live sessions:** any session over 24 h old regardless of its process, and, after one torn registry read, every other session's directory once 1 h has passed. The owner then no-ops every persist silently. | E | high | long-running sessions; multi-process | `active-sessions.ts:463-470, 107-111, 496-525`; `session-state.ts:407` | +| 9 | **`archive_learning` is broken on customer stores in two ways.** A short-prefix archive fails with `uuid ~~ unknown` (42883). A full-UUID archive of a row that doesn't exist reports `durable: true`, because all 8 `directPatch` callers ignore a 0-row result. | C, B, A | high | every Pro `archive_learning`; all PATCH paths | `archive-learning.ts:53-66, 133, 176`; venue probe: **42883** | +| 10 | **The free tier's only store can wipe itself.** Collections are rewritten whole and non-atomically, a corrupt file reads as `[]` ("starting fresh") so the next write replaces it, and read-modify-write is lock-free across windows. Same shape for `threads.json`. | A, E | high | free tier, crash mid-write or two windows | `local-file-storage.ts:37-66, 132-141`; `thread-manager.ts:249-272` | +| 11 | **Hooks track the wrong session, or none.** State is keyed on `$$` when `CLAUDE_SESSION_ID` is unset (it is), so the nag and the Stop-hook meaningful-session test never accumulate. `recall-check` gates on the newest registry session, not this window's. `GITMEM_DIR` set in the MCP `env` block is invisible to hooks. | D, E | high | every hook user | `session-start.sh:170` et al.; `recall-check.sh:43-44`; D 9.1 | +| 12 | **Hostname is identity.** macOS renames the host (`.local`/`.lan`, `-N` suffix; the audit Mac is `-4`), after which sessions can't be resumed and registry slots are orphaned. | D | high | Mac laptops | `active-sessions.ts:320, 137-139`; `session-close.ts:1369` | +| 13 | **`session_close` displays "N scars applied ✓" from its inputs** before the fire-and-forget usage batch runs, and the batch reports `success: true, durable: true` with zero rows when identifiers don't resolve. `health` counts that as success too. | B, A | medium-high | every Pro standard close | `session-close.ts:378-425, 1606-1611, 1727`; `record-scar-usage-batch.ts:198-205` | +| 14 | **`absorb_observations` never persists on a customer store** but says "Absorbed N" and its description says "persist". The column is probed as absent mid-session and dropped again at close. | A, B, F | medium | every Pro user of the tool | `absorb-observations.ts:71-80`; `session-columns.ts:107-121` | +| 15 | **Failures render as empty results.** `search` and `log` show "No results found." / "No entries found." on any query error; `session_start` shows "No threads or decisions." plus the first-session welcome when Supabase is unreachable. | B, F | medium | any outage | `search.ts:218-230, 350-364`; `log.ts:283-294`; `session-start.ts:280-283, 1031-1034` | +| 16 | **`install-hooks` / `uninstall-hooks` destroy the user's other Claude Code hooks.** Install replaces `settings.hooks` wholesale when no gitmem hooks are present; uninstall deletes the whole key. | F | medium | users with their own hooks | `bin/gitmem.js:650-659, 805-807` | +| 17 | **Async licence validation can flip the tier to free mid-session,** after tools, config, storage and the session were set up as Pro. The session is opened in Supabase and closed to local JSON. | E | medium | licence failures (expired, device limit) | `server.ts:499-508`; `config.ts:141`; `storage.ts:143` | +| 18 | **Late session create can undo a close.** `createSessionRecord` (fire-and-forget) and `session_close` are both full-row merge upserts with no ordering; a late create reverts a closed row. GIT-73's wait covers metrics only. | E | medium | Pro, slow first write | `session-start.ts:444-457, 1149`; `session-close.ts:1550` | +| 19 | **Type and FK assumptions:** `action_protocol` / `self_check_criteria` arrays sent to TEXT columns; `open_threads` objects stored as JSON strings in `TEXT[]`; unguarded FKs to `gitmem_sessions(id)` on decisions, scar usage and threads. | C | medium | Pro, specific inputs | `create-learning.ts:119-121`; `session-close.ts:587`; `setup.sql:78, 176-177` | +| 20 | **v1.8.0's `refresh_scar_behavioral_scores` bumps `updated_at` on unchanged rows** at every `session_start`. Since GIT-98's per-row delta this costs only the scored rows (≈42 KB in the venue run), not the whole index, but the fix lives in a `setup.sql` customers never re-run. | C | low-medium | every v1.8.0 store with active scars | `setup-v1.8.0.sql:383-417` | +| 21 | **Tests are blind where the risk is.** 22 src files (5,706 lines) have no unit test; Supabase is real only in the tag-time venue gate, never on PRs; the session-close suites hand-build about 20 mocks each and today *reach* an omitted export (`getSessionDir`) that throws and is swallowed; the CLI/wizard e2e tests don't run in CI. | G | root cause | all of the above | section 9 | + +**Counts across classes:** A 139 sites (1 critical, 6 high); B 7 false-success, 20 partial; C 17 findings beyond the 11 the GIT-109 analyzer already covers; D ~100 sites (4 critical); E 83 rows; F 30 tools and 17 CLI commands, 13 untrue description sentences; G 27 mock factories with reachable omitted exports. + +**What the feedback says** (section 10): 13 of 21 user bug reports are class B, 7 are A, 5 touch D, none C or E. Every 1.8.0-era report is fixed in 1.9.0–1.11.0. Two fixed defects have no regression test: Pro embedding generation and `migrate-local`. Schema and concurrency failures aren't being reported because they are silent, which is class A. + +--- + +## 2. Proposed order for Phases 1–6 + +Effort is engineer-days, including tests and the venue check, for one agent working alone. + +| Phase | Theme | Contents (summary rows) | Why this position | Effort | +|---|---|---|---|---| +| **1** | **Stop live loss and dead features** (patch release) | #1 recall RPC: call `gitmem_scar_search` with its parameter names, and replace the pinned-name test with one derived from `setup.sql`. #2 `init` writes to the resolved root. #3 hook `timeout` fallback (portable bash `timeout` or `node` with its own timeout). #4 thread sync inspects helper results; add a test that fails the *write*. #5 a failed load never replaces a non-empty index or hook cache. #9 prefix archive via a UUID range filter, and every `directPatch` caller checks row count. #16 hooks merge/filter like the wizard does. | Small, local changes that each fix active data loss or a dead feature for every customer. All verifiable on the venue today. | 4–5 | +| **2** | **Test realism** (before any refactor) | Run the venue `flow` on every PR, or a PostgREST + Postgres container in CI loaded with v1.8.0 `setup.sql`. Shared mock fixtures that spread `importOriginal`, plus a lint that fails on a factory omitting an export the subject imports. Run the CLI/wizard e2e in CI. A macOS runner for `hooks/tests`. Regression tests for Pro embedding generation and `migrate-local` (feedback 54cb480a, c6e4cdfc). | Phases 3–5 rewrite the session and store core; without a real store and honest mocks on PRs they would regress silently, as #4 and the `getSessionDir` swallow show today. | 5–7 | +| **3** | **Honest results everywhere** (class B/F) | `durable`/`stored_in` reach the display for every write tool (server contract). #13: close awaits or labels the usage batch, and a zero-row batch is not success. #14: `absorb_observations` says "session only" where the column is absent. #15: `search`, `log` and `session_start` distinguish an outage from empty. `cleanup_threads` / `archiveDormantThreads` errors. The 13 untrue description sentences. `session_close`'s advertised schema. | One contract, applied mechanically once tests can see the store. Directly addresses the dominant feedback class. | 4–6 | +| **4** | **Store integrity and concurrency** | #10: atomic temp+rename for every JSON store and `session.json`; a corrupt file is quarantined, not treated as empty; locked read-modify-write for `threads.json` and the free-tier collections. #8: `pruneStale` by liveness, not age or registry presence. `file-lock` TOCTOU and timeout behaviour. #17: licence flip applies only at startup. #18: close waits for the pending session create. | Local-file correctness is independent of the session model, and Phase 5 depends on it. | 4–5 | +| **5** | **Multi-session model (GIT-103)** | #6: session state keyed by session (per MCP request/connection), not a module global. #7: payload path per session (`sessions//closing-payload.json`, with the hook text and GIT-99 path message updated). #11: hooks keyed on a stable session id. #12: identity not tied to hostname. The registry slot model revisited. | The largest change, touching `session-state`, start, close, recall, confirm and the hooks. It needs Phases 2 and 4 underneath it. | 7–10 | +| **6** | **Schema contract hardening** | Extend the GIT-109 analyzer to RPC names and parameters, select strings, row properties and `setup.sql` function bodies. #19: array→TEXT coercion at the write, FK guards (the GIT-73 pattern) for decisions, usage and threads. #20: client-side mitigation for the v1.8.0 refresh function. Tier-gate transcript dispatch. Literal table names through `getTableName`. Remove dead code (`captureSessionTranscript`, `semanticSearch`, `cachedListWins`, `telemetry`). | Mostly lower severity once #1 is fixed; the analyzer extension keeps it fixed. | 3–4 | + +**Total ≈ 27–37 engineer-days.** If Chris wants the fastest reduction in customer harm: Phase 1 alone clears four of the five critical/high items that hit every customer. + +--- + +## 3. Class A — Silent Failure Audit (gitmem v1.11.0, f93c3a3) + + +Scope: every non-test file under `src/` (114 files, ~33k lines). Read-only. +Method: brace-matched extraction of all 236 `catch` blocks + grep for `.catch(`, `void `, `Promise.all(`, write calls (`directUpsert|directPatch|upsertRecord|.upsert(|writeFileSync|fetch POST/PATCH`), then each candidate read in context. + +Conventions used in the table: +- **Pattern**: 1 empty/comment-only catch; 2 catch-and-log-only; 3 `.catch(()=>{})`; 4 fire-and-forget / un-awaited promise; 5 `Promise.all` sibling-discard; 6 unchecked store write. +- **Tracked?**: `ET` = wrapped in `getEffectTracker().track(...)` so failure appears in `health`; `result` = failure reaches the tool's return value (flag/error/display); `stderr` = console only (invisible in MCP clients); `none` = nothing. +- **Reachable**: "yes" = ordinary Pro session; "only-if" = needs a specific store/network/fs failure. +- Severity per brief: critical = data the user thinks is saved is lost with no signal; high = lost, signal only on stderr; medium = degraded but visible; low = cosmetic/cache/telemetry. +- Free-tier-only or CLI-only (`src/commands/*`) sites are included for completeness but marked as such. + +Rows excluded on purpose (not silent-failure sites): catch blocks that (a) rethrow, (b) return an explicit error/`success:false`/`notStored()` result to the tool caller, or (c) guard a pure read of an optional file and return a documented default. They are counted in the appendix but not tabled. + +--- + +### Table + +| # | file:line | pattern | what it swallows / drops | effect on a Pro (setup.sql) store if it fires | reachable in normal use? | tracked / surfaced? | severity | +|---|---|---|---|---|---|---|---| +| **src/tools/create-learning.ts** | | | | | | | | +| 1 | create-learning.ts:209-212 | 2 | `embed()` exception; learning is written without `embedding` | Row saved, but rows without a parseable embedding are skipped when the local vector index is built (local-vector-search.ts:155-158) → the learning never surfaces in Pro `recall` until re-embedded | only-if (embedding provider down / rate-limited) | result flag `embedding_generated:false` (the display text does not mention it) | medium | +| 2 | create-learning.ts:243-255 | 4 | `writeTriplesForLearning` rejection | knowledge_triples rows missing; recall shows no related triples | only-if | ET `triple_write` | low | +| 3 | create-learning.ts:260-273 | 4 | `generateVariantsForScar` rejection | n/a on setup.sql (gated by `hasVariants()`, dev only) | no | ET `variant_generation` | low | +| 4 | create-learning.ts:277-279 | 3 | `flushCache()` rejection | Local vector index keeps the stale row set; the learning just created is absent from `recall` until the TTL refresh or an MCP restart | only-if (store/network hiccup right after a successful write) | stderr | medium | +| 5 | create-learning.ts:296-312 | 3 | `recordMetrics` rejection | gitmem_query_metrics row missing | only-if | ET `metrics` (inside recordMetrics) | low | +| **src/tools/create-decision.ts** | | | | | | | | +| 6 | create-decision.ts:80-82 | 2 | `embed()` exception | Decision row saved without embedding; no flag in the result | only-if | none (stderr) | low | +| 7 | create-decision.ts:96-109 | 4 | `writeTriplesForDecision` | triples missing | only-if | ET | low | +| 8 | create-decision.ts:127-142 | 3 | `recordMetrics` | metrics row | only-if | ET | low | +| **src/tools/record-scar-usage.ts** | | | | | | | | +| 9 | record-scar-usage.ts:88-102 | 3 | `recordMetrics` | metrics row | only-if | ET | low | +| **src/tools/record-scar-usage-batch.ts** | | | | | | | | +| 10 | record-scar-usage-batch.ts:128 | 5 | `Promise.all` over `resolveScarIdentifier` — cannot reject (each resolver catches at 86-89 and returns null) | none | n/a | result (`failed_identifiers`) | low | +| 11 | record-scar-usage-batch.ts:175 | 5 | `Promise.all` over N `directUpsert`s: the first rejection discards the ids of siblings that DID land | Tool returns `notStored()` / `usage_ids: []` although some scar_usage rows were inserted; a retry inserts duplicates (fresh uuids) | only-if (one of N inserts fails) | result (`success:false`) — but misreports partial success | medium | +| 12 | record-scar-usage-batch.ts:181-196 | 3 | `recordMetrics` | | only-if | ET | low | +| **src/tools/create-thread.ts** | | | | | | | | +| 13 | create-thread.ts:176-178 | 2 | `embed()` exception | Thread row saved with `embedding` null → later dedup is text-only; suggestion detection skips it | only-if | none (stderr; `embedding_generated` only in metrics metadata) | low | +| 14 | create-thread.ts:229-245 | 3 | `recordMetrics` (dedup-refused path) | | only-if | ET | low | +| 15 | create-thread.ts:326-342 | 3 | `recordMetrics` | | only-if | ET | low | +| 16 | create-thread.ts:345-355 | 4 | `writeTriplesForThreadCreation` | | only-if | ET | low | +| **src/tools/resolve-thread.ts** | | | | | | | | +| 17 | resolve-thread.ts:212-218 | 3 + 6 | Cascaded `resolveThreadInSupabase(...)`: the boolean return is ignored AND `.catch(() => {})` (the function never rejects anyway — thread-supabase.ts:267-270 returns false) | The "duplicate of t-XXXX" original is marked resolved in threads.json and listed under `also_resolved` in the result, but stays `open` in gitmem_threads; other sessions keep seeing it open | only-if (`resolution_note` matches /duplicate of t-…/ and the durable patch fails or the thread is not in Supabase) | none (stderr line from thread-supabase) | high | +| 18 | resolve-thread.ts:223-237 | 3 | `recordMetrics` | | only-if | ET | low | +| 19 | resolve-thread.ts:240-263 | 4 | triple writes (primary + cascade) | | only-if | ET | low | +| **src/tools/archive-learning.ts** | | | | | | | | +| 20 | archive-learning.ts:133-136 | 6 | `directPatch` returns the patched rows; the array is never inspected. A PATCH matching 0 rows returns 200 `[]`: full-UUID input is passed through by `resolveIdPrefix` (line 50) with no existence check, and an RLS-filtered row also yields `[]` | Tool answers `Archived learning ` with `success:true, durable:true` while `is_active` is unchanged | only-if (typo'd/foreign UUID, or RLS on learnings) | none | medium | +| 21 | archive-learning.ts:141-143 | 2 | `flushCache` exception (message dropped) | Archived learning keeps surfacing in recall until TTL refresh | only-if | result flag `cache_flushed:false` only | low | +| **src/tools/session-close.ts** | | | | | | | | +| 22 | session-close.ts:162-165 | 1 | readdir/stat errors while scanning `~/.claude/projects` for the transcript | Transcript silently not found → no upload; `transcriptStatus` stays undefined so the close display prints nothing about it | only-if (fs perms) | none | low | +| 23 | session-close.ts:309, 1681 → agent-briefing.ts:85-88 | 2 | `agent-briefing.md` write failure | cosmetic | only-if | stderr | low | +| 24 | session-close.ts:497-499 | 2 | `unregisterSession` exception | Registry keeps the closed session; the next session_start on the same PID may adopt/resume a closed session via `findSessionByHostPid` | only-if (lock/fs error) | stderr | medium | +| 25 | session-close.ts:508-510 | 2 | `rmSync` of the per-session dir | Closed session's session.json survives; `findResumableSessionOnDisk` (session-start.ts:754) can resume it | only-if | stderr | medium | +| 26 | session-close.ts:714-716, 726-732 | 3 / 2 | `captureSessionTranscript` (645-735) is defined but never called — dead code; its `.catch(err => console.error)` on `processTranscript` is unreachable | none | no (dead code) | n/a | low | +| 27 | session-close.ts:776 | 1 | per-session session.json read/parse failure while loading surfaced scars for auto-bridge | With in-memory scars empty (after an MCP restart) auto-bridge returns `[]` → no scar_usage rows for the session; close still reports `scars_applied` | only-if (restart + unreadable file) | stderr ("No surfaced scars available for auto-bridge") | medium | +| 28 | session-close.ts:888-891 | 2 | any exception in `bridgeScarsToUsageRecords` → `[]` | Same as #27: scar_usage not recorded, close green | only-if | stderr | medium | +| 29 | session-close.ts:1339-1341 | 1 | `supabase.getRecord` exception (network/auth/RLS) is indistinguishable from "not found" | Close proceeds from the LOCAL session.json copy; the upsert then rewrites `session_title/session_date/agent/project` from the local file rather than the store; stderr says "Session found in local file (not in Supabase)", misdescribing a transient read failure | only-if (transient store error at close) | none | low | +| 30 | session-close.ts:1358-1360, 1378-1380 | 1 | local file / registry read exceptions | Fall through to the visible "Session … not found" error | only-if | result (indirect) | low | +| 31 | session-close.ts:1453-1455 | 2 | `saveThreadsFile` exception during prune | threads.json keeps resolved threads (bloat, no loss) | only-if | stderr | low | +| 32 | session-close.ts:1478-1481 | 6 / 2 | `storeHasTable(transcript_chunks)` false → transcript upload skipped; `transcriptStatus` stays undefined, and `formatCloseDisplay` only prints a transcript line on `saved:false` | On EVERY setup.sql store (which, per the code comment, has neither the bucket nor `transcript_chunks`) the transcript is never uploaded and the close never says so. Also fires on a transient probe error (store-columns "unknown" → absent) | yes (all Pro/setup.sql customers) | stderr only | medium | +| 33 | session-close.ts:1482-1501 | 4 | `saveTranscript` rejection / `{success:false}` | Transcript missing from the bucket | only-if | ET `transcript` — but the close display is rendered before this async completes, so the failure shows only in a later `health` call or in the NEXT close's warnings | medium | +| 34 | session-close.ts:1490-1498 | 4 | `processTranscript(...)` is chained inside the tracked fn but not awaited; a resolved `{success:false}` is not logged at all (the `.then` only logs success) and only a rejection reaches `.catch(console.error)`. `saveResult.patch_warning` (supabase-client.ts:895-900: session row not updated with `transcript_path`) is dropped here too | `transcript_chunks` never written → `search_transcripts` finds nothing for the session; a transcript may sit in the bucket with no session row pointing at it (`get_transcript` cannot find it) | only-if | none for the `{success:false}` path; stderr for rejections; `patch_warning` unread | medium | +| 35 | session-close.ts:1528-1530 | 2 | blindspot fetch | cosmetic section missing | only-if | stderr | low | +| 36 | session-close.ts:1549-1552 | 5 | `Promise.all([directUpsert, blindspotPromise])` — `blindspotPromise` cannot reject (self-caught), so no sibling loss | none | n/a | n/a | low | +| 37 | session-close.ts:1557-1600 | 4 + 6 | embedding + `directPatch(embedding)` + suggestion detection; the `directPatch` row count at 1570 is not checked (a 0-row patch counts as success) | Session row without embedding → excluded from `loadRecentSessionEmbeddings` → thread suggestions degrade | only-if | ET `embedding` | low | +| 38 | session-close.ts:1606-1611 | 4 | `recordScarUsageBatch` resolving `{success:false}` (store rejects scar_usage inserts: schema drift, RLS, FK) | ALL scar_usage rows for the session (auto-bridged Q6 answers + "ignored" scars) are lost; the close has already returned success with `scars_applied: N`; analytics / blindspots / behavioral decay never see them. On Pro this is the ONLY write path for per-session scar usage | only-if (store rejects the insert) | ET `scar_usage` — visible in `health` and in the NEXT close's WARN lines, not in this one | high | +| 39 | session-close.ts:1622 | 3 | `updateRelevanceData` rejection | relevance feedback missing | only-if | ET `relevance_update` + stderr | low | +| 40 | session-close.ts:1652-1674 | 3 | `recordMetrics` | | only-if | ET | low | +| 41 | session-close.ts:1695 | 1 | `unlinkSync(closing-payload.json)` after a successful close | Stale payload is merged into the NEXT session_close (file provides defaults, inline params override — file-only fields such as `learnings_created`, `decisions`, `open_threads`, `closing_reflection` leak into another session) | only-if (fs) | none | medium | +| **src/tools/session-start.ts** | | | | | | | | +| 42 | session-start.ts:228 (+ thread-supabase.ts:767-770) | 3 + 2 | `archiveDormantThreads` rejection / swallowed exception | Housekeeping skipped | only-if | stderr | low | +| 43 | session-start.ts:280-283 | 2 | any exception in `loadLastSession` after the allSettled block (aggregateThreads, formatting) → `session:null`, no threads | session_start renders the FIRST-SESSION banner (`isFirstSession = !isResuming && !slimLastSession`, line 1299) and zero open threads for a customer with history | only-if | stderr | medium | +| 44 | session-start.ts:318-321 | 2 | rapport summaries (feature disabled — loader unused) | none | no | stderr | low | +| 45 | session-start.ts:377-385 | 2 | `loadRecentDecisions` exception | "recent decisions" silently empty | only-if | stderr | low | +| 46 | session-start.ts:412-415 | 2 | lightweight analytics | insights section omitted | only-if | stderr | low | +| 47 | session-start.ts:1148-1152 (+ 444-473) | 4 | `createSessionRecord` fire-and-forget: the `directUpsert` of the gitmem_sessions row; the catch at 464-473 returns `stored:false`, which only feeds `registerPendingSessionRow` (metrics FK wait). session_start's result never says the row failed | No session row until close; close then rebuilds it from local session.json. If the store stays broken the close fails visibly; meanwhile absorb_observations' `directUpsert({id, task_observations})` would INSERT a stub row | only-if (store rejects the insert: schema drift, RLS, enum/NOT NULL) | stderr only; metrics rows carry the id in `metadata.session_id` instead | medium | +| 48 | session-start.ts:493-506, 854, 1157 | 6 + 2 + 3 | `markSessionSuperseded`: `directPatch` row count unchecked, catch log-only, callers `.catch(() => {})` | Displaced session stays "unclosed" in gitmem_sessions forever (skews analytics / unclosed-session search) | only-if | stderr | low | +| 49 | session-start.ts:550-552, 575-577 (free tier) | 2 | local reads | Free-tier session_start shows the first-session banner / no decisions | only-if | stderr | low | +| 50 | session-start.ts:596-598 (free tier) | 2 | `storage.upsert("sessions", …)` failure — the free-tier SOT write | No session row in sessions.json; the free session_close (line 246) finds no existing session and builds one from params only (title/linear_issue lost) | only-if (fs) | stderr | medium (free only) | +| 51 | session-start.ts:621-623, 1221-1223, 1446-1448 (+ 836-838 inside `writeSessionFiles`) | 2 | session.json / registry / threads.json not written | Session identity is not on disk → after an MCP restart session_close cannot resolve the id, confirm_scars/reflect_scars persistence has no file, `recall_called` is not persisted (enforcement warns) | only-if (fs/lock) | stderr; session_start still returns a session_id | medium | +| 52 | session-start.ts:668-670, 1308, 1501 | 2 / 1 | display cache write into session.json | cosmetic | only-if | stderr / none | low | +| 53 | session-start.ts:684 | 1 | `readSessionFile` parse error → null | Registry says a session exists but the file is unreadable → a new session is created alongside (stderr warn at 763) | only-if | stderr | low | +| 54 | session-start.ts:768-770 | 2 | `checkExistingSession` exception → null | A resumable session is ignored and a second session is started; the first stays unclosed | only-if | stderr | medium | +| 55 | session-start.ts:813 | 1 | existing session.json unreadable on resume/refresh | `started_at` reset (duration wrong), `recall_called` reset (enforcement false-warn) | only-if | none | low | +| 56 | session-start.ts:1127-1131, 1381-1384 | 5 | `Promise.all` — every loader self-catches, so it cannot reject; benign | none | n/a | n/a | low | +| 57 | session-start.ts:1163-1165 | 3 | `ensureInitialized` (cache warm-up) | recall falls back to remote search (slower, still correct) | only-if | stderr | low | +| 58 | session-start.ts:1169-1173 | 3 | dynamic import + `refreshBehavioralScores` (never rejects: behavioral-decay.ts:60-63 / 76-79 return null) | `decay_multiplier` never refreshed; on a setup.sql store without the `refresh_scar_behavioral_scores` RPC this 404s on every session_start | yes if the RPC is absent | stderr | low | +| 59 | session-start.ts:1272-1292, 1470-1486 | 3 | `recordMetrics` | | only-if | ET | low | +| **src/tools/recall.ts** | | | | | | | | +| 60 | recall.ts:595 | 5 | `Promise.all` over `getOrAssignVariant` (self-catching; dev-only) | none | no | n/a | low | +| 61 | recall.ts:607-623 | 2 + 3 | `variant_performance_metrics` upserts (dev-only) | none on setup.sql | no | stderr | low | +| 62 | recall.ts:643-645 | 2 | `fetchDismissalCounts` | archival hints omitted | only-if | stderr | low | +| 63 | recall.ts:727-755 | 3 | `recordMetrics` | | only-if | ET | low | +| **src/tools/absorb-observations.ts** | | | | | | | | +| 64 | absorb-observations.ts:71-80 | 4 + 6 | `supportedColumns(...).then(present ? directUpsert : undefined).catch(console.error)`. When the store lacks `task_observations` (every setup.sql store, per the code comment) or the probe returns "unknown", the chain resolves `undefined` with no log at all | Tool returns "Absorbed N observations" but nothing is persisted; session_close later filters the same column out (session-columns.ts:104-119) → observations never reach any durable store on a customer Pro install. Free tier keeps them in memory only | yes (all setup.sql customers) | none | critical | +| 65 | absorb-observations.ts:86-99 | 3 | `recordMetrics` | | only-if | ET | low | +| **src/tools/analyze.ts** | | | | | | | | +| 66 | analyze.ts:136-139 | 5 | `Promise.all` of two reads: the first rejection discards the other; the outer catch (208-218) returns `success:false` | none silent | only-if | result | low | +| 67 | analyze.ts:171-179 | 3 | `recordMetrics` | | only-if | ET | low | +| **src/tools/cleanup-threads.ts** | | | | | | | | +| 68 | cleanup-threads.ts:201 (→ thread-supabase.ts:767-770) | 2 | `archiveDormantThreads` swallows and returns `{archived_count:0}`; a mid-loop failure also discards the ids already archived | Tool reports 0 archived (or fewer than actually patched) with success | only-if | stderr | medium | +| 69 | cleanup-threads.ts:270-286 | 3 | `recordMetrics` | | only-if | ET | low | +| **src/tools/list-threads.ts** | | | | | | | | +| 70 | list-threads.ts:133-135 | 2 | session-aggregation fallback failure | Falls to threads.json silently (stale/partial list); `source` only in metrics metadata | only-if (Supabase read already failed) | stderr | low | +| 71 | list-threads.ts:169-182 | 3 | `recordMetrics` | | only-if | ET | low | +| **src/tools/search.ts, log.ts, prepare-context.ts** | | | | | | | | +| 72 | search.ts:193, log.ts:158 | 1 | free-tier decisions.json read/parse | Decisions absent from search/log | only-if | none | low | +| 73 | search.ts:326-337, log.ts:258-266, prepare-context.ts:236-254 | 3 | `recordMetrics` | | only-if | ET | low | +| **src/tools/confirm-scars.ts, reflect-scars.ts** | | | | | | | | +| 74 | confirm-scars.ts:156-158 | 2 | session.json write of confirmations (+ surfaced_scars) | After an MCP restart the confirmations are gone → close auto-bridge records every scar as "none / not addressed" and relevance feedback is empty; the tool returned `valid:true` | only-if (fs) | stderr | medium | +| 75 | reflect-scars.ts:134-136 | 2 | session.json write of reflections | Reflections lost on restart; tool returns success | only-if | stderr | medium | +| **src/tools/contribute-feedback.ts** | | | | | | | | +| 76 | contribute-feedback.ts:118-135 | 4 | remote feedback POST | Local file written; remote copy missing | only-if | ET `feedback` | low | +| **src/tools/promote-suggestion.ts, dismiss-suggestion.ts** | | | | | | | | +| 77 | promote-suggestion.ts:97, dismiss-suggestion.ts:66 (→ thread-suggestions.ts:214-219) | 6 + 2 | `saveSuggestions` swallows the write error; callers do not check | Tool reports promoted/dismissed; suggested-threads.json unchanged → the suggestion reappears at the next close | only-if (fs) | stderr | medium | +| **src/services/thread-supabase.ts** | | | | | | | | +| 78 | thread-supabase.ts:215-218 (caller: thread-supabase.ts:676 in `syncThreadsToSupabase`) | 2 + 6 | `createThreadInSupabase` returns `null` on failure; the sync loop ignores the return and pushes the id to `synced` | A thread created mid-session whose durable INSERT fails at close is reported `all_synced:true`; the close succeeds; the thread exists only in threads.json, and the next session_start with Supabase authoritative drops it as "stale" once it is >24h old (session-start.ts:873-880) → thread lost. (create_thread itself DOES check this return — the gap is only the close-time sync) | only-if (INSERT rejected at close: schema drift / RLS / network) | stderr only | high | +| 79 | thread-supabase.ts:267-270 (caller: thread-supabase.ts:684) | 2 + 6 | `resolveThreadInSupabase` returns `false` on failure or when `thread_id` is not found; the sync loop ignores it | A thread resolved during the session stays `open` in gitmem_threads; threads.json is then pruned (the resolved entry removed) so the local record of the resolution is gone too; close reports `all_synced:true` | only-if | stderr | high | +| 80 | thread-supabase.ts:264 | 6 | `directPatch` row count unchecked | A 0-row PATCH (RLS) is reported as resolved (`return true`) | only-if | none | low | +| 81 | thread-supabase.ts:320-326, 344-347, 403-406, 499-502, 838-841 | 2 | read failures → `null` | Callers fall back to local/aggregated data (create-thread dedup text-only; list_threads aggregation; session_start aggregation; resolve-thread hydration miss) | only-if | stderr | low | +| 82 | thread-supabase.ts:554-564 (callers 680, 690) | 2 + 6 | per-thread touch failures swallowed; row count unchecked | touch_count/vitality not updated; the sync still counts the thread as synced | only-if | stderr | low | +| 83 | thread-supabase.ts:755 | 6 | `directPatch` row count unchecked in `archiveDormantThreads` | id reported archived when 0 rows changed | only-if | none | low | +| 84 | thread-supabase.ts:786-788 | 1 | metadata JSON parse | benign | only-if | none | low | +| **src/services/thread-manager.ts** | | | | | | | | +| 85 | thread-manager.ts:257-259 | 2 | `loadThreadsFile` → `[]` on a corrupt/unparseable threads.json; the next `saveThreadsFile` (create-thread.ts:321, resolve-thread.ts:196, session-start.ts:894) overwrites the file | Free tier (threads.json IS the SOT): every previously stored thread is discarded on the next write. Pro: local cache only (Supabase is SOT) | only-if (corrupt file, e.g. a crash mid-`writeFileSync`, which is not atomic) | none | high (free) / low (pro) | +| 86 | thread-manager.ts:78-80, 110-112 | 1 | JSON parse of legacy string threads | benign | only-if | none | low | +| **src/services/triple-writer.ts** | | | | | | | | +| 87 | triple-writer.ts:328-335 | 2 | per-triple insert failures collected, then thrown at 342-344 | Tracked by the callers' ET | only-if | ET | low | +| 88 | triple-writer.ts:486-489 | 2 | thread row-id lookup | Triple written with `source_id` null | only-if | stderr | low | +| **src/services/variant-*.ts (dev tier only)** | | | | | | | | +| 89 | variant-assignment.ts:81-84, 114-117, 151-162 | 2 | dev-only variant tables | none on setup.sql | no | stderr | low | +| 90 | variant-generation.ts:197-200 | 2 | LLM call (dev-only) | none | no | stderr | low | +| **src/services/session-state.ts** | | | | | | | | +| 91 | session-state.ts:281-284 | 2 | `recall_called` not persisted | After a restart, enforcement warns "recall never called" | only-if | stderr | low | +| 92 | session-state.ts:308-311 | 2 | `recall_failure` marker not persisted | After a restart, confirm_scars treats a failed recall as "no scars → proceed" (the GIT-93 defect returns) | only-if | stderr | medium | +| 93 | session-state.ts:335-337 | 2 | `recall_failure` not cleared on disk | Stale failure marker after a restart (false alarm) | only-if | stderr | low | +| 94 | session-state.ts:411-414 | 2 | `surfaced_scars` not persisted | After a restart: confirm_scars/reflect_scars see nothing, close auto-bridge records nothing → scar_usage lost | only-if | stderr | medium | +| 95 | session-state.ts:192-195, 448-450 | 2 | reads | fallbacks | only-if | stderr | low | +| **src/services/active-sessions.ts** | | | | | | | | +| 96 | active-sessions.ts:109-112 | 2 | registry read/parse failure → `{sessions:[]}` | The next `writeRegistry` (registerSession / reconcile) rewrites the registry with only the current entry → other live sessions' registry entries dropped (their session dirs survive; the GIT-89 disk scan recovers them) | only-if (corrupt/locked file) | stderr | low | +| 97 | active-sessions.ts:387-390, 417-420 | 2 | pid persist / registry reconcile | Next scan re-adopts | only-if | stderr | low | +| 98 | active-sessions.ts:42-44, 78-80, 296-299, 310-312, 518-524, 538-540, 610-613 | 2 / 1 | scan / prune / cleanup / legacy migration | housekeeping | only-if | stderr / none | low | +| **src/services/startup.ts** | | | | | | | | +| 99 | startup.ts:83-86 | 2 | `hook-scars.json` write | quick-retrieve hook returns nothing | only-if | stderr | low | +| 100 | startup.ts:225-228 | 2 | `loadScarsFromSupabase` → `{scars: []}` | Index not "ready" (`scars.length > 0` required, local-vector-search.ts:204) so recall falls back to remote per call; `initializeGitMem` still reports `success:true, scar_count:0` | only-if | stderr | low | +| 101 | startup.ts:133-136, 265-268, 616-621, 771-780, 811-813 | 2 / 3 | delta sync / remote stats / thread health / background init / periodic refresh | cache-only degradation | only-if | stderr | low | +| 102 | startup.ts:221 (→ vector-disk-cache.ts:245-249) | 6 | `writeVectorCache` returns `false`, ignored | Disk cache not written; next start re-downloads | only-if | stderr | low | +| **src/services/supabase-client.ts** | | | | | | | | +| 103 | supabase-client.ts:405-407 | 2 | `fetchRelatedTriples` | recall shows no triples | only-if | stderr | low | +| 104 | supabase-client.ts:645-648 | 2 | fingerprint request | cache treated as a miss | only-if | stderr | low | +| 105 | supabase-client.ts:891-900 | 6 + 2 | `directPatch(transcript_path)`: row count unchecked (0 rows when the session row does not exist yet, cf. #47) and exception → `patch_warning`, which the only live caller (session-close.ts:1489) drops | Transcript is in the bucket but no session row points at it; `get_transcript` cannot find it | only-if | stderr; `patch_warning` unread | medium | +| **src/services/storage.ts, store-columns.ts, write-health.ts** | | | | | | | | +| 106 | storage.ts:92-95 | 2 | `scarSearch` fallback | `[]` | only-if | stderr | low | +| 107 | store-columns.ts:36-38 (consumers: session-columns.ts:112-116, session-close.ts:1478, absorb-observations.ts:73, archive-learning.ts:132) | 2 | any non-schema probe error → "unknown" → treated as absent (uncached) | On a transient error at close, `task_observations` / `children` / `claude_code_session_id` are dropped from the session row of a store that HAS them (stderr: "Store has no … column"); the transcript upload is skipped (#32); `archived_at` omitted | only-if (network blip during the probe) | stderr | medium | +| 108 | write-health.ts:217-224 | 2 | unexpected exception in `checkWritePath` → `{ok:true, mode:"skipped"}` | `health.write_path.ok` reads true when the probe crashed | only-if | stderr; `health` shows mode "skipped" | low | +| **src/services/license.ts** | | | | | | | | +| 109 | license.ts:133-135, 147-149, 189 | 2 / 1 / 3 | license cache write/clear; error-body read `.catch(() => "")` | Offline fallback unavailable at the next start | only-if | stderr / none | low | +| **src/services/local-file-storage.ts (free tier SOT)** | | | | | | | | +| 110 | local-file-storage.ts:43-46 | 2 | `readCollection`: corrupt/unparseable `.json` → `[]` "starting fresh"; the next `upsert` (line 141) rewrites the file with the single new record | FREE TIER (the local store IS the truth): every learning / decision / session / scar_usage in that collection is lost on the next write; the write tool reports success | only-if (corrupt file — `writeCollection` is a plain non-atomic `writeFileSync`, so a crash mid-write produces one) | stderr | high (free only) | +| 111 | local-file-storage.ts:235-238 | 2 | starter scars load | none | only-if | stderr | low | +| **src/services/local-vector-search.ts, metrics.ts, cache.ts, vector-disk-cache.ts** | | | | | | | | +| 112 | local-vector-search.ts:155-158 | 2 | unparseable embedding → row skipped | That learning never surfaces in local recall | only-if | stderr | low | +| 113 | metrics.ts:157 | 3 | `created.catch(() => false)` — by design (feeds the FK wait) | none | n/a | n/a | low | +| 114 | metrics.ts:397 | 3 | `recordMetrics` in `trackQuery` | | only-if | ET | low | +| 115 | metrics.ts:446 | 6 | `directPatch` row count unchecked (inside ET) | relevance not written if 0 rows match | only-if | ET only if it throws | low | +| 116 | metrics.ts:483-485 | 2 | `detectRequery` | returns false | only-if | none | low | +| 117 | cache.ts:95-98, 160-162, 185-188, 376-384, 390-392, 406-413, 446-448, 460-462 | 2 / 1 | result-cache fs errors | cache miss | only-if | stderr; the `writeResult` path used by the tracked setters (234-347) is ET `cache_set` | low | +| 118 | vector-disk-cache.ts:272-292 | 1 | lock acquire / stat errors → behave as a lone downloader | duplicate downloads | only-if | none | low | +| **src/services/doc-*.ts, transcript-chunker.ts, thread-suggestions.ts, timezone.ts, gitmem-dir.ts, misc** | | | | | | | | +| 119 | doc-index.ts:78-81 | 2 | docs-index.json unreadable → `[]` | index_docs rebuilds from scratch (re-embeds everything) | only-if | stderr | low | +| 120 | doc-chunker.ts:202-204, 223-225 | 1 | unreadable dirs/files skipped | index_docs under-reports without an error count | only-if | none | low | +| 121 | transcript-chunker.ts:262-266 | 2 | chunk embedding failure → chunk skipped, `success:true` with fewer chunks | gaps in transcript_chunks | only-if | stderr | low | +| 122 | transcript-chunker.ts:208-211, 235-237 | 2 / 1 | line / wrapper parse | benign | only-if | stderr / none | low | +| 123 | thread-suggestions.ts:198-200, 235-237, 291-297 | 1 / 2 | reads | fallbacks | only-if | none / stderr | low | +| 124 | timezone.ts:39-41, 44-46, 57-59, 100-102, 128-130 | 1 / 2 | tz config parse / format | fall back to UTC / raw string | only-if | stderr / none | low | +| 125 | gitmem-dir.ts:145-147, 171-173, 231-233, 260-262, 294-297, 308-310, 313-317, 364-366, 380, 394 | 1 | read-only root-resolution / config guards | none | only-if | none | low | +| 126 | agent-detection.ts:27-29, 39-41; file-lock.ts:45-48, 92-94, 102-104, 113-115, 133-138 | 1 / 2 | detection / lock housekeeping (lock-acquire failures rethrow) | none | only-if | none / stderr | low | +| 127 | behavioral-decay.ts:60-63, 76-79, 139-141 | 2 | RPC failure → `null` | see #58 | yes if RPC absent | stderr | low | +| 128 | variant-generation.ts:320-346 | 5 | `Promise.allSettled`, then throws on any rejection — correct pattern | none | no (dev) | ET | low | +| **src/lib/telemetry.ts, src/hooks/quick-retrieve.ts** | | | | | | | | +| 129 | lib/telemetry.ts:185-190, 201-203, 221-226, 255-257, 281-283, 307-309 | 2 / 1 / 3 | telemetry send / local log — silent by design | none | yes | none | low | +| 130 | hooks/quick-retrieve.ts:76-79, 132-134, 171-174 | 1 / 3 | hook fail-open by design | hook injects nothing | only-if | none | low | +| **src/server.ts, src/index.ts** | | | | | | | | +| 131 | server.ts:499-511 | 3 | `validateLicense` rejection (an exception, not an invalid result) → tier left as detected AND `checkWritePathWithTimeout` never runs (it is only called inside `.then`) | A Pro key whose validation throws keeps pro tier with no write-path verdict; `health` shows none | only-if | stderr | low | +| 132 | server.ts:508, 515 | 4 | `void checkWritePathWithTimeout()` | none — the verdict is stored (`lastVerdict`) and reported by `health`; the timeout path warns on stderr | yes | `health` | low | +| 133 | index.ts:16-19 | 3 | fatal → `exit(1)` | visible | n/a | stderr + exit | low | +| **src/commands/*.ts (CLI, not MCP)** | | | | | | | | +| 134 | activate.ts:145-147, 179-181, 261-263 | 1 | stored token / config.json parse → "start fresh"; the later `writeFileSync` at 534 overwrites config.json | A corrupt config silently loses `install_id` / `feedback_enabled` / telemetry consent | only-if | none | low | +| 135 | activate.ts:554-557 | 2 | .gitignore append | printed to stdout | only-if | stdout | low | +| 136 | check.ts:331-333, 374-380, 404-406, 419-421, 437-439, 462-464 | 1 / 2 | diagnostics / benchmarks | health-report gaps | only-if | stdout | low | +| 137 | migrate-local.ts:89-91 | 1 | corrupt collection file skipped | Records in that file are not migrated and not counted in `errors` / `skipped` — the migration summary says complete | only-if | none | medium (CLI) | +| 138 | migrate-local.ts:268-270, 288-290, 330-332 | 1 | log file / backup / cleanup | none | only-if | none | low | +| 139 | deactivate.ts:71-74; commands/telemetry.ts:21-23; migrate-root.ts:221-223 | 1 | reads | exit / defaults | only-if | stdout / none | low | + +--- + +### (a) Counts + +#### Raw site counts (grep / brace-matched, whole `src/` minus tests) + +| Class | Count | Note | +|---|---|---| +| `catch` blocks total | 236 | 114 files | +| — empty or comment-only body (pattern 1) | 50 | listed individually in the table (#22, 27, 29, 30, 41, 53, 55, 72, 84, 86, 98, 109, 118, 120, 122-126, 134, 136-139 …) | +| — body is `console.*` only, no return/flag (pattern 2, strict) | 52 | | +| — `console.*` + `return null/[]/false/default` (pattern 2, "log-and-degrade"; caller cannot distinguish failure from empty) | ~36 | e.g. thread-supabase.ts:215, 267, 320, 344, 403, 499, 767, 838; session-start.ts:280, 377, 412, 464; startup.ts:133, 225, 265; local-file-storage.ts:43; storage.ts:92; active-sessions.ts:109; session-close.ts:888 | +| — remaining (rethrow / return explicit error / handled) | ~98 | excluded from the table | +| `.catch(` on promises (pattern 3) | 39 | 23 are literally `.catch(() => {})`; 20 of those are `recordMetrics(...).catch(() => {})` | +| `void ` (pattern 4) | 2 | server.ts:508, 515 | +| `getEffectTracker().track(` fire-and-forget (pattern 4, tracked) | 14 call sites + 2 internal (metrics.ts:218, 423) | paths: triple_write ×5, embedding, scar_usage, transcript, variant_generation, feedback, cache_set ×5 | +| other un-awaited promise chains (pattern 4, untracked) | 6 | session-start.ts:1151 (createSessionRecord), 1169 (behavioral decay), absorb-observations.ts:73, session-close.ts:1490 (processTranscript), startup.ts:771, server.ts:499 | +| `Promise.all(` (pattern 5) | 9 | 2 can actually lose sibling results: record-scar-usage-batch.ts:175, analyze.ts:136 (visible). `Promise.allSettled` used correctly at session-start.ts:190 and variant-generation.ts:320 | +| `directPatch(` callers with the returned row array unchecked (pattern 6) | 8 of 8 | archive-learning.ts:133, thread-supabase.ts:264, 554, 755, session-close.ts:1570, session-start.ts:493, supabase-client.ts:891, metrics.ts:446 — `directPatch` never signals "0 rows matched" | +| store writes whose boolean/null return is ignored by the caller (pattern 6) | 5 | thread-supabase.ts:676 (`createThreadInSupabase`), 684 (`resolveThreadInSupabase`), 680/690 (`touchThreadsInSupabase`), promote/dismiss-suggestion → `saveSuggestions`, startup.ts:221 (`writeVectorCache`) | + +#### Table rows (139) by primary pattern (a row may carry more than one) + +| Pattern | Rows | +|---|---| +| 1 empty / comment-only catch | 28 | +| 2 catch-and-log-only | 72 | +| 3 `.catch(() => …)` | 32 | +| 4 fire-and-forget / un-awaited | 13 | +| 5 `Promise.all` | 7 | +| 6 unchecked store write | 15 | + +#### Table rows by severity + +| Severity | Rows | Row numbers | +|---|---|---| +| critical | 1 | #64 | +| high | 6 | #17, #38, #78, #79, #85 (free tier), #110 (free tier) | +| medium | 26 | #1, 4, 11, 20, 24, 25, 27, 28, 32, 33, 34, 41, 43, 47, 50 (free), 51, 54, 68, 74, 75, 77, 92, 94, 105, 107, 137 (CLI) | +| low | 106 | everything else | + +#### Table rows by how the failure surfaces + +| Surfaces via | Rows | +|---|---| +| Effect tracker → `health` / next close WARN (ET) | 29 | +| Tool result (flag / `success:false`) | 6 | +| stderr / stdout only (invisible in MCP clients) | 71 | +| nothing at all | 27 | +| n/a (benign / dead code) | 6 | + +Of the 7 critical+high rows, **none** surfaces in the tool result that the user reads; 1 (#38) reaches ET/health after the fact, 5 are stderr-only, 1 (#64) is fully silent. + +--- + +### (b) Top 10 worst instances + +1. **#64 absorb-observations.ts:71-80 — critical.** On every store provisioned from setup.sql the `task_observations` column does not exist, so the `.then(present ? directUpsert : undefined)` chain resolves `undefined` with no log, no ET entry and no result flag; session_close's column filter then strips the same field. The tool reports "Absorbed N observations" and none are ever persisted anywhere durable for a Pro customer. +2. **#78 thread-supabase.ts:676 ← 215-218 — high.** `syncThreadsToSupabase` ignores `createThreadInSupabase`'s `null` and marks the thread synced; the close returns `all_synced:true`, and session-start.ts:873-880 later discards the local-only copy as "stale" after 24h. A thread lost with one stderr line, on the exact path GIT-69 was meant to close. +3. **#79 thread-supabase.ts:684 ← 267-270 — high.** Same loop ignores `resolveThreadInSupabase`'s `false`; the resolution never reaches Supabase, and because `all_synced` is true, threads.json is pruned — the only record of the resolution is deleted while the durable store still says open. +4. **#38 session-close.ts:1606-1611 — high.** The whole session's scar_usage batch is fire-and-forget; a `{success:false}` from the store is recorded by ET but the close has already printed `scars_applied: N`. On Pro this is the only path that writes per-session scar usage, so the input to blindspots/behavioral decay silently vanishes. +5. **#17 resolve-thread.ts:212-218 — high.** Cascade resolve of the "duplicate of" original: return value ignored, `.catch(() => {})`, and the result lists it under `also_resolved` regardless — a success claim for a write that may not have happened. +6. **#110 local-file-storage.ts:43-46 — high (free tier).** A corrupt `learnings.json` (or any collection) is read as `[]` "starting fresh" and the next `upsert` rewrites the file with one record; `writeCollection` is a non-atomic `writeFileSync`, so a crash mid-write manufactures exactly this file. Whole free-tier collections gone, write reports success. +7. **#85 thread-manager.ts:257-259 — high (free tier).** Same shape for threads.json, the free-tier thread SOT: corrupt → `[]` → next `saveThreadsFile` overwrites. +8. **#32 session-close.ts:1478-1481 — medium, but universal.** Every setup.sql store lacks `transcript_chunks`, so `storeHasTable` is false, the transcript is skipped, `transcriptStatus` stays undefined and the close display prints nothing. Customers never learn transcripts are not being captured. +9. **#20 archive-learning.ts:133-136 — medium.** `directPatch` on a full UUID that does not exist (no existence check on that input path) or an RLS-filtered row returns `[]`; the tool answers `success:true, durable:true`. Same "0 rows patched = success" defect at all 8 `directPatch` call sites. +10. **#47 session-start.ts:1148-1152 — medium.** The session row INSERT is fire-and-forget; a failure is stderr-only and the session_start result carries no flag, so a broken store is discovered only at close (or masked, because close recreates the row from the local file). + +Honourable mentions: #11 (batch insert `Promise.all` misreports partial success and invites duplicate rows on retry), #74/#75/#94 (confirm/reflect/surfaced-scar persistence swallows fs errors, so a restart erases the session's scar bookkeeping), #105 (`transcript_path` patch warning is dropped by the only live caller), #41 (stale closing-payload.json leaks into the next session's close). + +--- + +### (c) Pattern-level observations + +1. **`recordMetrics(...).catch(() => {})` — 20 sites** (every tool). Benign: `recordMetrics` wraps its upsert in `getEffectTracker().track("metrics", …)` and never rejects on store errors, so the `.catch` is dead; failures do reach `health`. Free tier no-ops. +2. **Effect tracker coverage is real but late.** 14 tracked fire-and-forget sites (+2 internal) do land in `health` and in `formatWriteWarnings` at close. Two caveats: (i) the close display is built before the tracked promises started in that same close resolve (#33, #38), so their failures appear only in a later `health` call or the next close; (ii) ET is in-memory — a restart erases it. +3. **Every `directPatch` caller ignores the returned row array (8/8).** `directPatch` throws on HTTP errors but returns `[]` for a filter that matched nothing (RLS, wrong id, row not created yet). archive-learning, thread resolve/touch/archive, session embedding, superseded marking, transcript_path and relevance updates all report success on 0 rows. +4. **`syncThreadsToSupabase` (thread-supabase.ts:660-700) catches its own loop exceptions and records them in `failed[]`, but its three write helpers swallow errors internally and return `null`/`false`/`void`, which the loop never inspects.** The `failed[]`/`all_synced` machinery added for GIT-69 therefore only fires on exceptions the helpers do not already eat (e.g. the `directQuery` existence check), not on the writes themselves (#78, #79, #82). +5. **"log and return a default" is the dominant style in services (~36 catches).** `thread-supabase.ts` (8), `session-start.ts` loaders (5), `startup.ts` (3), `active-sessions.ts` (3) all return `null`/`[]`/`{count:0}` on failure, so callers cannot tell "store said nothing" from "store unreachable". Downstream this produces the first-session banner for a returning user (#43), `archived_count: 0` for a failed archive (#68), and aggregation fallbacks that silently show stale data (#70, #81). +6. **Capability probe "unknown" collapses to "absent" (store-columns.ts:36-38).** Correct for schema errors, but a transient network/auth error during the probe now silently drops optional columns from a write on a store that has them (#107), skips the transcript upload (#32) and skips observation persistence (#64). Only #107 logs; #64's skip path logs nothing. +7. **Local session-state persistence is best-effort everywhere** (session-state.ts:281/308/335/411, confirm-scars.ts:156, reflect-scars.ts:134, session-start.ts:621/836/1221/1446, active-sessions.ts:387/417). Each site is individually low-stakes, but together they mean an MCP restart can erase recall_called, recall_failure, surfaced scars, confirmations and reflections with only stderr lines — and the GIT-93 "failed recall ≠ empty recall" guard (#92) is one of the casualties. +8. **Free-tier store files are read with "corrupt → empty" and written non-atomically** (local-file-storage.ts:43-66, thread-manager.ts:249-272). Two independent sites produce the same wipe-on-next-write outcome (#85, #110). `active-sessions.ts` and `vector-disk-cache.ts` already have tmp+rename helpers; the SOT files do not use them. +9. **Setup.sql gaps are handled by silent skipping rather than disclosure.** `transcript_chunks` (#32), `task_observations` (#64), `archived_at` (archive-learning.ts:129-136), `refresh_scar_behavioral_scores` RPC (#58), variant tables (dev-gated). Each skip is by design, but none is reflected in the affected tool's result, so a customer cannot tell a disabled feature from a broken one. +10. **`Promise.all` is mostly safe here** — 7 of 9 sites wrap loaders that self-catch and cannot reject. The two that can reject are record-scar-usage-batch.ts:175 (loses sibling ids, invites duplicate rows on retry, #11) and analyze.ts:136 (visible failure). session-start.ts:190 and variant-generation.ts:320 already use `allSettled`. +11. **Dead code with silent-failure shape:** `captureSessionTranscript` (session-close.ts:645-735) is never called; its `.catch(err => console.error)` and the `patch_warning` propagation it contains do not run. The live inline path (1482-1501) re-implements it without the `patch_warning`/`{success:false}` logging. +12. **Result-level honesty (GIT-101 `writeResult`) is applied to the primary write but not to the secondary effects it triggers.** create_learning/decision/thread, resolve_thread, archive_learning, session_close all return an honest `WriteResult` for their main row, then launch 1-4 tracked or untracked side writes whose failure never changes that result. The single exception is create_thread, which reads back its row before claiming success. +13. **Two error-shape inconsistencies worth noting (not tabled, visible failures):** create-decision.ts:150-160 returns `display: "Failed to log decision"` with the cause only on stderr (create-learning includes it); the free-tier recall catch (recall.ts:495-511) returns the error but does not call `setRecallFailure`, unlike the Pro path (766). + +--- + +## 4. Class B — False Success audit (gitmem v1.11.0, f93c3a3, branch audit/GIT-113-phase-0) + + +Read-only audit. Scope: every handler in src/tools/*.ts plus src/services/* that write to a store. +Dispatch fact (src/server.ts:429-430): when a result has a string `display`, ONLY that string is sent to the client. Result fields (success/durable/stored_in) never reach the agent when a display exists. + +### Working notes (incremental — table at end) + +#### Store primitives (baseline for every finding) +- src/services/supabase-client.ts:418-462 `directUpsert`: throws on non-2xx (L440-443) AND on empty representation (L451-456). Honest — a resolved promise means a row exists. +- src/services/supabase-client.ts:476-510 `directPatch`: throws on non-2xx (L505-508) but RETURNS `[]` when the filter matched zero rows (L510). Any caller that discards the return value cannot distinguish "updated" from "no such row". +- src/services/local-file-storage.ts:49-67 `writeCollection` uses fs.writeFileSync — throws on I/O failure, so free-tier `upsert` (L128-143) is honest. Note L58-64: >10MB silently drops oldest 20% of records and returns normally. +- src/services/effect-tracker.ts:136-178 `track()`: never rethrows; rejection -> returns undefined; `{success:false}` result -> recorded as failure but result still returned. Only visible via `health`. +- src/services/triple-writer.ts:288-347 `writeTriples`: throws if any triple failed (so tracker records it). Callers all use track() → invisible to tool result. +- src/services/write-health.ts:217-224: an unexpected error in the probe returns `{ ok: true, mode: "skipped" }` — summary says "durability UNVERIFIED" yet ok:true (health line prints "(ok, ...)"). +- src/services/write-result.ts:36-45 `writeResult(durable, storedLocally)`: pure function of its args — honesty depends entirely on what each caller passes as `durable`. + +#### Service-level findings (thread + suggestion stores) +- src/services/thread-suggestions.ts:206-220 `saveSuggestions`: try/catch swallows fs.writeFileSync errors, logs to stderr, returns void. Callers (dismiss_suggestion, promote_suggestion) cannot know the file was not written. +- src/services/thread-supabase.ts:725-771 `archiveDormantThreads`: catch at L767-770 returns `{archived_count: 0, archived_ids: []}` on ANY error — including an error on the Nth PATCH after N-1 rows were already archived (L755-758 loop). Partial archivals are reported as zero. Callers cannot distinguish "nothing eligible" from "store failed". +- src/services/thread-supabase.ts:573-716 `syncThreadsToSupabase`: the per-thread try block calls `createThreadInSupabase` (L674) which RETURNS NULL on failure (L215-218, never throws), `resolveThreadInSupabase` (L682) which RETURNS FALSE on failure (L267-270), and `touchThreadsInSupabase` (L671, L689) which swallows every error (L561-564). None of these return values are examined; `synced.push(thread.id)` at L691 runs regardless. Therefore `failed` can only be populated by the directQuery lookup at L657 throwing. `all_synced: true` (L712) is reported when every create/resolve/touch failed. The GIT-69 comment (L578-582) says this gate exists so session_close can refuse to prune the local cache — the gate is hollow for write failures. +- src/services/thread-supabase.ts:226-271 `resolveThreadInSupabase`: returns true after directPatch (L264) without inspecting the returned row array; row was looked up at L240 so a zero-row patch is only a race. Minor. +- src/services/thread-supabase.ts:198-219 `createThreadInSupabase`: honest (null on failure) — caller responsibility. +- src/services/store-columns.ts:32-39: a probe that fails for a non-schema reason (network down) returns "unknown" → column treated as absent → optional field silently dropped from the write. By design (comment L17-19); noted because it means absorb_observations persists nothing when the store is unreachable and says nothing. + +#### Tool notes batch 1 (create_thread, resolve_thread, promote_suggestion, archive_learning, record_scar_usage, record_scar_usage_batch) +- create_thread (src/tools/create-thread.ts): honest. durable = supabaseSynced && post-write read (L363-367); display derived from `durable` (L397-405); success = durable || !hasSupabase() (L408). Only nit: L313 `if (threads.length > 0)` — the in-memory session thread list is NOT updated when it is currently empty, so the first thread created in a session is not in session state (affects session_close's thread sync input, not this tool's claim). +- resolve_thread (src/tools/resolve-thread.ts): primary outcome honest via `writeResult(supabaseSynced, true)` L283 and display rewrite L284-290. GAP (partial): cascade resolves L212-218 — `resolveThreadInSupabase` return ignored, `.catch(()=>{})`; display L280 says "Also resolved: t-xxx" regardless of whether the durable store accepted the cascade. Also L246/L259 hard-code `project: "default"` for triples (not a success claim; noted). +- promote_suggestion (src/tools/promote-suggestion.ts:96-97,107): after createThread succeeds, `saveSuggestions(suggestions)` swallows write errors (thread-suggestions.ts:206-220); display "Promoted suggestion to thread\nID: t-xxx" and success/durable copied from createThread (L101-103). If suggested-threads.json write fails the suggestion stays "pending" and re-surfaces at next session_start while the tool said promoted. Partial gap (the thread itself IS honest). +- archive_learning (src/tools/archive-learning.ts): FALSE SUCCESS on pro. L133-136 `await directPatch(learningsTable, { id: eq. }, {...})` — return value (row array) discarded. directPatch returns [] for zero matched rows (supabase-client.ts:510). L47-51: a full-UUID input bypasses the existence lookup entirely. L176 `...writeResult(hasSupabase())` → {success:true, durable:true, stored_in:"supabase"}; display L173 "Archived learning .\n(Nms)". So archive_learning() reports durable success. cache_flushed (L138-143) IS derived from the real flushCache outcome — honest. +- record_scar_usage (src/tools/record-scar-usage.ts): honest — directUpsert throws → catch → notStored (L110-121). writeResult(hasSupabase()) at L105 is reached only after a successful await. +- record_scar_usage_batch (src/tools/record-scar-usage-batch.ts): no `display` → JSON reaches agent. (a) L101-115 free tier returns `success: true, durable: false, stored_in: null` — contradicts write-result.ts contract ("success false whenever nothing was stored") — by design per comment, but a caller at free tier is told success with zero usage_ids. (b) L167-176: Promise.all — one failed upsert rejects the whole batch → catch L206 reports notStored with `usage_ids: []` although the other upserts already landed (under-report, not false success). (c) L65-83 partial-title resolution scans an unordered `limit: 100` page and matches `description.includes(identifier)` — can attribute a usage to the wrong scar and report it resolved. + +#### Tool notes batch 2 (create_learning, create_decision, save_transcript, confirm_scars, reflect_scars) +- create_learning (src/tools/create-learning.ts): honest. writeResult(hasSupabase()) L318 reached only after awaited directUpsert (L222) with id check (L233-238); free tier awaited local upsert (L284); catch → notStored (L330-337). Side effect not in claim: flushCache L277-279 fire-and-forget — on failure the new learning is invisible to recall until restart, display still says "Created scar". Triples/variants via tracker (health only). +- create_decision (src/tools/create-decision.ts): honest; same structure (L87 awaited, L145 writeResult, L154-159 notStored). Failure display L158 "Failed to log decision" drops the error message (result.error not set at all) — the agent is told it failed but not why. +- save_transcript (src/tools/save-transcript.ts): honest. `writeResult(true)` L113 only after `supabase.saveTranscript` resolved; uploadFile throws on non-2xx (supabase-client.ts:830-833). patch_warning L118 surfaces the session-row PATCH failure. No display → JSON reaches agent. Nit: on free tier isConfigured() is false → uploadFile throws → notStored (fine), but if a free-tier user happens to have SUPABASE_URL set, writeResult(true) at L113 would yield {success:true, stored_in:"local"} for a Supabase upload — label wrong, not a false success. +- confirm_scars (src/tools/confirm-scars.ts): does NOT call record_scar_usage (session_close does). Claims: L103 "SCAR CONFIRMATIONS ACCEPTED", L110 "All recalled scars addressed. Consequential actions are now unblocked." Evidence: in-memory addConfirmations (L310) — always succeeds. `persistConfirmationsToFile` L140-159 swallows fs errors and silently returns when session.json is absent (L146). If the PreToolUse hook reads .gitmem/sessions//session.json (see gitmem-dir/hook), "unblocked" can be false while the display says it. Partial gap; GIT-93 recall-failure branch L196-220 is honest. +- reflect_scars (src/tools/reflect-scars.ts): same shape; L92 "Session close will use these for execution_successful." persisted in memory (L249) + file with swallowed errors (L122-137). Reflections reach session_close via in-memory getReflections — true within one process; false across an MCP restart if the file write failed. Partial. + +#### session_close (src/tools/session-close.ts) — detailed +- Session row itself: HONEST. L1548-1553 awaits directUpsert; `sessionRowStored = true` only after (L1553); catch L1739-1758 returns writeResult(sessionRowStored) + success:false. +- SCAR USAGE — FALSE SUCCESS (confirmation before completion). L1606-1611 `getEffectTracker().track("scar_usage", ..., () => recordScarUsageBatch(...))` is fire-and-forget. Display built at L1727 via formatCloseDisplay: L378-379 "N scars applied" and L419-425 one `STATUS.pass` line per scar — all computed from `params.scars_to_record` (input), never from the batch result. close_compliance.scars_applied (buildCloseCompliance) likewise from input. The batch has not resolved when the display is rendered, so formatWriteWarnings (L440) cannot include it either. Compounding: recordScarUsageBatch returns success:true even when every identifier failed to resolve (failed_identifiers populated, usage_ids []) — effect-tracker.reportedFailure (effect-tracker.ts:103-113) only flags `success === false`, so `health` also counts it as a success. Net: on a customer store where gitmem_scar_usage is missing/blocked or scar ids don't resolve, close says "✓ complete · 3 scars applied" with three check marks and nothing anywhere records otherwise. +- THREAD SYNC — FALSE SUCCESS. L1423 `threadSync = await syncThreadsToSupabase(...)`; L1443 `if (!threadSync.all_synced)` skip prune else L1449-1450 `saveThreadsFile(openThreadsOnly)` (drops resolved threads locally); L1687 `partialPersist = !threadSync.all_synced`; L1732 `success: !partialPersist`. But all_synced is hollow (thread-supabase.ts:663-691: create→null, resolve→false, touch→swallowed, none examined, synced.push unconditional). So: every thread create/resolve fails on the store → all_synced:true → threads.json pruned → cleanupSessionFiles + payload deleted (L1689-1696) → display `STATUS.complete` with "N threads". This is the exact scar (cd345431) the GIT-69 comment at L1401-1408 says was fixed. Only a *lookup* failure (directQuery at thread-supabase.ts:657 throwing) populates `failed`. +- TRANSCRIPT — hidden failure, no false claim. L1459 `transcriptStatus` is declared and never assigned on the pro path (upload is tracked fire-and-forget L1482-1502), so formatCloseDisplay's transcript-failure line (L434-437) can never render. `captureSessionTranscript` (L645-733), which did set it, is unreferenced (verify with grep). Failure visible only via `health` later. +- "N learnings" (L380) and close_compliance.learnings_stored come from `params.learnings_created.length` — the agent's claim, not a store read. Display restates input as outcome. Partial. +- EMBEDDING/SUGGESTIONS (L1556-1598) and updateRelevanceData (L1622): fire-and-forget, nothing claimed in display → no false claim; failures reach `health` only. saveSuggestions inside (L1593) swallows. +- FALSE FAILURE (mismatch): L1681 `await writeAgentBriefing(...)` runs after the upsert; if it throws, the catch reports display `STATUS.failed` + "Failed to persist session: " while the row is stored (writeResult(sessionRowStored) → success overridden to false at L1751) and session state already cleared (L1677). Agent is told the close failed; retry will find close_compliance already set. +- Write warnings (L440, formatWriteWarnings L471-487): computed from tracker at render time — reflects failures from EARLIER in the process (create_learning triples etc.), never the writes this call just launched. +- Free tier `sessionCloseFree` L223-344: honest (awaited local upserts; catch → notStored + STATUS.failed display). + +#### Remaining tool notes (session_start, session_refresh, recall, read-only tools, index_docs, absorb/dismiss/feedback/cleanup confirmations) +- session_start (src/tools/session-start.ts): session row write is fire-and-forget L1149-1152 (`registerPendingSessionRow(sessionId, createSessionRecord(...).then(r => r.stored))`); `stored` is consumed only by metrics.ts:156-178 (FK guard). Display built L1300 → productLine("active") + session id, before the write resolves; nothing in display or result reports a failed row. loadLastSession catch L280-283 and the allSettled halves L204/L211 return empty → display L1031-1034 "No threads or decisions." identical to a genuinely empty store; L1299 `isFirstSession = !isResuming && !slimLastSession` → a Supabase outage prints FIRST_SESSION_NUDGE ("Welcome to gitmem...") to an established user. writeSessionFiles: session.json write swallowed L836-838; whole call wrapped L1219-1223 with warn only. `resumed` (L1256) comes from disk/registry (checkExistingSession) — honest. +- sessionStartFree: local session upsert failure swallowed L596-598; display still "active". +- session_refresh (L1329-1504): same loadLastSession semantics; display "refreshed"; writeSessionFiles failure swallowed L1446-1448. Free tier returns `message` without display (JSON) — honest. +- recall (src/tools/recall.ts): honest post-GIT-93. Local index not ready → Supabase fallback L544-565; any throw → setRecallFailure + "⚠️ Error querying institutional memory" L758-783; confirm_scars distinguishes (confirm-scars.ts:196-220). local-vector-search.ts:220-223 returns [] when not ready but recall guards with isLocalSearchReady() first. Residual: local index staleness (create_learning flush failure L277-279; another process's writes until periodic refresh) makes "No relevant scars found" (L192) possible while the scar exists — read-side, low. +- search (src/tools/search.ts): catch L218-230 (free) and L350-364 (pro) return `display: buildSearchDisplay([], 0, ...)` → "No results found." (L92). An unreachable store is rendered as an empty corpus. log (src/tools/log.ts): identical, L191-200 and L283-294 → "No entries found." (L75). +- list_threads (src/tools/list-threads.ts:100-150): Supabase null → session aggregation → local file; display L56 "N open · M resolved" never states the source, so a degraded read from a stale threads.json looks authoritative. prepare_context: honest (error payload L378). analyze/graph_traverse/get_transcript/search_transcripts/search_docs: honest. +- index_docs (src/tools/index-docs.ts + src/services/doc-index.ts): write path throws (writeLocalIndex L102) → server error; counts honest at call time. Durability gap: doc-index.ts:95 strips embeddings before writing docs-index.json; initDocVectorIndex L435-438 rebuilds from that file → 0 vector entries after restart → searchDocs L257 silently falls to BM25; index_docs then reports "up to date — no changes detected" (L139). "Chunks embedded: N" (L193) is true only for the current process lifetime. +- health (src/server.ts:367-383): honest; note write-health.ts:223 `skipped` → ok:true with summary "durability UNVERIFIED" → line reads "(ok, ...)". +- Confirmations: session-state.ts:537-541 addObservations returns 0 with no session (no error); session-columns.ts:74/92/114 drops task_observations on stores lacking it; schema/setup.sql has no task_observations/transcript_chunks/archived_at (grep) — gitmem_scar_usage IS provisioned (L132). enforcement.ts:116-118 reads confirmations from in-memory session, not the file. + +--- + +### FINAL REPORT + +Legend — gap: none / partial (claim over-states or hides a failure but the core write is verified) / false-success (a success claim with no evidence, or with contrary evidence). Severity: high / medium / low. "display" = the only text the agent sees (server.ts:429-430). + +| # | tool / function | file:line | claim (field or display text) | what evidence exists at that point | gap | on a customer store: what the user is told vs what happened | reachable in normal use? | severity | +|---|---|---|---|---|---|---|---|---| +| 1 | session_close → syncThreadsToSupabase | src/tools/session-close.ts:1423,1443-1450,1687,1732; src/services/thread-supabase.ts:663-691,215-218,267-270,561-564 | display `STATUS.complete` header + "N threads"; result `success:true`; local threads.json pruned; session dir + closing-payload.json deleted | `all_synced` is `failed.length===0`, but the loop pushes every id to `synced` (L691) without reading createThreadInSupabase (returns null on failure), resolveThreadInSupabase (returns false) or touchThreadsInSupabase (swallows). Only a lookup throw (L657) records a failure. | false-success | Told: "close complete", threads synced. Happened: any/all thread creates/resolves rejected by the store; resolved threads dropped from threads.json; retry state deleted. The GIT-69 comment (L1401-1408) says this was fixed; the gate is hollow for write failures. | Yes — any RLS/constraint/network failure on gitmem_threads | high | +| 2 | session_close → recordScarUsageBatch | src/tools/session-close.ts:1606-1611,378-379,419-425,440,1727 | display "N scars applied" + one `STATUS.pass` line per scar; close_compliance.scars_applied | None. Batch is `getEffectTracker().track(...)` fire-and-forget; display rendered from `params.scars_to_record` before the promise settles; formatWriteWarnings (L440) reads the tracker before the batch reports. | false-success | Told: "✓ 3 scars applied" with check marks. Happened: batch may have inserted 0 rows (network, RLS, unresolved identifiers). Nothing later corrects it (see #3). | Yes — every close with surfaced scars | high | +| 3 | record_scar_usage_batch | src/tools/record-scar-usage-batch.ts:198-205 (+ effect-tracker.ts:103-113) | `success:true, durable:true, stored_in:"supabase"` (JSON, no display) | Reached whenever no upsert threw — including `resolvedCount===0`, `usage_ids:[]`, all entries in `failed_identifiers`. Tracker's reportedFailure only inspects `success===false`, so `health` counts it as success. | false-success | Told (by session_close's tracker): success. Happened: zero usage rows written because titles/IDs did not resolve (title matching L54-83 is exact/`includes` on an unordered 100-row page). | Yes — auto-bridge passes scar UUIDs (fine) but Q6 text and coerced `{title}` entries (session-close.ts:1039-1046) pass titles | medium | +| 4 | archive_learning (pro) | src/tools/archive-learning.ts:47-51,133-136,173,176 | display "Archived learning .\n(Nms)"; `success:true, durable:true, stored_in:"supabase"` via `writeResult(hasSupabase())` | `await directPatch(...)` resolved — but its row array is discarded, and directPatch returns `[]` for zero matched rows (supabase-client.ts:510). A full-UUID input skips the existence lookup entirely (L49-51). | false-success | Told: archived, durable. Happened: no row matched (nonexistent UUID, wrong project prefix, RLS-hidden row) — nothing changed; cache flushed anyway. | Yes — any mistyped/foreign UUID | high | +| 5 | session_start session row | src/tools/session-start.ts:1149-1152,1300; createSessionRecord L464-473 | display `productLine("active")` + ` · · ` | None — the sessions upsert is in flight; `stored:false` is only consumed by metrics' FK guard (metrics.ts:170-178). | partial (confirmation-before-completion) | Told: session active with an ID. Happened: no durable row; the ID exists only in local files until session_close upserts it (which will then fail honestly if the store is still down). | Yes — store unreachable / sessions table missing | medium | +| 6 | session_start reads | src/tools/session-start.ts:204,211,280-283,1031-1034,1048-1050,1299 | display "No threads or decisions."; result `last_session:null`, no `open_threads`; FIRST_SESSION_NUDGE "Welcome to gitmem…" | loadLastSession catch → empty; allSettled halves → empty; no field or line distinguishes "read failed" from "nothing stored". | partial (read-side false-empty) | Told: clean slate, welcome banner. Happened: Supabase unreachable; threads/decisions exist. | Yes — any outage at start | medium | +| 7 | session_refresh | src/tools/session-start.ts:1381-1391,1446-1448,1493 | display `productLine("refreshed")`; "No threads or decisions." | Same loadLastSession semantics as #6; session file write failure swallowed. | partial | Told: refreshed. Happened: reads failed / file not updated. | Yes | low | +| 8 | session_start / refresh → writeSessionFiles | src/tools/session-start.ts:836-838,1219-1223,1446-1448; sessionStartFree 596-598,618-623 | display "active"/"refreshed" | session.json write, registry write and local session upsert failures are logged only. | partial | Told: active. Happened: .gitmem/sessions//session.json absent → confirm_scars/reflect_scars file persistence silently no-ops (L146), session_close "not found in Supabase, local files, or registry" if the row also failed. | Rare (fs permissions/disk) | low | +| 9 | cleanup_threads auto_archive → archiveDormantThreads | src/tools/cleanup-threads.ts:200-204,116,303,306; src/services/thread-supabase.ts:747-770 | display "Archived: 0"; `success:true, archived_count:0, archived_ids:[]` | catch L767-770 returns zeros on ANY error, including after k of N PATCHes succeeded (partial archivals lost). Caller cannot distinguish "nothing eligible" from "store failed". | false-success (silent failure reported as a clean zero; partial archivals under-reported) | Told: nothing to archive / 0 archived. Happened: query or PATCH failed; or k threads WERE archived and the response says 0. | Yes — any store error during triage | medium | +| 10 | cleanup_threads free tier with auto_archive:true | src/tools/cleanup-threads.ts:129,186-194 | display "Archived: 0"; `success:true` | auto_archive is never attempted on free tier; nothing says so. | partial | Told: 0 archived. Happened: request ignored. | Yes | low | +| 11 | dismiss_suggestion | src/tools/dismiss-suggestion.ts:66,70,73; src/services/thread-suggestions.ts:206-220 | display "Dismissed suggestion: "; `success:true` | saveSuggestions is void and swallows fs.writeFileSync errors. | false-success (on I/O failure) | Told: dismissed. Happened: suggested-threads.json unchanged; suggestion re-surfaces. | Only on fs failure (read-only dir, disk full) | low | +| 12 | promote_suggestion | src/tools/promote-suggestion.ts:96-97,107 | display "Promoted suggestion to thread\nID: t-…" | Thread outcome copied from createThread (honest); suggestion-file save swallowed as #11. | partial | Told: promoted. Happened: thread exists but suggestion still "pending" → offered again next session. | Only on fs failure | low | +| 13 | contribute_feedback remote | src/tools/contribute-feedback.ts:117-138,143,150 | `remote_submitted:true`; display "(queued for remote)" | `remoteSubmitted = true` is set unconditionally after launching the tracked promise; outcome only in `health`. | partial (result field is a constant; display hedges with "queued") | Told: queued. Happened: POST to community_feedback may 4xx/5xx; user never learns. Local file write (L105) is honest — it throws. | Yes when feedback_enabled | low | +| 14 | contribute_feedback rate limit | src/tools/contribute-feedback.ts:69,105 | "(N remaining this session)" | incrementFeedbackCount runs before the local write; a throw at L105 consumes a slot. | partial | Told nothing (server error). Happened: quota decremented for a non-write. | Rare | low | +| 15 | absorb_observations persist | src/tools/absorb-observations.ts:71-80,101,113; src/services/store-columns.ts:32-39; src/services/session-columns.ts:74,92,114 | display "Absorbed N observations (M scar candidates)"; tool description "calls this to persist and analyze them" | Only in-memory addObservations is verified. The Supabase upsert is a bare promise with `.catch(console.error)` — not even effect-tracked — and is skipped entirely when the probe says the column is absent or the probe errors ("unknown" → absent). At close, filterToStoreSessionColumns drops task_observations on setup.sql stores. | false-success (for the "persist" claim on customer stores); partial on nTEG's store | Told: absorbed. Happened on every customer store: observations live only in process memory and are discarded at close; no message at either point. | Yes — every pro customer | medium | +| 16 | absorb_observations without session | src/tools/absorb-observations.ts:60,101; session-state.ts:537-541 | display "Absorbed 0 observations (M scar candidates)" + suggestions | addObservations returned 0 because there is no session; no error text. | partial | Told: 0 absorbed (reads like "nothing to absorb"). Happened: refused for lack of session. | Yes | low | +| 17 | resolve_thread cascade | src/tools/resolve-thread.ts:212-218,280 | display "Also resolved: t-…" | resolveThreadInSupabase return value ignored, `.catch(()=>{})`. Primary resolve IS honest (L283-290). | partial | Told: original also resolved. Happened: cascade may be local-only while the primary line says durable. | Yes when note says "duplicate of t-…" | low | +| 18 | create_learning cache flush | src/tools/create-learning.ts:277-279 | display "Created scar: …" | Row write verified (L222-238). flushCache failure only warned; recall then serves the stale local index and can answer "No relevant scars found" (recall.ts:192) for a scar that exists. | partial (read-side) | Told: created. Happened: stored but not recallable until restart/refresh. | Yes when the reload read fails | low | +| 19 | create_decision failure display | src/tools/create-decision.ts:154-159 | display "Failed to log decision" (no reason; no `error` field) | Honest that it failed; the cause is dropped. | none (mismatch: under-information) | Told: failed. Happened: failed — reason invisible. | Yes | low | +| 20 | session_close transcript | src/tools/session-close.ts:1459,1482-1502,434-437; captureSessionTranscript 645-733 unreferenced | display never mentions transcript; result has no transcript field | `transcriptStatus` is declared and never assigned on the pro path (upload is tracked fire-and-forget), so the failure line at L434-437 is dead. | partial (hidden failure; no positive claim) | Told nothing. Happened: transcript upload may have failed; only `health` knows. | Yes (cli/desktop with transcript_chunks table) | low | +| 21 | session_close "N learnings" / learnings_stored | src/tools/session-close.ts:380,1308; compliance-validator.ts:308; scars_applied 291-297 | display "N learnings", "N scars applied" (stats line) | Counts come from `params.learnings_created.length` and Q6 text — the agent's own claim, never a store read. | partial (input echoed as outcome) | Told: 2 learnings. Happened: whatever create_learning did earlier, unverified here. | Yes | low | +| 22 | session_close after writeAgentBriefing throw | src/tools/session-close.ts:1677,1681,1739-1758 | display `STATUS.failed` + "Failed to persist session: "; `success:false` | Row was upserted (sessionRowStored=true, writeResult(true) spread) then overridden to success:false; session state already cleared. | none (false FAILURE / mismatch) | Told: close failed. Happened: session stored; retry finds close_compliance already set. | Rare | low | +| 23 | session_close write-health warnings | src/tools/session-close.ts:440,471-487 | "⚠ : k of n writes failed … session content stored OK" | Reflects tracker state at render time — earlier tools' failures, never the embedding/scar_usage/transcript effects this call just launched. | partial (confirmation-before-completion) | Told: no warnings. Happened: this close's async writes not yet settled. | Yes | low | +| 24 | search | src/tools/search.ts:218-230,350-364,92 | display "No results found." | catch blocks return an empty display; the error message goes to stderr only. | partial (read-side false-empty) | Told: nothing matches. Happened: store unreachable / query error. | Yes | medium | +| 25 | log | src/tools/log.ts:191-200,283-294,75 | display "No entries found." | Same pattern as #24. | partial (read-side false-empty) | Told: empty log. Happened: query failed. | Yes | medium | +| 26 | list_threads | src/tools/list-threads.ts:100-150,56-59 | display "N open · M resolved" | Falls Supabase → session aggregation → threads.json; the display never names the source. | partial | Told: authoritative counts. Happened: numbers from a stale local cache while Supabase was down. | Yes | low | +| 27 | index_docs "Chunks embedded" | src/tools/index-docs.ts:193,139; src/services/doc-index.ts:95,222,435-438,257 | display "Chunks embedded: N"; later "up to date — no changes detected" | Embeddings are held in memory only; docs-index.json is written without them; on restart the vector index rebuilds to 0 entries and search falls back to BM25 silently. | partial (durability over-claim) | Told: N chunks embedded, index up to date. Happened: after any restart, no embeddings exist and re-index is refused as unchanged. | Yes — every restart on pro | medium | +| 28 | record_scar_usage_batch free tier | src/tools/record-scar-usage-batch.ts:101-115 | `success:true, durable:false, stored_in:null` | Nothing is written by design; contradicts write-result.ts:11-15 ("success false whenever nothing was stored"). | partial | Told: success. Happened: nothing stored; free-tier session_close records scar_usage separately (sessionCloseFree L292-305), so no data loss — the field is just wrong. | Yes on free | low | +| 29 | record_scar_usage_batch partial insert | src/tools/record-scar-usage-batch.ts:167-176,212-220 | on throw: `notStored()`, `usage_ids:[]`, `failed_count: N-resolved` | Promise.all rejects on the first failure; rows already inserted are reported as not stored. | none (under-report) | Told: nothing recorded. Happened: some rows exist. | Rare | low | +| 30 | confirm_scars / reflect_scars file persist | src/tools/confirm-scars.ts:103,110,140-159; src/tools/reflect-scars.ts:92,122-137 | "SCAR CONFIRMATIONS ACCEPTED … Consequential actions are now unblocked." / "Session close will use these" | In-memory state updated (enforcement.ts:116-118 reads memory, so the in-process claim holds). File persistence swallows errors and silently returns when session.json is absent. | partial (claim false only across an MCP restart) | Told: unblocked/recorded. Happened: after a restart, confirmations/reflections are gone; session_close's auto-bridge records the scars as "none / not addressed". | Rare | low | +| 31 | health write-path verdict | src/services/write-health.ts:217-224,76-79 | "Write path: check skipped after an internal error — durability UNVERIFIED (ok, …)" | `skipped` returns `ok:true`. | none (mismatch in wording) | Told: ok. Happened: not checked. | Rare | low | +| 32 | thread-supabase resolveThreadInSupabase | src/services/thread-supabase.ts:264-266 | returns true | PATCH row array not inspected (row looked up 24 lines earlier; only a race). | none | — | Rare | low | +| 33 | effect-tracker reportedFailure | src/services/effect-tracker.ts:103-113 | records success for any result without `success:false` | Enables #3 and lets `{success:true, failed_identifiers:[…]}` and processTranscript `{success:true, chunksCreated:0}` count as successes. | partial (enabler) | — | Yes | low | +| — | create_thread | src/tools/create-thread.ts:363-408 | "Thread created … stored in supabase" / "SAVED LOCALLY ONLY" | Post-write read of the stored row; durable ⇒ read succeeded. (Nit L313: in-memory list not updated when empty.) | none | honest | — | — | +| — | resolve_thread (primary) | src/tools/resolve-thread.ts:202-209,283-290 | "Thread resolved" / "RESOLVED LOCALLY ONLY" | writeResult(supabaseSynced,true) from the actual return value. | none | honest | — | — | +| — | create_learning / create_decision / record_scar_usage | create-learning.ts:222-238,318; create-decision.ts:87,145; record-scar-usage.ts:65,105 | "Created …" / "Decision logged" / "Scar usage recorded" | awaited directUpsert, which throws on non-2xx and on empty representation. | none | honest | — | — | +| — | save_transcript | src/tools/save-transcript.ts:92-118 | writeResult(true), patch_warning | awaited upload (throws on non-2xx); session PATCH failure surfaced as patch_warning. | none | honest | — | — | +| — | recall | src/tools/recall.ts:544-565,572,758-783 | "No relevant scars found" vs "⚠️ Error querying institutional memory" | local-not-ready → remote fallback; any throw → banner + setRecallFailure; confirm_scars checks it. | none (residual staleness #18) | honest | — | — | +| — | prepare_context, analyze, graph_traverse, get_transcript, search_transcripts, search_docs, health | (see notes above) | error payloads / success:false / server error | catches return explicit error text; search_docs and index_docs let write errors propagate. | none | honest | — | — | + +### The four GIT-101-excluded tools + +**absorb_observations** (src/tools/absorb-observations.ts). Writes: (1) in-memory `addObservations` (session-state.ts:537-553) — verified by return count; (2) a Supabase upsert of `{id: sessionId, task_observations: getObservations()}` to gitmem_sessions (L71-80), gated on `supportedColumns(sessionsTable, ["task_observations"])`, launched as a bare promise with `.catch(console.error)` — not awaited, not effect-tracked, so it reaches neither the result nor `health`. The display is "Absorbed N observations (M scar candidates)" (L101) with N from the in-memory add. Outcome of the durable write never reaches result or display. Is that a gap? Yes, and it is a false-success rather than merely partial on customer stores: schema/setup.sql does not define task_observations, so the probe returns "missing" and nothing is written mid-session; at session_close the same column is stripped by filterToStoreSessionColumns (session-columns.ts:74,92,114). The tool description says it is called "to persist and analyze" observations, the display says absorbed, and on every customer store the observations are discarded at both points with no message. On nTEG's store (column present) the write is real but its failure is invisible. A secondary gap: with no active session the display says "Absorbed 0 observations" (L60→0) rather than refusing. + +**dismiss_suggestion** (src/tools/dismiss-suggestion.ts). Writes: mutates the in-memory suggestions array (`dismissSuggestionById`, thread-suggestions.ts:157-168) and calls `saveSuggestions(suggestions)` (L66) which writes .gitmem/suggested-threads.json. `saveSuggestions` is `void` and swallows every write error (thread-suggestions.ts:206-220). The tool then returns `success:true` and display "Dismissed suggestion: " (L69-74). The write's outcome cannot reach the result because the function discards it. Gap: false-success on I/O failure — the suggestion remains "pending" on disk and is re-offered at the next session_start while the tool said dismissed. Reachability is low (a local file write failing), which is why it is rated low; it is still a claim with no evidence. Note the tool is local-only on every tier, so "durable" semantics are the local file's. + +**contribute_feedback** (src/tools/contribute-feedback.ts). Writes: (1) local `.gitmem/feedback/--.json` via `fs.writeFileSync` (L105) — throws on failure, so "Feedback recorded: … Saved to .gitmem/feedback/" (L144) is backed by evidence; (2) when `feedback_enabled`, a POST to the hard-coded community_feedback endpoint (feedback-remote.ts:26-43, throws on non-2xx) run under `getEffectTracker().track("feedback", ...)` (L118-137). `remoteSubmitted = true` is assigned unconditionally at L138 right after launching, so `remote_submitted:true` (L150) is a constant, not an outcome. The display says "(queued for remote)" (L143), which is accurate wording; the result field is not. Gap: partial — the display hedges correctly, but the result field over-states and the remote failure is visible only in `health`. Minor: the per-session quota is decremented (L69) before the local write, so a thrown write consumes a slot. + +**cleanup_threads** (src/tools/cleanup-threads.ts). Writes: only when `auto_archive:true` on pro/dev — `archiveDormantThreads(project)` (L201) PATCHes `status:"archived"` per eligible row (thread-supabase.ts:755-758). Everything else is a read. `archiveDormantThreads` catches every error and returns `{archived_count:0, archived_ids:[]}` (L767-770); cleanup_threads copies those into the result (L202-203, L306-307) and display "Archived: N" (L116) with `success:true` (L303). So the outcome reaches the display only when nothing failed; a failed query, a failed first PATCH, or a failed Nth PATCH after N-1 successes all render as "Archived: 0". Gap: false-success (a store failure reported as a clean zero) and an under-report (partial archivals lost). Also, on free tier `auto_archive` is silently ignored (L129-194) and still prints "Archived: 0". The Step-2 read (L207) is not caught, so a failure THERE surfaces as a server error — honest, and inconsistent with the archive step. + +### Counts + +- Rows audited: 33 findings + 7 explicitly-honest groups (covering all 31 tools requested plus the write services). +- false-success: 7 — #1 session_close thread gate, #2 session_close scar usage, #3 record_scar_usage_batch, #4 archive_learning, #9 cleanup_threads archive, #11 dismiss_suggestion, #15 absorb_observations (customer stores). +- partial: 20 — #5, #6, #7, #8, #10, #12, #13, #14, #16, #17, #18, #20, #21, #23, #24, #25, #26, #27, #28, #30, #33 (of which read-side "error rendered as empty": #6, #24, #25, #26). +- mismatch / under-report only (no false success): 6 — #19, #22, #29, #31, #32, and the free-tier stored_in contradiction in #28. +- Honest: create_thread, resolve_thread (primary path), create_learning, create_decision, record_scar_usage, save_transcript, recall, prepare_context, analyze, graph_traverse, get_transcript, search_transcripts, search_docs, index_docs (write path), health, session_close session row, sessionCloseFree. +- Category tally: (1) success without evidence — #1,#2,#3,#4,#9,#11,#15; (2) constant/derived-from-input fields — #13 remote_submitted, #21 learnings/scars counts, #28 success:true on free, #31 ok:true skipped; (3) display vs result mismatch — #19,#22,#31; (4) confirmation-before-completion — #2,#5,#13,#15,#23; (5) excluded tools — see paragraphs; (6) session_start/refresh/confirm/reflect/index_docs/save_transcript/feedback/recall — #5-#8,#13,#20,#27,#30, recall honest. + +### Top 10 (by user impact on a customer store) + +1. #1 session_close thread-sync gate is hollow — `all_synced:true` while every thread write failed; local resolved threads pruned, retry state deleted, display "complete". (session-close.ts:1443-1450,1687,1732; thread-supabase.ts:663-691) +2. #2 session_close renders "N scars applied ✓" from its inputs before the fire-and-forget batch runs. (session-close.ts:1606-1611,378-425,1727) +3. #4 archive_learning reports durable success for a UUID that matched no row (PATCH result discarded; full UUID skips lookup). (archive-learning.ts:47-51,133,176) +4. #3 record_scar_usage_batch returns success:true/durable:true with zero rows written when identifiers fail to resolve; effect-tracker counts it as success. (record-scar-usage-batch.ts:198-205; effect-tracker.ts:103-113) +5. #15 absorb_observations says "Absorbed N" and its description says "persist"; on every setup.sql store observations are dropped mid-session (column probe) and again at close (column filter), silently. (absorb-observations.ts:71-80,101; session-columns.ts:74,92,114) +6. #9 cleanup_threads reports "Archived: 0" for store failures and for partial archivals. (cleanup-threads.ts:200-204,116; thread-supabase.ts:767-770) +7. #6 session_start shows "No threads or decisions." and the first-session welcome banner when Supabase is unreachable. (session-start.ts:280-283,1031-1034,1299) +8. #5 session_start prints "active · " before the session row write resolves; a failed row is never reported. (session-start.ts:1149-1152,1300) +9. #24/#25 search and log render any query error as "No results found." / "No entries found." (search.ts:218-230,350-364; log.ts:191-200,283-294) +10. #27 index_docs claims "Chunks embedded: N" but embeddings are never persisted; after restart semantic doc search is silently BM25 and re-index says "up to date". (doc-index.ts:95,435-438; index-docs.ts:139,193) + +--- + +## 5. CLASS C — Schema assumptions audit (gitmem v1.11.0, f93c3a3, branch audit/GIT-113-phase-0) + + +Read-only. Sources: `src/**` (116 .ts), `schema/setup.sql` (665 lines), `tests/fixtures/schema/setup-v1.8.0.sql` (657 lines), `tests/helpers/schema-drift.ts`, `tests/unit/schema-drift.test.ts`, `tests/e2e/blank-supabase.mjs`. +Table names below are resolved with the default prefix (`src/services/tier.ts:160-171`: `GITMEM_TABLE_PREFIX || "gitmem_"`). +Excluded (already reported by the GIT-109 analyzer, `tests/unit/schema-drift.test.ts:44-58`): the 3 probe-guarded columns (`gitmem_learnings.archived_at`, `gitmem_sessions.claude_code_session_id`, `gitmem_sessions.task_observations`) and the 8 dev-tier variant items (`variant_assignments`, `variant_performance_metrics`, `scar_enforcement_variants.{active,description,enforcement_config,...}`). + +### v1.8.0 -> current setup.sql diff (complete) + +`diff tests/fixtures/schema/setup-v1.8.0.sql schema/setup.sql` produces exactly two hunks: + +1. `refresh_scar_behavioral_scores()` body — current adds a predicate (`schema/setup.sql:407-412`): + `AND l.decay_multiplier IS DISTINCT FROM GREATEST(0.1, 1.0 - (us.times_dismissed::FLOAT / us.times_surfaced::FLOAT) * 0.8)` so unchanged rows are no longer rewritten (and no longer get `updated_at = NOW()`). Comment cites GIT-98. +2. `DROP POLICY IF EXISTS "Service role full access"` added before the two `CREATE POLICY` statements on `gitmem_licenses` / `gitmem_license_activations` (`schema/setup.sql:537-544`). Re-applicability only; nTEG-infra tables. + +No table, column, view, index, RLS, trigger or RPC signature differs between the two versions. Every object listed below as "in current" is therefore also "in v1.8.0" unless stated. + +Objects defined by setup.sql (both versions): tables `gitmem_learnings`, `gitmem_sessions`, `gitmem_decisions`, `gitmem_scar_usage`, `gitmem_threads`, `knowledge_triples`, `gitmem_query_metrics`, `scar_enforcement_variants`, `gitmem_licenses`, `gitmem_license_activations`; views `gitmem_learnings_lite`, `gitmem_sessions_lite`, `gitmem_decisions_lite`, `gitmem_threads_lite`; functions `gitmem_semantic_search`, `gitmem_scar_search`, `refresh_scar_behavioral_scores`, `gitmem_update_timestamp` (trigger fn), `gitmem_validate_license`, `gitmem_deactivate_device`; triggers on `gitmem_learnings` and `gitmem_sessions` only; **no storage buckets; no `match_*` functions; no `transcript_chunks`; no `variant_*` tables.** + +### T1 — Tables / views referenced by src/ + +| name (resolved) | how referenced | file:line(s) | in v1.8.0? | in current? | guarded? | reachable on customer store? | finding? | +|---|---|---|---|---|---|---|---| +| gitmem_learnings | `getTableName("learnings")` | create-learning.ts:222; log.ts:233; archive-learning.ts:62,131; analytics.ts:239,272; supabase-client.ts:546,569,589,613; startup.ts:160,250; record-scar-usage-batch.ts:55,67; write-health.ts:180; storage.ts:60,80 (via `getStorage()`); agent-briefing.ts:27 | Y | Y | n/a | yes | none | +| gitmem_learnings | literal `gitmem_learnings` (prefix env ignored) | commands/check.ts:145,452; commands/activate.ts:105 | Y | Y | n/a | yes | LOW: literal name bypasses `GITMEM_TABLE_PREFIX` (diagnostics/activation only) | +| gitmem_learnings_lite | `getTableName("learnings_lite")` | supabase-client.ts:785 (`cachedListWins`) | Y | Y | n/a | **no caller in src/** (`grep cachedListWins` -> definition only) | LOW: dead path; also carries latent `is_active: "eq.true"` through `listRecords`, which prefixes `eq.` again (supabase-client.ts:84-88,108-110) -> `is_active=eq.eq.true` if ever called | +| gitmem_sessions | `getTableName("sessions")` | session-start.ts:444,486,493; session-close.ts:1336,1547,1570; supabase-client.ts:891,917; analytics.ts:97; thread-suggestions.ts:264; absorb-observations.ts:72; agent-briefing.ts:26 | Y | Y | writes filtered by `filterToStoreSessionColumns` (session-close.ts:1548) | yes | none beyond known | +| gitmem_sessions | literal `gitmem_sessions` | activate.ts:105; check.ts:354 (`gitmem_${table}`) | Y | Y | n/a | yes | LOW prefix bypass | +| gitmem_sessions_lite | `getTableName("sessions_lite")` | session-start.ts:192,303; session-close.ts:1216; list-threads.ts:113 | Y | Y | n/a | yes | none | +| gitmem_decisions | `getTableName("decisions")` | create-decision.ts:87; write-health.ts:180; storage.ts (free path only) | Y | Y | n/a | yes | FK on `session_id` unguarded (T6) | +| gitmem_decisions | literal | activate.ts:105; check.ts:354 | Y | Y | n/a | yes | LOW prefix bypass | +| gitmem_decisions_lite | `getTableName("decisions_lite")` | supabase-client.ts:761 (`cachedListDecisions`, called session-start.ts:344) | Y | Y | n/a | yes | none | +| gitmem_scar_usage | `getTableName("scar_usage")` | record-scar-usage.ts:65; record-scar-usage-batch.ts:169; analytics.ts:207; behavioral-decay.ts:102 | Y | Y | n/a | yes | FK on `session_id` unguarded (T6) | +| gitmem_scar_usage | literal | activate.ts:105 | Y | Y | n/a | yes | LOW prefix bypass | +| gitmem_threads | `getTableName("threads")` | thread-supabase.ts:210,240,264,306,519,554,617,657,735,755,825; triple-writer.ts:480; agent-briefing.ts:28 | Y | Y | n/a | yes | FK on `source_session`/`resolved_by_session` unguarded (T6); `updated_at` never maintained (T5) | +| gitmem_threads_lite | `getTableName("threads_lite")` | thread-supabase.ts:337,393,433; cleanup-threads.ts:207 | Y | Y | n/a | yes | none | +| knowledge_triples | literal, unprefixed | triple-writer.ts:309; supabase-client.ts:393; graph-traverse.ts:165,174,261 | Y | Y | n/a | yes | none (setup.sql creates it unprefixed too; `GITMEM_TABLE_PREFIX` never applies) | +| gitmem_query_metrics | literal, unprefixed | metrics.ts:219 (write), 430 (read), 446 (patch), 467 (read) | Y | Y | FK race guarded only for rows this process created (metrics.ts:191-197) | yes | LOW: under a non-default prefix (nTEG `orchestra_`) metrics still target `gitmem_query_metrics` | +| scar_enforcement_variants | literal | variant-assignment.ts:70; variant-generation.ts:322 | Y (different columns) | Y | `hasVariants()` dev-only (tier.ts:113) | no (pro) | known (GIT-106) | +| variant_assignments | literal | variant-assignment.ts:101,140 | N | N | dev-only | no | known | +| variant_performance_metrics | literal | recall.ts:607 | N | N | dev-only + `hasMetrics()` | no | known | +| gitmem_transcript_chunks | `getTableName("transcript_chunks")` raw `fetch` POST with `on_conflict=session_id,chunk_index` | transcript-chunker.ts:296 | N | N | `storeHasTable(...)` (session-close.ts:1479) gates the only caller (`processTranscript` at session-close.ts:1492) | no (probe answers "missing") | none on close path; the probe selects `id` (store-columns.ts:67) — assumes nTEG's table has an `id` column (not verifiable here) | +| community_feedback | literal URL on nTEG project `cjptxyezuxdiinufgrrm` | feedback-remote.ts:9 | N | N | by design (anon INSERT to nTEG) | n/a | INFO: not a customer object; not in setup.sql even as reference | +| gitmem_licenses / gitmem_license_activations | not referenced from src/ directly (RPC only) | setup.sql:510-551 | Y | Y | n/a | created on every customer store | LOW: setup.sql:505-507 says "Users do NOT need to run these" but they are in the file customers run (tables + two SECURITY DEFINER RPCs callable by anon) | + +Free-tier-only collections (`.gitmem/*.json`, never Supabase): `storage.upsert("scar_usage")` session-close.ts:294; `storage.get("sessions")` session-close.ts:246; `storage.query(...)` session-start.ts:532,559 — gated by `!hasSupabase()`. + +Note on `agent-briefing.ts:25-28`: `getStorage()` is `SupabaseStorage` on pro/dev (storage.ts:142-146), so `storage.query("sessions"|"learnings"|"threads")` with no options becomes `directQuery(table, {select:"*"})` with no limit (storage.ts:62-67) — three full-table reads including `embedding` vectors, awaited inside every pro `session_close` (session-close.ts:1681). Tables exist; this is a load/latency observation, not a missing-object one. + +### T2 — RPC / SQL functions + +| name | file:line | in v1.8.0? | in current? | args sent vs SQL params | finding? | +|---|---|---|---|---|---| +| `match_gitmem_learnings_weighted` (`match_${getTableName("learnings")}_weighted`) | supabase-client.ts:693-694 (`scarSearch`), reached via `cachedScarSearch` from recall.ts:557, search.ts:274, prepare-context.ts:359, storage.ts:90 | **N** | **N** | sends `query_embedding` (string `"[...]"`), `match_count`, `match_threshold` (supabase-client.ts:703-712) | **HIGH** — no setup.sql defines it. setup.sql's equivalent is `gitmem_scar_search(query_embedding, match_count, similarity_threshold)` (setup.sql:322-326) — different name, different threshold param name, and a return set lacking `learning_type`, `applies_when`, `source_linear_issue`, `why_this_matters`, `action_protocol`, `self_check_criteria`, `problem_context`, `domain`, `keywords` that recall.ts:100-121 / 648-670 reads. Comment at supabase-client.ts:680-692 (GIT-93) chose the name from "a survey of the deployed functions" — i.e. nTEG's store. Never called otherwise; not probe-guarded. | +| `match_gitmem_learnings` (`match_${getTableName("learnings")}`) | supabase-client.ts:190-191 (`semanticSearch`) | N | N | `query_embedding`, `match_count`, `similarity_threshold` (supabase-client.ts:200-204) | LOW — `semanticSearch` has **no callers in src/** (dead export). setup.sql's equivalent is `gitmem_semantic_search` (setup.sql:287-291) which is never called. | +| `match_transcript_chunks` | search-transcripts.ts:139 | N | N | `query_embedding`, `match_count`, `similarity_threshold`, `filter_project` (search-transcripts.ts:141-146) | MED-LOW — tool is un-advertised on pro (server.ts:321 `hasTranscripts()`), but the dispatch at server.ts:219-223 is not tier-gated, so a pro client that calls `search_transcripts` by name gets PGRST202. | +| `refresh_scar_behavioral_scores` | behavioral-decay.ts:45 (fire-and-forget from session-start.ts:1169-1170) | Y | Y (body differs) | body `{}`; function takes no params; returns `TABLE(scars_updated INT, scars_scanned INT)` — consumed at behavioral-decay.ts:69-72 | **HIGH** (behavioural, v1.8.0 stores) — see T5. | +| `gitmem_validate_license` | license.ts:26 (hard-coded nTEG URL) | Y (reference copy) | Y | `p_api_key`, `p_install_id` — matches setup.sql:557 | none (nTEG infra) | +| `gitmem_deactivate_device` | commands/deactivate.ts:20-21,40 | Y (reference copy) | Y | `p_api_key`, `p_install_id` — matches setup.sql:633 | none (nTEG infra) | +| `gitmem_semantic_search` | — (setup.sql:287) | Y | Y | — | INFO: provisioned on every customer store, never called | +| `gitmem_scar_search` | — (setup.sql:322) | Y | Y | — | INFO: provisioned on every customer store, never called (see HIGH above) | +| `gitmem_update_timestamp` | — (setup.sql:486) | Y | Y | trigger fn | see T5 | + +When the RPC path is taken on a pro store: `isLocalSearchReady()` is `initialized && scars.length > 0` (local-vector-search.ts:203-205, 381-384). It is false (a) from server start until the bulk load finishes, (b) permanently on a store whose indexed learnings set is empty (a fresh store — the first `recall` of a new customer), (c) after a failed bulk load, (d) whenever `GITMEM_SEARCH_MODE=remote` (config.ts:8,71-83; startup.ts:290-299). `scarSearch` calls `embed()` first (supabase-client.ts:673-678) so the 404 only appears when an embedding provider is configured — which is the normal pro configuration. The blank-store e2e (tests/e2e/blank-supabase.mjs) runs `recall` at line 347 against an empty store with a fake embedding server (lines 10-11, 162-214) and fails the run on any `/rest/v1/` 4xx not on `EXPECTED_FAILURES` (line 253; the list at line 81 is empty) — this path is expected to be visible there; not executed in this audit. + +### T3 — Storage buckets + +| bucket | file:line | created by setup.sql? | guarded? | finding? | +|---|---|---|---|---| +| `session-transcripts` (`TRANSCRIPT_BUCKET`) | supabase-client.ts:804; upload `POST /storage/v1/object//` :819; download :849; `saveTranscript` :886; `getTranscript` :926 | **No** — neither setup.sql version contains `storage.buckets`, `storage.objects` or any `INSERT INTO storage` (confirmed by reading both files end to end); no README/docs instruction creates it (`grep -rn session-transcripts README.md docs` -> none; docs/cli-ux-guidelines.md:275 is scar prose) | session_close path: gated transitively by `storeHasTable(gitmem_transcript_chunks)` (session-close.ts:1479) — a **proxy** guard (table presence stands in for bucket presence). `save_transcript` / `get_transcript` tools: only un-advertised on pro (server.ts:321-325), dispatch at server.ts:211-218 is not tier-gated | MED-LOW: a pro client calling `save_transcript` by name gets `Storage upload error: 404 Bucket not found`; `get_transcript` returns "No transcript found" because `transcript_path` is never set on customer stores. Not a silent data loss — surfaced as tool error. | + +No other bucket/path strings exist in src/ (`grep -rn 'storage/v1\|bucket' src` -> only the sites above; `bucket` in cleanup-threads.ts:164,259 is a local variable name). + +### T4 — Row properties read but not provided by either setup.sql + +| table (as read) | property | file:line | in v1.8.0? | in current? | effect | +|---|---|---|---|---|---| +| gitmem_learnings (via RPC result or local index) | `required_verification` | recall.ts:114 (type), 200-201 (`scars.filter(s => s.required_verification?.blocking)`), 660 | N | N | Always `undefined` on a customer store: the "blocking verification gate" branch never fires. Not in `INDEXED_LEARNING_COLUMNS` (supabase-client.ts:530-531) either, so it is also absent from the local index on every store — the feature is dead on all stores unless nTEG's `match_*_weighted` returns it. Silent. | +| gitmem_learnings (via RPC) | `problem_context`, `solution_approach`, `domain`, `keywords`, `why_this_matters`, `action_protocol`, `self_check_criteria`, `learning_type`, `applies_when`, `source_linear_issue` | recall.ts:105-121, 652-665 | Y (columns) | Y | Columns exist, but `gitmem_scar_search` (the only setup.sql scar RPC) returns none of them (setup.sql:327-337) and the RPC actually called does not exist (T2). On the local-index path only `INDEXED_LEARNING_COLUMNS` are present, so `why_this_matters`/`action_protocol`/`self_check_criteria`/`problem_context`/`domain`/`keywords` are `undefined` there too. | +| gitmem_sessions_lite | `decision_count`, `open_thread_count` ("From lite view (counts only)") | session-start.ts:112-113 | N (view lists no such columns, setup.sql:265-269) | N | Declared, never read anywhere in src/ (`grep` -> the two declaration lines only). Stale interface; no runtime effect. | +| gitmem_learnings | `action_protocol`, `self_check_criteria` typed `string[]` | types/index.ts:158-159, 342-343; recall.ts:117-118; schemas/create-learning.ts:32-33 (`z.array(z.string())`) | column is `TEXT` (setup.sql:30-31) | same | Shape mismatch both directions — see T6. | +| gitmem_learnings | `is_starter` | recall.ts:136,459,659; search.ts:51,153 | N | N | Local-only flag (local-file-storage.ts:185-196; stripped on migration migrate-local.ts:30). `undefined` on Supabase rows by design. | +| gitmem_threads / _lite | `ThreadRow.updated_at` | thread-supabase.ts:39 | Y | Y | Column exists but no trigger maintains it (T5) and no PATCH sets it (thread-supabase.ts:252-264, 554-560, 755-757). Value is always `created_at`-time. Read nowhere. | +| gitmem_scar_usage | `scar_title`, `scar_severity` | analytics.ts:117-118 | N | N | Already handled: selected columns exclude them and `enrichScarUsageTitles` fills from learnings (analytics.ts:204-212, 256-297). No effect. | + +All other row interfaces checked resolve to existing columns: `ThreadRow` (thread-supabase.ts:29-48) = `gitmem_threads` / `gitmem_threads_lite` column set; `analytics.ts SessionRecord` :17-29 = `gitmem_sessions`; `RepeatMistakeRecord` :125-130; `KnowledgeTriple` supabase-client.ts:366-375 and `RawTriple` graph-traverse.ts:44-55 = `knowledge_triples`; `LogEntry` log.ts:38-47; `DecisionRecord` session-start.ts:129-135 = `gitmem_decisions_lite`; metrics.ts:426-434 (`id,memories_surfaced,metadata`) = `gitmem_query_metrics`; `loadRecentRapport` columns session-start.ts:304 = `gitmem_sessions_lite`. + +### T5 — setup.sql function bodies, triggers, RLS, views + +| function / object | referenced objects | self-consistent? | differs from v1.8.0 how | +|---|---|---|---| +| `gitmem_semantic_search(query_embedding vector(1536), match_count INT=5, similarity_threshold FLOAT=0.0)` setup.sql:287-317 | `gitmem_learnings` (id, title, description, severity, counter_arguments, embedding) — table name hard-coded, ignores `GITMEM_TABLE_PREFIX` | Yes. No `is_active` filter (returns archived learnings). | Identical. Never called by src/. | +| `gitmem_scar_search(query_embedding, match_count INT=5, similarity_threshold FLOAT=0.0)` setup.sql:322-376 | `gitmem_learnings` (id, title, description, severity, scar_type, counter_arguments, decay_multiplier, embedding, is_active, created_at) — hard-coded | Yes. Declares `decay_days`, `temporal_weight` (setup.sql:341-342) and never uses them (harmless). Temporal decay keyed on `scar_type IN ('process','incident','context')`; the column default is `'operational'` (setup.sql:17) and the code default is `'process'` (create-learning.ts:161) — both fall into "no decay" branches, so no error, but the two defaults disagree. | Identical. Never called by src/ (T2 HIGH). | +| `refresh_scar_behavioral_scores()` setup.sql:383-422 | `gitmem_scar_usage` (scar_id, reference_type, surfaced_at), `gitmem_learnings` (id, decay_multiplier, updated_at, is_active) — hard-coded | Yes. `reference_type IN ('none','refuted')` matches the values code writes (session-close.ts:739-746, 858, 875; record-scar-usage.ts:53). | **v1.8.0 body has no `IS DISTINCT FROM` guard** (current :407-412). On a v1.8.0 store every `session_start` (session-start.ts:1169-1170 -> behavioral-decay.ts:39-58) runs `UPDATE gitmem_learnings SET decay_multiplier = ..., updated_at = NOW()` for every learning with >=3 surfacings in 90 days, even when the multiplier is unchanged. `updated_at` is the GIT-98 cache fingerprint (supabase-client.ts:609-617 `order=updated_at.desc&limit=1`, startup.ts:160-172), so the on-disk vector cache is invalidated on every start and the full embedding download (`loadScarsWithEmbeddings`, ~20 KB/row per supabase-client.ts:566) re-runs each session. This is exactly the defect GIT-98 fixed — but the fix lives in a SQL body customers never re-apply. Also changes the meaning of the returned `scars_updated` (rows touched vs rows changed). | +| `gitmem_update_timestamp()` + triggers setup.sql:486-502 | `NEW.updated_at`; triggers on `gitmem_learnings`, `gitmem_sessions` only | Yes for those two. `gitmem_threads` (setup.sql:184) and `knowledge_triples` (:212) have `updated_at` columns with **no trigger**; `gitmem_decisions`, `gitmem_scar_usage`, `gitmem_query_metrics` have no `updated_at`. Triple writes set it explicitly (triple-writer.ts:324); thread PATCHes do not. archive-learning.ts:130 relies on the learnings trigger (holds). | Identical. | +| Views setup.sql:258-282 | column lists match their base tables | Yes. `gitmem_learnings_lite` omits `why_this_matters`, `action_protocol`, `self_check_criteria`, `repeat_mistake`, `related_scar_id`, `repeat_mistake_details`, `embedding`; `gitmem_sessions_lite` omits `recording_path`, `transcript_path`, `embedding`; `gitmem_threads_lite` omits `embedding` only. No `security_invoker` — views run with owner privileges (RLS on base tables bypassed through the view; only `service_role` is used by the client, so no code impact). | Identical. | +| RLS setup.sql:429-481 | 8 tables, two PERMISSIVE policies each | Policies are OR'd. `"Block anonymous access" ... USING (auth.role() != 'anon')` **grants** full access to every non-anon role (e.g. `authenticated`) rather than blocking anything the first policy did not already allow. Code is unaffected (uses `SUPABASE_SERVICE_ROLE_KEY || SUPABASE_KEY`, supabase-client.ts:121-122; activate.ts:352-356 prompts for the service-role key). Schema fact, not a code drift. | Identical. | +| `gitmem_validate_license` / `gitmem_deactivate_device` setup.sql:557-665 | `gitmem_licenses`, `gitmem_license_activations`; `SECURITY DEFINER` | Yes. | Only the `DROP POLICY IF EXISTS` lines around them differ (:538, :542). These run on every customer store despite setup.sql:505-507. | +| Indexes | `idx_gitmem_learnings_embedding` ivfflat `lists=10` built at setup time on an empty table (setup.sql:55-57) | Valid; index quality is fixed at creation. | Identical. | + +### T6 — Column-type / constraint assumptions + +| file:line | value sent | column type / constraint | risk | +|---|---|---|---| +| archive-learning.ts:62-66 | `filters: { id: "like.%" }` -> `id=like.abcd%` | `gitmem_learnings.id UUID` | **MED-HIGH**: Postgres has no `uuid ~~ unknown` operator and PostgREST does not cast the column, so short-prefix archive on a Supabase store is expected to fail with 42883 (400) on every call; only full-UUID input (archive-learning.ts:49-51) bypasses the query. Unit tests mock `directQuery` (tests/unit/tools/archive-learning.test.ts:122,144); the blank-store e2e does not run `archive_learning`. Verify on the venue. | +| create-learning.ts:120-121 | `action_protocol`, `self_check_criteria` as JSON arrays (`z.array(z.string())`, schemas/create-learning.ts:32-33; definitions.ts) | `TEXT` (setup.sql:30-31) | **MED**: shape mismatch. migrate-local.ts:112-120 documents that arrays into these TEXT columns "prevents PostgREST 400 errors from type mismatches" and joins them with newlines — but only on the migration path; `create_learning` sends the raw array. Either the write 400s (whole learning lost, per that comment) or PostgREST stores the JSON text `["a","b"]`, which recall.ts:117-118 then reads back as a `string` typed `string[]`. Not verified live. Note `hasEnforcementFields()` (tier.ts:153) exists but is not referenced anywhere in src/ — no tier gate on these fields. | +| create-decision.ts:54,87 | `session_id: params.session_id \|\| null` | `gitmem_decisions.session_id UUID REFERENCES gitmem_sessions(id)` (setup.sql:114) | **MED**: no existence check. The session row is created fire-and-forget (session-start.ts:1151, `createSessionRecord` may return `stored:false` at :464-472 "session can be created on close"); a `create_decision` naming that session — or any id not in this store — fails the whole write with 23503. Only `gitmem_query_metrics` has the GIT-73 guard (metrics.ts:191-197). | +| record-scar-usage.ts:41,48; record-scar-usage-batch.ts:132,144 | `session_id` = current session id | `gitmem_scar_usage.session_id UUID REFERENCES gitmem_sessions(id)` (setup.sql:135) | **MED-LOW**: same FK, same unguarded path. On the close path the session upsert (session-close.ts:1550) precedes `recordScarUsageBatch` (:1606), so ordering holds there; mid-session `record_scar_usage` after a failed `createSessionRecord` -> 23503. | +| thread-supabase.ts:145-146 (`threadObjectToRow`), :260 (`resolved_by_session`) | `source_session`, `resolved_by_session` = session ids carried on the thread object | `REFERENCES gitmem_sessions(id)` (setup.sql:176-177) | **MED-LOW**: threads created from a local `threads.json` (free-tier history, or a session whose row never landed) carry ids absent from the store -> 23503 on `createThreadInSupabase` (returns null, thread stays local; thread-supabase.ts:215-218) or on `resolveThreadInSupabase`. | +| metrics.ts:199-215 | `session_id` for a session not created by this process (`sessionRowLanded` returns `undefined`, metrics.ts:165-167) | FK setup.sql:226 | LOW: sent as-is; 23503 if absent (tracked by effect tracker, not fatal). | +| supabase-client.ts:250-253, 313-316, 488-491 | `directQuery`/`directPatch` filter heuristic: any value containing `.` is passed as a raw operator expression | all filtered columns | LOW: a data value with a dot (e.g. a `project` of `"acme.web"`, thread-scope.ts:138 passes it raw) is sent unprefixed and PostgREST rejects the filter (400). `listRecords` (supabase-client.ts:84-88) is the safe variant but is only used at a few sites. | +| create-learning.ts:199; create-decision.ts:71; session-close.ts:1568; thread-supabase.ts:149 | `embedding: JSON.stringify(vector)` (a `"[...]"` string) | `vector(1536)` | OK: pgvector text input accepts `[..]`. transcript-chunker.ts:272,307 sends a raw JSON array (dev-only table). | +| supabase-client.ts:201,704; search-transcripts.ts:142 | `query_embedding: "[..]"` string | `vector(1536)` RPC param | OK (when the function exists). | +| metrics.ts:432 | `or=(session_id.eq.,metadata->>session_id.eq.)` | `metadata JSONB DEFAULT '{}'` (setup.sql:238) | OK on both versions; key is written by metrics.ts:195 so no missing-key assumption. | +| thread-suggestions.ts:268; analytics.ts:94,201,235; log.ts:230; behavioral-decay.ts:105 | `gte.` on DATE, `gte.` on TIMESTAMPTZ | DATE / TIMESTAMPTZ | OK. | +| session-close.ts:545 (retroactive) | `session_date: ` | `session_date DATE` | OK: Postgres date input accepts the ISO datetime and truncates. | +| triple-writer.ts:293-302, :311 | `source_id` non-UUID -> nulled (GIT-105); `predicate` one of 8 values | `source_id UUID`; `predicate` CHECK (setup.sql:199) | OK: every predicate written (`grep predicate: "` -> affects_doc, created_in, created_thread, influenced_by, relates_to_thread, resolves_thread, supersedes) is in the CHECK list. | +| thread-supabase.ts:137-138 | `status` in {emerging,active,cooling,dormant,archived,resolved}; `thread_class` in {operational,backlog} | CHECKs setup.sql:169-170 | OK (thread-vitality.ts:32,50,219-221). | +| create-learning.ts:107,160-183 | `learning_type`, `severity` | CHECKs setup.sql:13,16 | OK (zod-validated; defaults `medium`/`low`/`medium` are in the list). | +| record-scar-usage.ts:53; session-close.ts:739-746,858,875,1042 | `reference_type` | CHECK setup.sql:139 | OK: explicit/acknowledged/refuted/none all listed. | +| create-learning.ts:161 vs setup.sql:17 | `scar_type` default `"process"` (code) vs `'operational'` (column) | TEXT, no CHECK | INFO: no failure; rows written without the code path (e.g. SQL inserts) decay differently in `gitmem_scar_search`. | +| migrate-local.ts:41-68 `KNOWN_COLUMNS` | column allowlist for migrating local JSON -> Supabase | — | OK: the four sets equal the setup.sql column sets exactly (learnings 27, sessions 16, decisions 13, scar_usage 14). | + +### T7 — Runtime probes (what is asked of the store before writing/reading) + +Mechanism: `src/services/store-columns.ts:32-39` issues `GET /rest/v1/?select=&limit=1`; 42703/42P01/PGRST204/PGRST205/"does not exist"/"Could not find the" => "missing"; any other error => "unknown" (not cached, treated as absent for that call). Answers cached per process (`known` map, :24). `storeHasTable(t)` = `supportedColumns(t, ["id"]).has("id")` (:66-68). + +| probe | site | what it guards | +|---|---|---| +| `gitmem_learnings.archived_at` | archive-learning.ts:132 | PATCH payload key (known finding) | +| `gitmem_sessions.task_observations` | absorb-observations.ts:73 | upsert (known finding) | +| `gitmem_sessions.{blocked_by, children, claude_code_session_id, compacted, compacted_at, compacted_summary, handover_linear_slug, insights, metrics, pre_compaction_summary, task_observations}` — only those keys present in the payload | session-columns.ts:104-119 via session-close.ts:1548 | the session-close upsert (known) | +| table `gitmem_transcript_chunks` | session-close.ts:1479 | transcript upload + chunking on close (also stands in for the bucket, T3) | +| tables `gitmem_learnings`, `gitmem_decisions` (select id, limit 1) | write-health.ts:180-190 (startup) | write-path health message only | +| tables `gitmem_learnings`, `gitmem_sessions`, `gitmem_decisions`, `gitmem_scar_usage` (`select=id&limit=0`) | activate.ts:104-126 | `gitmem-mcp activate` schema check — **does not probe** `gitmem_threads`, `knowledge_triples`, `gitmem_query_metrics`, `scar_enforcement_variants`, the 4 views, or any function; a partially-applied setup.sql passes activation | +| `gitmem_learnings` `select=updated_at ... Prefer: count=exact` | supabase-client.ts:609-641 | vector-cache fingerprint (not a schema probe; consumes `updated_at` semantics — see T5 HIGH) | + +nTEG-only objects reached with **no** probe and **no** tier gate: +- RPC `match_gitmem_learnings_weighted` (supabase-client.ts:693) — pro path, T2 HIGH. +- RPC `match_gitmem_learnings` (supabase-client.ts:190) — dead code. +- Column `required_verification` read from search results (recall.ts:114,200,660) — silent `undefined`. +nTEG-only objects reached by name with only an advertising gate (dispatch not gated): bucket `session-transcripts` via `save_transcript`/`get_transcript` (server.ts:211-218 vs :321-325); RPC `match_transcript_chunks` via `search_transcripts` (server.ts:219-223). +Behavioural drift that no probe can detect: the v1.8.0 body of `refresh_scar_behavioral_scores` (T5). + +### T8 — Findings, ranked + +| # | severity | reach | finding | evidence | +|---|---|---|---|---| +| 1 | **HIGH** | every Pro store, every time the local vector index is not ready (server start window, empty store / first recall of a new customer, failed bulk load, `GITMEM_SEARCH_MODE=remote`) | `scarSearch` calls RPC `match_gitmem_learnings_weighted`, which neither setup.sql version defines; setup.sql provisions `gitmem_scar_search` with a different name, parameter (`similarity_threshold` vs `match_threshold`) and a narrower return set. `recall`, `search`, `prepare_context` therefore error with PGRST202 on that path. The GIT-93 rename (CHANGELOG.md:219-223) targeted the function name found on nTEG's deployment, not the one setup.sql ships. Not probe-guarded. | supabase-client.ts:680-712; setup.sql:322-337 (both versions); recall.ts:543-565; search.ts:257-280; prepare-context.ts:359; local-vector-search.ts:203-205,381-384; config.ts:71-83; startup.ts:290-299 | +| 2 | **HIGH** | every v1.8.0 store with any learning surfaced >=3 times in 90 days (i.e. every active customer after a few sessions) | v1.8.0 `refresh_scar_behavioral_scores` rewrites `updated_at = NOW()` on unchanged rows at every `session_start`; `updated_at` is the GIT-98 disk-cache fingerprint, so the on-disk embedding cache is invalidated and the full learnings download re-runs on every start. The fix exists only in the current setup.sql body, which customers never re-apply. The package cannot detect which body a store runs. | setup.sql:402-412 vs setup-v1.8.0.sql:402-407; session-start.ts:1169-1170; behavioral-decay.ts:39-58; supabase-client.ts:598-641; startup.ts:156-172 | +| 3 | MED-HIGH | every Pro store; every `archive_learning` call with a short id prefix | `id=like.%` against a `UUID` primary key — Postgres has no `uuid ~~ text` operator; expected 42883/400 on every prefix archive (full-UUID input bypasses the query). Tests mock the query. | archive-learning.ts:53-66; setup.sql:12; tests/unit/tools/archive-learning.test.ts:122,144 | +| 4 | MED | every Pro store; every `create_learning` that passes `action_protocol` or `self_check_criteria` | JSON arrays written to `TEXT` columns. migrate-local.ts:112-120 records that this produced PostgREST 400s and coerces on migration only; `create_learning` sends the raw array (`hasEnforcementFields()` exists but is unused). Either the whole learning write fails, or JSON text lands in the column and is read back as a non-array. Not verified live. | create-learning.ts:119-121; schemas/create-learning.ts:32-33; types/index.ts:342-343; setup.sql:30-31; recall.ts:117-118; tier.ts:153 | +| 5 | MED | Pro stores; `create_decision` with `session_id`, and `record_scar_usage` mid-session | FK `REFERENCES gitmem_sessions(id)` on `gitmem_decisions.session_id` and `gitmem_scar_usage.session_id` with no row-existence guard; the session row is created fire-and-forget and may not exist (`stored:false`). 23503 fails the whole write. Only `gitmem_query_metrics` has the GIT-73 guard. | create-decision.ts:54,87; record-scar-usage.ts:41,48,65; record-scar-usage-batch.ts:132,144,169; session-start.ts:440-473,1151; metrics.ts:139-178; setup.sql:114,135 | +| 6 | MED-LOW | Pro stores; threads whose `source_session`/`resolved_by_session` is not a row in this store (local history, unlanded session row) | FK 23503 on thread create/resolve; create falls back to local silently (returns null), resolve returns false. | thread-supabase.ts:145-146,209-218,252-270; setup.sql:176-177 | +| 7 | MED-LOW | Pro clients calling un-advertised tools by name | `save_transcript` -> bucket `session-transcripts` does not exist (setup.sql creates no buckets) -> 404; `search_transcripts` -> `match_transcript_chunks` PGRST202. Dispatch is not tier-gated, only the tool listing is. | supabase-client.ts:804-836,886; search-transcripts.ts:139; server.ts:211-223 vs 321-325 | +| 8 | MED-LOW | every store (customer and nTEG unless nTEG's RPC returns it) | `required_verification` is read from search rows but no setup.sql column and no `INDEXED_LEARNING_COLUMNS` entry provides it; the "blocking verification gate" branch never fires. Silent. | recall.ts:114,200-201,660; supabase-client.ts:530-531; setup.sql:11-39 | +| 9 | LOW | nTEG (non-default prefix) and diagnostics | Literal table names bypass `GITMEM_TABLE_PREFIX`: `gitmem_query_metrics` (all 4 metrics sites), `gitmem_learnings`/`gitmem_sessions`/`gitmem_decisions`/`gitmem_scar_usage` in check/activate. Under `orchestra_` metrics still write to `gitmem_query_metrics`. | metrics.ts:219,430,446,467; check.ts:145,354,452; activate.ts:105 | +| 10 | LOW | dead code | `semanticSearch` (RPC `match_gitmem_learnings`, absent everywhere) and `cachedListWins` (`gitmem_learnings_lite`, would send `is_active=eq.eq.true`) have no callers. | supabase-client.ts:168-215, 773-798, 84-88 | +| 11 | LOW | every customer store | setup.sql provisions objects the client never uses (`gitmem_semantic_search`, `gitmem_scar_search`, `gitmem_licenses`, `gitmem_license_activations`, `gitmem_validate_license`, `gitmem_deactivate_device` — the last four despite setup.sql:505-507 saying users need not run them) and omits objects the client does use (`match_*`, bucket, `transcript_chunks`). | setup.sql:287-376, 505-665 | +| 12 | LOW | every customer store | RLS policy `"Block anonymous access" USING (auth.role() != 'anon')` is PERMISSIVE and OR'd with the service-role policy, so it grants full access to any non-anon role rather than restricting. No effect on the client (service-role key), but a schema fact both versions share. | setup.sql:440-481 | +| 13 | LOW | `gitmem-mcp activate` | Schema check probes 4 of 8 tables and no views/functions; a partial setup.sql passes. | activate.ts:104-126 | +| 14 | LOW | every Pro `session_close` | `writeAgentBriefing` performs three `select=*` full-table reads (with embeddings, no limit) on sessions/learnings/threads and is awaited before close returns. | agent-briefing.ts:25-28; storage.ts:49-67,142-146; session-close.ts:1681 | +| 15 | LOW | Pro stores | `directQuery`/`directPatch` treat any filter value containing `.` as an operator expression; a project name with a dot breaks every scoped thread query. | supabase-client.ts:250-253,313-316,488-491; thread-scope.ts:138 | +| 16 | LOW | both versions | `gitmem_threads.updated_at` and `knowledge_triples.updated_at` have no trigger; thread PATCHes never set it. | setup.sql:184,212,494-502; thread-supabase.ts:252-264,554-560,755-757 | +| 17 | INFO | both versions | `scar_type` default mismatch: column `'operational'` vs code `"process"`; `gitmem_scar_search` decays only `incident`/`context`. `session-start.ts:112-113` declares `decision_count`/`open_thread_count` that no view provides (unused). `gitmem_semantic_search` has no `is_active` filter. | setup.sql:17,358-364; create-learning.ts:161; session-start.ts:112-113 | + +Not findings (verified consistent): every CHECK-constrained value the code writes (`learning_type`, `severity`, `reference_type`, thread `status`/`thread_class`, triple `predicate`) is in the corresponding list; `source_id` UUID is guarded (GIT-105); embeddings are sent as `"[...]"` strings which pgvector accepts; `metadata->>session_id` targets a key the code itself writes; migrate-local `KNOWN_COLUMNS` match setup.sql exactly; the `_lite` views contain every column the code selects from them; `ThreadRow`, `analytics.SessionRecord`, `RawTriple`, `KnowledgeTriple`, `LogEntry`, `DecisionRecord` map to existing columns. + +--- + +## 6. Class D — Environment Assumptions (gitmem v1.11.0, f93c3a3, branch audit/GIT-113-phase-0) + + +Read-only sweep of `src/`, `bin/`, `hooks/scripts/`, `scripts/` (tests excluded). Line numbers are exact for f93c3a3. +Empirical checks were run on the audit host (macOS 14.6 / Darwin 23.6, bash 3.2.57, Node 22.17, no `jq`, no `timeout`/`gtimeout`, `pgrep` present, hostname `Chriss-MacBook-Pro-4.local`, `CLAUDE_CODE_ENTRYPOINT=claude-desktop`, `CLAUDE_SESSION_ID` unset in the tool shell, `~/.gitmem` registry holding 2 live sessions from 2 distinct npx-launched server pids). Where a claim rests on such a check it is marked **[verified]**; everything else is from reading the code. + +Severity scale: **Critical** = silently disables or corrupts a shipped feature in a normal customer setup; **High** = wrong behaviour reachable in a common setup; **Medium** = wrong behaviour in a plausible but less common setup; **Low** = cosmetic / theoretical / dev-only. + +--- + +### Category 1 — cwd-relative paths + +| # | file:line | code (short quote) | assumption | breaks when / on which platform | reachable in normal use? | severity | +|---|---|---|---|---|---|---| +| 1.1 | bin/init-wizard.js:25,169,397,440,452,498 | `const cwd = process.cwd(); const gitmemDir = join(cwd, ".gitmem")` | The store the wizard seeds (`learnings.json`, `threads.json`, `config.json`, `closing-payload.json`) is the store the server reads | Since GIT-91 (1.8.0) the server reads `~/.gitmem` (or `GITMEM_DIR`); the wizard writes no `GITMEM_DIR` into the MCP config it generates (buildMcpConfig, :253-268). `npx gitmem-mcp init` therefore seeds `/.gitmem/` which the server never reads, and because it writes `config.json` there, `isLiveGitmemRoot` (gitmem-dir.ts:287) flags it as stranded on every `session_start` | Yes — every fresh CLI-driven install | **Critical** | +| 1.2 | bin/gitmem.js:122-130, 224-236 | `cmdInit`: `join(process.cwd(), ".gitmem")` + writes `config.json` (`project`) | Same as 1.1 for `gitmem init --project X` on the legacy command | `--project` is written to a `config.json` the server never consults (server reads `getConfigProject()` from `~/.gitmem/config.json`, gitmem-dir.ts:355) | Yes | **High** | +| 1.3 | bin/gitmem.js:532-546, bin/init-wizard.js:272,342,373 | hooks copied to `/.gitmem/hooks`, hook commands written as relative `bash .gitmem/hooks/x.sh` | Claude Code / Cursor runs hooks with cwd = project root | Holds for Claude Code (hooks run in project dir). Creates a project-scoped `.gitmem/` dir containing only `hooks/` — not "live", so no warning, but it is the same directory 1.1 writes to, so the two collide | Yes, benign alone | Low | +| 1.4 | hooks/scripts/auto-retrieve-hook.sh:145-159 | `SCRIPT_DIR/../../dist/hooks/quick-retrieve.js`, then `${CLAUDE_PLUGIN_ROOT}/../dist/...`, then `/workspace/gitmem/dist/...`, `$(npm root -g)/gitmem/dist/...`, `.../lib/node_modules/gitmem/dist/...` | Built JS sits two levels above the scripts dir; global package is named `gitmem` | (a) In the copied-hooks layout (1.3) `SCRIPT_DIR=/.gitmem/hooks` → `../../dist` = `/dist` — exists only in the gitmem repo itself. (b) Package name is `gitmem-mcp` (package.json:2), so both global candidates look in a directory that never exists. (c) npx installs live in `~/.npm/_npx//node_modules/gitmem-mcp/` — not a candidate. (d) `/workspace/gitmem` is an nTEG container path. Net: for any customer not using `node_modules/gitmem-mcp/hooks/scripts` directly, quick-retrieve is never found and the hook exits 0 silently (:167-170) | Yes — every `init`-installed customer | **Critical** (compounded by 6.2) | +| 1.5 | hooks/scripts/session-start.sh:195-196 | `[ -f ".gitmem/config.json" ] && jq -r '.project' .gitmem/config.json` | The project-scoped `.gitmem/config.json` in cwd names the session's project | Reads the pre-1.8.0 project-scoped config (or the one 1.1/1.2 just wrote) while the server's own default comes from `~/.gitmem/config.json`. Three project sources disagree (hook cwd config, server home config, `GITMEM_DEFAULT_PROJECT` server.ts:522). Also silently skipped without `jq` (no node fallback here, unlike every other JSON read in the hooks) | Yes | Medium | +| 1.6 | hooks/scripts/session-start.sh:65 | `for MCP_FILE in ".mcp.json" ".claude/mcp.json"` | cwd = project root | Intended; holds under Claude Code | Yes | Low | +| 1.7 | hooks/scripts/resolve-root.sh:37-40 | relative `GITMEM_DIR` → `$PWD/$GITMEM_DIR` | Hook cwd == server cwd | Server does **not** resolve a relative `GITMEM_DIR` at all (gitmem-dir.ts:59-65 returns `envDir` verbatim; every later `path.join` resolves it against the server's cwd at call time). A relative or `~/…` `GITMEM_DIR` yields two different stores (and `~` is expanded by neither) | Only if user sets a relative/`~` `GITMEM_DIR` | Medium | +| 1.8 | src/services/gitmem-dir.ts:223 | `let dir = process.cwd()` (findStrandedProjectRoots) | The MCP server's cwd is the project tree | Under the desktop app / user-scoped `~/.claude.json` servers the server cwd is not the repo; stranded detection then never sees the project's `.gitmem`, so the R18 notice (session-start.ts:966-987) is cwd-dependent while resolution is not | Yes (desktop) | Medium | +| 1.9 | src/commands/migrate-root.ts:163-165, 305 | `findProjectRoots() = findStrandedProjectRoots()` | CLI is run from inside the project | Documented ("above the current directory", :308); fine for a CLI | Yes | Low | +| 1.10 | src/tools/session-close.ts:120-122, 668-670, 135-149 | `const cwd = process.cwd(); … cwdFull.replace(/\//g, "-")` → `~/.claude/projects/` | Server cwd == the cwd Claude Code encoded for the transcript dir | (a) Desktop app / user-scoped server: cwd is not the project → no match → no transcript. (b) Symlinked or `/private/var` vs `/var` cwd: the encoded string differs from Claude Code's own encoding (GIT-107 class, string-only, no realpath). (c) Multi-chat on one project: picks the most recently modified `.jsonl` regardless of which chat is closing (:181-186) — wrong transcript attached / wrong `claude_session_id` extracted | Yes (pro transcript metadata; dev transcript upload) | Medium | +| 1.11 | src/commands/activate.ts:541-548 | `projectRoot = process.cwd()` → append `.gitmem/` to `/.gitignore` | Store is project-scoped | Harmless (only a gitignore entry); leftover from pre-1.8.0 | Yes | Low | +| 1.12 | src/commands/telemetry.ts:19,27 | `join(process.cwd(), "package.json")`, `join(process.cwd(), ".gitmem")` | cwd is the gitmem package AND the store | Version reads as `0.0.0` from any other cwd; telemetry state file goes to `/.gitmem`, not the server's root; bypasses `getGitmemDir()` entirely | `gitmem telemetry` subcommand only | Medium | +| 1.13 | src/hooks/quick-retrieve.ts:154 | `process.argv[1]?.endsWith("quick-retrieve.js")` | Invoked by absolute path | Fine | Yes | Low | +| 1.14 | bin/uninstall.js:23,65-126 | `cwd`-relative `.mcp.json`, `.claude/`, `.gitmem` | cwd = project | Same class as 1.1; uninstall preserves `/.gitmem` (:375) which is not the store the server used | Yes | Low | +| 1.15 | bin/gitmem.js:32,87,110,495; bin/init-wizard.js:24,81-172; src/commands/activate.ts:166-167 | `__dirname` via `fileURLToPath(import.meta.url)` + `..`/`../..` | Package layout identical for global / npx / local | Holds: `bin/` and `dist/commands/` are both one/two levels below package root in all three layouts and `files` (package.json) ships `schema/`, `hooks/`, templates | Yes | Low | + +**Count: 15 sites (2 Critical, 1 High, 5 Medium, 7 Low).** + +--- + +### Category 2 — remaining string path comparisons (no realpath) + +| # | file:line | code (short quote) | assumption | breaks when / on which platform | reachable in normal use? | severity | +|---|---|---|---|---|---|---| +| 2.1 | src/services/gitmem-dir.ts:59-65 | `const envDir = process.env.GITMEM_DIR; … return envDir;` | `GITMEM_DIR` is absolute and canonical | Never `path.resolve`d, never `canonicalPath`'d, no `~` expansion, trailing `/` kept. `findStrandedProjectRoots` (:221-227) does use `sameDirectory`, but every consumer of `getGitmemDir()` (`active-sessions`, `session-state`, `license`, `cache` paths) builds paths from the raw string. Hook side strips a trailing slash and prefixes `$PWD` (resolve-root.sh:37-41) — different normalisation from the server | Only with non-canonical `GITMEM_DIR` | Medium | +| 2.2 | src/services/gitmem-dir.ts:61 | `cachedGitmemDir !== envDir` | – | Only gates a log line | – | Low | +| 2.3 | src/tools/session-close.ts:138-144 | `cwdFull.replace(/\//g, "-")` vs `readdirSync(projectsDir)` names | Claude Code's project-dir encoding is a pure string function of the same cwd string | Symlinks, `/private/var`↔`/var`, case-insensitive APFS (`/Users/Chris` vs `/Users/chris`), trailing slashes — none canonicalised (see 1.10) | Yes | Medium | +| 2.4 | src/services/active-sessions.ts:138, 221, 405 | `a.hostname === b.hostname && a.pid === b.pid` | Not a path, but the same "string equality as identity" class — see Cat 4 | – | – | (Cat 4) | +| 2.5 | src/services/file-lock.ts:88 | `holder.hostname === os.hostname()` | idem | – | – | (Cat 4) | +| 2.6 | src/commands/activate.ts:548 | `line.trim() === ".gitmem/" \|\| line.trim() === ".gitmem"` | gitignore line spelled exactly so | `/.gitmem/`, `.gitmem/**`, `**/.gitmem` not recognised → duplicate append | Yes | Low | +| 2.7 | bin/init-wizard.js:863,883; bin/uninstall.js:386-389 | `content.includes(".gitmem/")` / `line.trim() !== ".gitmem/"` | idem | idem | Yes | Low | +| 2.8 | src/diagnostics/anonymizer.ts:87-88 | `result.includes(".gitmem")` / `indexOf(".gitmem")` | Display-only | – | – | Low | +| 2.9 | hooks/scripts/credential-guard.sh:124 | `grep -qEi '(/\.ssh/\|/\.gnupg/\|/secrets?/)'` on the raw `file_path` | Agent passes an absolute path | `~/.ssh/config` (tilde form) or a relative `.ssh/id_rsa` is not matched by the directory rule; basename rule (:113) still catches `id_rsa`/`id_ed25519` but not e.g. `.ssh/config` or `.ssh/known_hosts` | Yes | Low | +| 2.10 | src/commands/migrate-root.ts:305,322 | `path.resolve(explicit)` then `sameDirectory(source, home)` | – | Correct (GIT-107) — listed for completeness as the only canonicalised comparison outside gitmem-dir.ts | – | – | + +Verified on macOS **[verified]**: `fs.realpathSync.native` normalises both `/var→/private/var` and path case (`/USERS/chriscrawford → /Users/chriscrawford`), so `sameDirectory` is sound where it is used; the defect is that it is used only in stranded-root detection and `migrate-root`. + +**Count: 8 real sites (0 High, 2 Medium, 6 Low) + 2 cross-references.** + +--- + +### Category 3 — tier / licence assumptions + +Tier detection (src/services/tier.ts:27-57): `GITMEM_TIER` env → licence key (env `GITMEM_API_KEY` or `~/.gitmem/config.json.api_key`) with 72 h cache → *optimistic* `pro` before validation → no key but `SUPABASE_URL` (env or config.json) → `pro` (or `dev` if `GITMEM_DEV`) → else `free`. + +| # | file:line | flag / code | what it gates | assumption | breaks when | reachable in normal use? | severity | +|---|---|---|---|---|---|---|---| +| 3.1 | src/services/supabase-client.ts:693 | `rpcName = \`match_${getTableName("learnings")}_weighted\`` (called from storage.ts:88 `SupabaseStorage.search` → recall.ts:443 when `isLocalSearchReady()` is false, and quick-retrieve.ts:71) | Remote scar search on **pro** | The RPC `match_gitmem_learnings_weighted` exists on the customer's project | `schema/setup.sql` provisions only `gitmem_semantic_search` (:287) and `gitmem_scar_search` (:322) — **no `match_*_weighted` function** **[verified by grep]**. The GIT-93 comment (:680-691) says the survey was of *deployed* (nTEG) functions. On every customer store the fallback returns PGRST202 whenever the local vector index is cold (first recall after start, cache miss, flush) | Yes — pro, first recall after every server start | **High** | +| 3.2 | src/services/tier.ts:47-56 | `if (!supabaseUrl) return "free"; … return "pro"` | Entire pro feature set | "backward compat for us" | Any customer who sets `SUPABASE_URL`/`supabase_url` without a licence key runs pro. Not a runtime break, but the licence gate is advisory only | Yes | Low (design) | +| 3.3 | src/services/tier.ts:29-31 | `GITMEM_TIER=dev` accepted from env | Everything dev-only (variants, transcripts, batch tools) | Only nTEG sets it | Any user can set it; dev paths then hit tables absent from setup.sql: `variant_assignments`, `variant_performance_metrics` (recall.ts:607, variant-assignment.ts:101,140), `transcript_chunks` + RPC `match_transcript_chunks` (search-transcripts.ts:139), storage bucket `session-transcripts` (supabase-client.ts:804). Gated tools return errors; nothing corrupts | Only if user opts in | Low | +| 3.4 | src/services/tier.ts:42-44 + server.ts:496-502 | optimistic `"pro"` then async `setTier("free")` on validation failure | Tier at startup | Downgrade re-evaluates lazily everywhere | Singletons built before validation finishes are **not** reset: `storage.ts:137 _storage` (built as `SupabaseStorage` if any tool ran early), `config.ts:135 configInstance`, `startup.ts:42-44` background init already started with `hasSupabase()` true (server.ts:518-523). `resetStorage()`/`reloadConfig()` are never called in production (grep: only tests). After downgrade, `getStorage()` callers (record-scar-usage.ts:74, create-decision.ts:113, create-learning.ts:284, search.ts:142, log.ts:122, recall.ts:443, prepare-context.ts:288, agent-briefing.ts:25, session-start.ts:523, session-close.ts:227, archive-learning.ts:78/146) keep writing/reading Supabase while `writeResult(hasSupabase())` reports "local" | Race: a tool call within ~1 s of startup with an invalid/expired key | Medium | +| 3.5 | src/services/tier.ts:95-97, 100-102, 143-155 | `hasEmbeddings`, `hasCompliance`, `hasAdvancedAgentDetection`, `hasMultiProject`, `hasEnforcementFields` | nothing | – | **Zero callers outside tier.ts** **[verified by grep]** — dead flags; documentation implies gating that does not exist (e.g. "compliance validation dev only" — compliance runs for all tiers in session-close) | – | Low | +| 3.6 | src/services/tier.ts:137-140; src/tools/recall.ts:602 | `hasMetrics()` only used as `hasVariants() && hasMetrics()` | nothing on pro | – | `recordMetrics` (metrics.ts:187) gates on `hasSupabase()`, so metrics are written on pro regardless of `hasMetrics()`; the flag is effectively dead | – | Low | +| 3.7 | src/services/metrics.ts:219,430,446,467 | `"gitmem_query_metrics"` literal | pro metrics | Table name never carries `GITMEM_TABLE_PREFIX` | Under `GITMEM_TABLE_PREFIX=orchestra_` (nTEG's own `.mcp.json` **[verified]**) metrics still target `gitmem_query_metrics`; on customer stores it is in setup.sql:224, so this only bites prefixed deployments | nTEG only | Low | +| 3.8 | src/services/triple-writer.ts:309; variant-*.ts:70,101,140,322; recall.ts:607; supabase-client.ts:804 | `"knowledge_triples"`, `"scar_enforcement_variants"`, `"variant_assignments"`, `"variant_performance_metrics"`, bucket `"session-transcripts"` | pro (triples) / dev (rest) | Unprefixed | `knowledge_triples` is in setup.sql:196 → OK on pro. Variant tables: `scar_enforcement_variants` is in setup.sql:245 but the other two are not (dev-only, gated by 3.3) | pro: OK; dev: nTEG only | Low | +| 3.9 | src/services/behavioral-decay.ts:17-19, 45, 102 | `const SUPABASE_URL = process.env.SUPABASE_URL \|\| ""` (module const, env only) | Credentials are in env | Pro customers configured by `activate` have `supabase_url`/`supabase_key` **only in `config.json`** (license.ts:73-96). `isConfigured()` (supabase-client) is true via `getProConfig()`, so both `refreshBehavioralScores()` (every session_start, session-start.ts:1169) and `fetchDismissalCounts()` (every recall) proceed with `SUPABASE_REST_URL=""` → `fetch("/rpc/refresh_scar_behavioral_scores")` throws `TypeError: Invalid URL` → caught, logged; decay scores never refresh, dismissal hints never appear | Yes — every config.json-configured pro user | **High** | +| 3.10 | src/lib/telemetry.ts:233 | `process.env.SUPABASE_URL ? "pro" : "free"` | idem | Config.json-configured pro users are reported as `free` in telemetry | Yes (if telemetry on) | Low | +| 3.11 | src/tools/search-transcripts.ts:124-127; src/services/transcript-chunker.ts:289-293; src/commands/check.ts:81-85,449-450; bin/gitmem.js:101-102,310; bin/init-wizard.js:254-266 | `process.env.SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` only (no `getProConfig`, no `SUPABASE_KEY` alias) | idem | `gitmem check` reports "Supabase not configured" for config.json pro users; `SUPABASE_KEY` (accepted in license.ts:79 and documented in index.ts:10) is ignored by these sites | Yes (`check`), dev (transcripts) | Medium | +| 3.12 | src/services/tier.ts:160-164; bin/gitmem.js:240; src/services/write-health.ts:175 | `GITMEM_TABLE_PREFIX \|\| "gitmem_"` (three copies) | Prefix agreed across server, CLI `init`, hook children | `.mcp.json env` sets it for the server only; `npx gitmem-mcp init` run from a terminal without it loads starter scars into `gitmem_learnings` while the server reads `orchestra_learnings` | nTEG-style prefixed setups | Low | +| 3.13 | src/tools/definitions.ts:2541-2559 | registration gates `hasBatchOperations`, `hasTranscripts`, `hasCacheManagement`, `hasSupabase` | Tool visibility | Registration ≠ execution: `recordScarUsageBatch` is imported and executed by session-close.ts:38 on every tier; `saveTranscript` path in session-close.ts:1479 gates on a live table probe (`storeHasTable`) rather than `hasTranscripts()` | Fine (probe-gated) | Low | +| 3.14 | src/services/license.ts:26-28 | hardcoded nTEG validation URL + anon key | Licence check | Outbound call to nTEG's Supabase from every pro start (10 s timeout, :173); offline → cache honoured (:216-220) | Yes | Low | +| 3.15 | src/commands/activate.ts:105 | `requiredTables = ["gitmem_learnings", …]` literal | Activation preflight | Ignores `GITMEM_TABLE_PREFIX` | prefixed stores | Low | + +**Count: 15 sites (0 Critical, 2 High, 2 Medium, 11 Low).** No case was found where `pro` unlocks a *write* to an nTEG-only object; the two real breaks are a *read* RPC that setup.sql never creates (3.1) and env-only credential reads that bypass `config.json` (3.9/3.11). + +--- + +### Category 4 — hostname / PID as identity (exhaustive) + +Identity model as implemented: a session is keyed by `(hostname, pid, project)` in the registry (GIT-86) and by `sessions//session.json` on disk (GIT-89); resume/adoption gates on `hostname` equality and on `process.kill(pid, 0)`. + +Empirical context **[verified]**: audit host hostname is `Chriss-MacBook-Pro-4.local` — the `-4` suffix is macOS/Bonjour's automatic rename after a name conflict, i.e. this machine's hostname has already changed at least four times. The registry holds two sessions from two distinct server pids (44778, 44790) — one process per chat here, not one process for all. + +| # | file:line | code (short quote) | assumption | breaks when / on which platform | reachable in normal use? | severity | +|---|---|---|---|---|---|---| +| 4.1 | src/services/active-sessions.ts:320 | `if (typeof data.hostname === "string" && data.hostname !== currentHostname) continue;` | `os.hostname()` is stable for the life of a session | **This is the gate on session resume.** macOS changes `os.hostname()` on network change (`.local`↔`.lan`, `-2/-3/-4` conflict suffixes, sleep/wake on a new Wi-Fi); Docker/WSL restarts can change it; DHCP-assigned Linux hosts too. After a change, every existing `session.json` is skipped: MCP restart recovery (session-state.ts:123) returns null → "No active session"; `session_start` (session-start.ts:754) opens a *new* session; the old one lingers 24 h (pruneStale :465) and, being in the registry, is treated as live by the hooks (see 4.19) | Yes (laptops) | **High** | +| 4.2 | src/services/active-sessions.ts:337 | `hostname: currentHostname` (candidate entry rewritten to current host) | – | Consistent with 4.1; only reached for same-host files | – | – | +| 4.3 | src/services/active-sessions.ts:237-244 | `process.kill(pid, 0)`; `EPERM` ⇒ alive | (a) pids are unique for 24 h, (b) the pid namespace is shared, (c) EPERM only ever means "exists" | (a) pid reuse (macOS wraps at 99999; long-uptime Linux) makes a dead server's session "live foreign" → never adopted, unrecoverable until 24 h. (b) Store on a shared volume between host and container (or two containers): host pids are invisible → `ESRCH` → every foreign session looks dead → **adopted by a process that is not its owner** (GIT-20 violation). `EPERM` on macOS confirmed for pid 1, `ESRCH` for 999999 **[verified]** | (b): Docker bind-mounting `~/.gitmem` | Medium | +| 4.4 | src/services/active-sessions.ts:341 | `pidAlive: pid > 0 && pid !== currentPid && isPidAlive(pid)` | – | idem 4.3 | – | – | +| 4.5 | src/services/active-sessions.ts:351-355 | own-pid fast path: `candidates.filter(c => c.entry.pid === currentPid).sort(newestFirst)[0]` | One session per (process, project) | Called with `project` **undefined** from session-state.ts:123 (`findResumableSessionOnDisk()`), so a single process holding sessions for several projects (the desktop one-process model that GIT-86 describes) recovers the *newest* of its own sessions for **every** chat — the GIT-103 leak by another door | Desktop app single-process mode | **High** | +| 4.6 | src/services/active-sessions.ts:359-370 | adopt at most one dead-pid session; `persistSessionPid` rebinds pid | Only one orphan exists | Two orphaned sessions (two dead servers) → only the newest is adopted; the other stays orphaned until 24 h | MCP server restarted twice | Low | +| 4.7 | src/services/active-sessions.ts:381-391 | `persistSessionPid` read-modify-write of `session.json` without the lock | No concurrent writer of the same session file | `setRecallCalled`/`persistSurfacedScars` (session-state.ts:280,410) write the same file unlocked; a lost update drops `pid` or `surfaced_scars` | Rare race | Low | +| 4.8 | src/services/active-sessions.ts:137-139 | `sameSlot`: `hostname === && pid === && project ===` | – | Hostname drift (4.1) makes the old slot never match → registry accumulates one entry per hostname spelling | Yes | Medium | +| 4.9 | src/services/active-sessions.ts:218-223 | `findSessionByHostPid(hostname, pid, project?)` | – | `project` optional → newest session of the process when omitted (see 4.14, 4.15) | – | – | +| 4.10 | src/services/active-sessions.ts:405 | reconcile no-op if `existing.pid === entry.pid && existing.hostname === entry.hostname` | – | fine | – | – | +| 4.11 | src/services/active-sessions.ts:585-586, 594-595 | legacy migration defaults `hostname: old.hostname \|\| os.hostname()`, `pid: old.pid \|\| process.pid` | The migrating process owns the legacy session | Assigns the current server's pid to a session it did not start — harmless (legacy path, once) | Legacy stores only | Low | +| 4.12 | src/services/active-sessions.ts:35 | tmp file `.active-sessions.${process.pid}.tmp` | pids unique on the filesystem | Two hosts sharing one `~/.gitmem` (NFS home) with equal pids clobber each other's tmp file mid-write | Shared home dirs | Low | +| 4.13 | src/tools/session-start.ts:754 | `findSessionByHostPid(os.hostname(), process.pid, project) ?? findResumableSessionOnDisk(project)` | – | Correct project-scoped form (GIT-86); subject to 4.1 | – | – | +| 4.14 | src/tools/session-start.ts:1347 | `session_refresh`: `findSessionByHostPid(os.hostname(), process.pid, params.project)` with `params.project` optional | – | Without `project`, refreshes whichever session of this process started last | Desktop multi-chat | Medium | +| 4.15 | src/tools/session-close.ts:931-935 | `resolveCurrentSession() ?? findSessionByHostPid(os.hostname(), process.pid)` — **no project** | One session per process | With `session_id` omitted, closes the process's newest session, which in single-process desktop mode may belong to another chat. `resolveCurrentSession()` itself (4.5) has the same bias | Desktop multi-chat, `session_close` without id | **High** | +| 4.16 | src/tools/session-close.ts:1369 | registry fallback requires `mySession.hostname === os.hostname() && mySession.pid === process.pid` | – | After hostname drift the last-resort lookup fails → "Session not found … Was session_start called?" | Yes (with 4.1) | Low | +| 4.17 | src/tools/session-start.ts:821-822, 848-849 | writes `hostname: os.hostname(), pid: process.pid` | – | Source of the values above | – | – | +| 4.18 | src/services/file-lock.ts:71-72, 88 | lock body `{pid, hostname}`; reentrance = `holder.pid === process.pid && holder.hostname === os.hostname()` | Hostname constant within one 30 s critical section | Practically safe (no nested `withLockSync` call exists: `findResumableSessionOnDisk→reconcileRegistryEntry` is not called under the lock; `pruneStale→pruneOrphanedDirs` is inline) | – | Low | +| 4.19 | hooks/scripts/resolve-root.sh:59-87 (esp. :82) | `if [ -n "$pid" ] && [ "$h" = "$host" ] && ! gitmem_pid_alive "$pid"; then continue; fi` — entries from **other** hostnames are kept as live | Foreign-host entries are real remote sessions | After hostname drift (4.1) the stale entries are "foreign" → kept → `session-close-check.sh:43` sees an open session no process can close → Stop hook blocks with the closing ceremony until the 24 h prune (which only the server runs) | Yes (with 4.1) | Medium | +| 4.20 | hooks/scripts/resolve-root.sh:49-54 | `out=$(kill -0 "$1" 2>&1) … case "$out" in *ermitted*)` | bash's EPERM text contains "ermitted" | Localised shells (`LANG=de_DE`, `es_ES`, …) print "Keine Berechtigung"/"Operación no permitida" → EPERM read as dead → another user's live session on the same host is skipped (opposite of the server's EPERM semantics) | Non-English locales, multi-user hosts | Low | +| 4.21 | hooks/scripts/resolve-root.sh:62 | `host=$(hostname 2>/dev/null \|\| echo "")` | `hostname(1)` == `os.hostname()` | Both call `gethostname(2)` — equal on macOS **[verified]**. `hostname` binary absent (some slim images) → `""` → every entry is "foreign" → never liveness-checked | Slim containers | Low | +| 4.22 | hooks/scripts/recall-check.sh:43-44 | `SID=$(gitmem_live_session_ids \| head -n 1)` → `sessions/$SID/session.json` | The newest live registry entry on this host is *this chat's* session | The registry is host-global (`~/.gitmem`), so with two concurrent sessions (two terminals, CLI + desktop, two projects — exactly the state of the audit host **[verified]**) the PreToolUse gate and nag read the *other* session's `surfaced_scars`/`confirmations`. Hooks have no per-chat identity: the hook stdin carries `session_id` but no script parses it | Yes, whenever >1 session is open | **High** | +| 4.23 | hooks/scripts/post-tool-use.sh:27 ; session-close-check.sh:43 | `gitmem_live_session_ids` non-empty ⇒ "a session is open" | Any live session on the host means *this* session is open | Chat B's open session keeps chat A's Stop hook blocking after A closed (guard flag `stop_hook_active` limits it to once per STATE_DIR) | Yes, >1 session | Medium | +| 4.24 | hooks/scripts/session-start.sh:170; recall-check.sh:105; post-tool-use.sh:102; session-close-check.sh:24 | `SESSION_ID="${CLAUDE_SESSION_ID:-$$}"` → `STATE_DIR=/tmp/gitmem-hooks-$SESSION_ID` | `CLAUDE_SESSION_ID` is exported to hooks | It is **not set** in the Claude Code (desktop) tool shell on the audit host **[verified]**, and it is not among the documented hook env vars (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, `CLAUDE_ENV_FILE`). If unset in the hook env, `$$` is the bash pid of *each hook invocation*, so every hook gets a fresh, empty state dir: `tool_call_count` never exceeds 1 (recall nag needs >10, recall-check.sh:191), `start_time` is always "now" (Stop meaningful-duration >300 s never true, session-close-check.sh:73-74), and the Stop hook exits at :48-57 because its own dir does not exist. Stop enforcement and the recall nag would be dead independent of `jq` (GIT-112). `hooks/tests/test-hooks.sh:95` always exports `CLAUDE_SESSION_ID`, so this branch is never exercised by the suite. The hook stdin JSON's `session_id` is never read. **Needs confirmation in a real hook env; if confirmed this is Critical** | Yes | **High** (Critical if confirmed) | +| 4.25 | hooks/scripts/session-start.sh:135-156 | readiness gate: `pgrep -f "gitmem/dist/index.js"` / `pgrep -f "gitmem-mcp"` | The server's argv contains one of these strings | Real server argv on the audit host is `node ~/.npm/_npx//node_modules/.bin/gitmem` — matches **neither** **[verified]**; the gate passes only because the *npx parent shim*'s argv contains `gitmem-mcp` (pids 44647… **[verified]**). With `command: "gitmem"` (global bin → argv `node /usr/local/bin/gitmem`) or `command: "node", args: [".../gitmem-mcp/bin/gitmem.js"]`… no match → full ~7.4 s stall, exceeding the 5000 ms timeout written by `init`/`install-hooks` (bin/gitmem.js:606, bin/init-wizard.js:280, and this repo's own `.claude/settings.json:10` **[verified]**) → SessionStart hook killed → no "call session_start" instruction. Also `pgrep` (procps) is absent from slim images (the CI no-`ps` job never asserts on `pgrep`) → same 7 s stall | Global-bin installs; slim containers | Medium | +| 4.26 | src/services/agent-detection.ts:50,57 | `hostname === process.env.GITMEM_AGENT_HOSTNAME` ⇒ `"autonomous"` | Stable hostname on the autonomous host | Hostname drift flips CODA-1 to `"cli"` | nTEG only | Low | +| 4.27 | src/services/vector-disk-cache.ts:279 | lock body `{pid}` with no liveness check (60 s mtime staleness only, :283-290) | – | Fine; not identity | – | – | +| 4.28 | src/commands/migrate-root.ts:282; src/services/vector-disk-cache.ts:232 | tmp names by pid | as 4.12 | as 4.12 | – | Low | + +**Count: 28 sites (0 Critical, 5 High [4.1, 4.5, 4.15, 4.22, 4.24], 6 Medium, 10 Low, 7 informational).** + +--- + +### Category 5 — single-session / process-wide module globals (GIT-103, exhaustive) + +"Shared across sessions?" = **Yes** when one server process can serve more than one logical session (desktop single-process mode, or any client that reuses a server) and the value is per-session by meaning. + +| # | file:line | global | holds | per-session by meaning? | shared across sessions in one process? | reset available? | severity | +|---|---|---|---|---|---|---|---| +| 5.1 | src/services/session-state.ts:56 | `let currentSession` | id, project, agent, `surfacedScars`, `confirmations`, `reflections`, `observations`, `children`, `threads`, `feedbackSubmitCount`, `recallCalled`, `recallFailure` | **Yes** | **Yes** — last `session_start` wins; `recall`/`confirm_scars`/`reflect_scars`/enforcement (enforcement.ts:96,116,134), `thread-scope.ts:106-109` project scoping, `getSessionActivity`, feedback rate-limit all read it | `setCurrentSession`/`clearCurrentSession` (process-wide, not per-session) | **High** | +| 5.2 | src/services/session-state.ts:60 | `let recoveryAttempted` | latch | Yes | Yes | reset in set/clear | Medium | +| 5.3 | src/services/session-state.ts:75 | `let lastFailedRecoveryFingerprint` | registry mtime sum at last failed recovery | Yes | Yes | never explicitly (overwritten on next attempt) | Low | +| 5.4 | src/services/gitmem-dir.ts:34 | `let cachedGitmemDir` | resolved root | No (per process by design) | n/a | `setGitmemDir`, `clearGitmemDirCache` | Low | +| 5.5 | src/services/gitmem-dir.ts:119 | `let strandedWarningIssued` | once-per-process latch | No | n/a | `clearGitmemDirCache` only | Low | +| 5.6 | src/services/tier.ts:22 | `let _tier` | tier | No | n/a | `setTier`, `resetTier` | (see 3.4) | +| 5.7 | src/services/config.ts:135 | `let configInstance` | tier + search mode + Supabase URL snapshot | No | n/a | `reloadConfig` (never called in prod) | Low (3.4) | +| 5.8 | src/services/storage.ts:137 | `let _storage` | backend chosen from tier at first call | No | n/a | `resetStorage` (never called in prod) | Medium (3.4) | +| 5.9 | src/services/local-file-storage.ts:243 | `let _instance` | `basePath` captured from `getGitmemDir()` at construction | No | n/a | `resetLocalFileStorage` | Low | +| 5.10 | src/services/cache.ts:18-19, 477 | `DEFAULT_CACHE_DIR` (const from `HOME`), `let cacheInstance` | disk cache dir | No | n/a | `resetCache` | Low (see 9.x HOME) | +| 5.11 | src/services/embedding.ts:163 | `let _config` | provider/key snapshot | No | n/a | `resetEmbeddingConfig` | Low | +| 5.12 | src/services/supabase-client.ts:62-67 | `const _proConfig`, `SUPABASE_URL`, `SUPABASE_KEY`, `SUPABASE_REST_URL` | credentials read **once at import** from env/config.json | No | n/a | none (module consts) — `activate` writing config.json is not seen until restart | Low | +| 5.13 | src/services/behavioral-decay.ts:17-19 | `const SUPABASE_URL/KEY/REST_URL` | env-only credentials at import | No | n/a | none | (3.9) | +| 5.14 | src/services/local-vector-search.ts:324 | `const instances: Map` keyed `"__all__"` | unified in-memory vector index | No (cross-project by design, comment :319-322) | n/a | `clearLocalSearch` | Low | +| 5.15 | src/services/doc-index.ts:112 | `let vectorIndex: VectorEntry[]` | in-memory doc vectors for all projects | Partially (project filter applied at query time?) | Yes | `clearDocIndex` | Low | +| 5.16 | src/services/startup.ts:42-44 | `startupCompleted`, `startupPromises` Maps, `backgroundRefreshInterval` | warm-up state, refresh timer | No | n/a | none in prod | Low | +| 5.17 | src/services/write-health.ts:63 | `let lastVerdict` | last write-path verdict | No | n/a | `resetWritePathVerdict` (tests) | Low | +| 5.18 | src/services/metrics.ts:153 | `const pendingSessionRows = new Map>()` | one entry per session ever started in this process, **never removed** (comment :151: "Entries stay after they settle") | Yes (keyed by session) | Grows unbounded in a long-lived multi-session process | `clearPendingSessionRows` (tests) | Low | +| 5.19 | src/services/store-columns.ts:24 | `const known = new Map()` | column-presence probe cache | No | n/a | `resetStoreColumnCache` | Low | +| 5.20 | src/services/effect-tracker.ts:276 | `let instance: EffectTracker` (+ `RingBuffer failures`, `stats`, `startedAt` :123) | fire-and-forget stats, "session uptime" | Yes by meaning (`formatSummary` printed at session close, `uptimeMs` since process start) | Yes — one chat's failures show in another chat's close summary; uptime is process uptime | none | Medium | +| 5.21 | src/lib/telemetry.ts:316 | `let telemetryInstance` | bound to first `(gitmemDir, version)` passed | No | n/a | none | Low | +| 5.22 | src/diagnostics/collector.ts:402 | `let globalCollector` | diagnostics timings | Process-wide by design | n/a | `resetCollector` | Low | +| 5.23 | src/services/timezone.ts:14-15 | `let _timezone`, `let _loaded` | tz from config/TZ | No | n/a | `resetTimezone` | Low | +| 5.24 | src/services/active-sessions.ts:545 | `let migrationRan` | once-per-process | No | n/a | `resetMigrationFlag` | Low | +| 5.25 | src/services/nudge-variants.ts:58,75 | (none — env read per call) | – | – | – | – | – | +| 5.26 | src/tools/session-start.ts:68, src/tools/session-close.ts:450, src/schemas/registry.ts:34,59, src/services/enforcement.ts:33-64, src/services/triple-writer.ts:59,85, thread-dedup.ts:40, thread-vitality.ts:64, agent-detection.ts:87, schemas/common.ts:16,24, closing-questions.ts:133,169, migrate-local.ts:30-116, nudge-variants.ts:18, supabase-client.ts:526-578 | `const` tables/Sets/Records | immutable lookup data | No | n/a | n/a | – | + +**Count: 24 mutable module-level globals; 3 are per-session by meaning and shared (5.1 High, 5.20 Medium, 5.18 Low); 5.2/5.3 are latches over 5.1; 5.6/5.7/5.8 form the tier-downgrade non-reset (3.4). Session-scoped state has no keyed container — every consumer assumes exactly one session per process.** + +--- + +### Category 6 — external tool dependence in hooks / bin / scripts (exhaustive) + +Hook wiring (hooks/hooks/hooks.json): SessionStart→session-start.sh (10 s); UserPromptSubmit→auto-retrieve-hook.sh (3 s); PreToolUse→credential-guard.sh, recall-check.sh (3/5 s); PostToolUse→post-tool-use.sh (3 s); Stop→session-close-check.sh (5 s). `install-hooks`/`init` write the same set but with SessionStart **5000 ms** (bin/gitmem.js:606, bin/init-wizard.js:280). `display-hook.sh` is **wired nowhere** (grep: no references outside itself). + +| # | file:line | tool / construct | assumption | breaks when / on which platform | reachable in normal use? | severity | +|---|---|---|---|---|---|---| +| 6.1 | hooks/scripts/auto-retrieve-hook.sh:174 | `timeout 2.5 node "$QUICK_RETRIEVE" …` | GNU coreutils `timeout` on PATH | **macOS ships no `timeout` (nor `gtimeout` without Homebrew coreutils)** **[verified on audit host]** → "command not found" → `RESULT=""` → `\|\| true` → exit 0. Auto-retrieve is silently dead on every stock macOS install, even when quick-retrieve.js is found | Yes — all macOS users | **Critical** | +| 6.2 | hooks/scripts/auto-retrieve-hook.sh:158-159 | `npm root -g`, `which gitmem` | see 1.4 | wrong package name; also spawns `npm` (slow, ~300 ms+) inside a 3 s hook | Yes | (1.4) | +| 6.3 | hooks/scripts/session-start.sh:145 | `pgrep -f` | procps present; argv pattern matches | see 4.25 (no match for npx/global-bin argv; absent on slim images) → ~7.4 s stall vs 5 s/10 s timeout | Yes | Medium | +| 6.4 | hooks/scripts/session-start.sh:11-16 | `PLUGIN_LOG="/tmp/gitmem-hooks-plugin-debug.log"; echo … >> "$PLUGIN_LOG"` under `set -e` | `/tmp/…debug.log` writable by this user | On a multi-user host the first user's file (umask 022, world-readable) blocks others: `echo` fails → `set -e` aborts the hook at :12 before any output. Also logs the full hook stdin (:16, includes `transcript_path`, `cwd`, `session_id`) to a world-readable file, unbounded, never rotated (only `uninstall-hooks` deletes it, bin/gitmem.js:861) | Multi-user Linux, shared CI | Medium | +| 6.5 | hooks/scripts/*.sh (5 files) ; bin/gitmem.js:838,861 | `/tmp/gitmem-hooks-${SESSION_ID}` hardcoded; `readdirSync("/tmp")` | `/tmp` exists, is shared, `$TMPDIR` ignored | macOS per-user `$TMPDIR` (`/var/folders/…`) is ignored; `/tmp` is sticky so cross-user dirs cannot be cleaned (uninstall swallows EACCES :846-848). Windows-native: no `/tmp` (unsupported anyway) | Yes | Low | +| 6.6 | hooks/scripts/session-start.sh:40-53, recall-check.sh:57-73,80-84,149-156, post-tool-use.sh:38-54, credential-guard.sh:28-44, display-hook.sh:30-40,51-63,123-130, resolve-root.sh:63-73 | `jq` with `node -e` fallback | `node` on PATH in the hook env | Correct degradation (GIT-112). Exception: session-start.sh:195-196 reads `project` **only with jq** — no fallback | Yes (macOS has no jq **[verified]**) | Low (Medium for :195) | +| 6.7 | hooks/scripts/auto-retrieve-hook.sh:41-52,138-140,186 ; resolve-root.sh:67-73 | `node` required | `node` on hook PATH | nvm/volta/fnm users whose `node` is only on an interactive-shell PATH: hooks run with the launcher's PATH; desktop app launched from Finder has no shell rc → `node` missing → auto-retrieve exits 0, resolve-root returns no sessions (Stop enforcement off), JSON parsing yields empty strings (credential-guard allows everything) | Desktop-app / GUI-launched clients with version managers | Medium | +| 6.8 | hooks/scripts/resolve-root.sh:62 | `hostname` | binary present | see 4.21 | slim images | Low | +| 6.9 | hooks/scripts/resolve-root.sh:49-54 | `kill -0` (builtin) + English error text | see 4.20 | non-English locale | Low | +| 6.10 | hooks/scripts/session-start.sh:101-102 | `which gitmem-mcp`, `which gitmem` | `which` present | `which` is debianutils/not POSIX; missing in some minimal images → detection step 5 silently false | slim images | Low | +| 6.11 | hooks/scripts/session-start.sh:125,162 | `date +%s%N` with `\|\| date +%s` fallback | GNU `date` | macOS `date` does not fail on `%N` — prints a literal `N` (`1790129016N` **[verified]**); value only logged, never computed | – | Low | +| 6.12 | hooks/scripts/post-tool-use.sh:106; session-close-check.sh:53,70; recall-check.sh:175; session-start.sh:173 | `date -u +"%Y-%m-%dT%H:%M:%SZ"`, `date +%s` | POSIX | portable | – | – | +| 6.13 | hooks/scripts/session-start.sh:150,154 | `sleep 0.3`, `sleep 0.5` | fractional sleep | GNU/BSD/busybox accept it; strict POSIX `sleep` does not (Solaris) | – | Low | +| 6.14 | recall-check.sh:128,135; post-tool-use.sh:79,86; credential-guard.sh:66-93,113,124; auto-retrieve-hook.sh:74-125 | `grep -qE '…\s…\b…'` | `\s`, `\b` in ERE | GNU extensions; BSD grep on macOS accepts them; busybox grep (Alpine) accepts `\b`/`\s` via libc only partially | Alpine images | Low | +| 6.15 | display-hook.sh:80-81,93 | `awk -v sep="═══ GITMEM_DATA ═══"` and a 51-char `─` regex | byte-wise comparison OK | BWK awk (macOS) and gawk both compare bytes; fine. Script is unwired anyway | – | Low | +| 6.16 | display-hook.sh:111; post-tool-use.sh:109; resolve-root.sh:90 | `sed 's/…/…/'` (no `-i`) | BRE only | Portable; **no `sed -i` anywhere** (good) | – | – | +| 6.17 | all hooks | `#!/bin/bash`, `set -e`, `&>/dev/null`, `<<<`, `${BASH_SOURCE[0]}`, arrays, `$(( ))`, `local` | bash ≥ 3.2 | No `declare -A`, `${var,,}`, `mapfile`, `readarray`, `[[ =~ ]]`, `printf -v`, `${!var}` in hooks → **bash 3.2 clean [verified by grep]**. Invoked as `bash script` so the shebang is irrelevant; `#!/usr/bin/env bash` used only in scripts/ | – | – | +| 6.18 | scripts/release-gate.sh:32,74 | `declare -a`, `${!NAMES[@]}` | bash ≥ 3.0 | fine on 3.2 | dev only | – | +| 6.19 | scripts/venue-schema.sh:42-47 | `psql`, `PGCONNECT_TIMEOUT`, `files+=()` | psql installed | dies with a message; dev/CI only | dev only | Low | +| 6.20 | scripts/release-status.sh:6-9 | `node -p require('./package.json')`, `npm view` (network), `git describe`, `wc -l \| tr -d ' '` | run from repo root | `./package.json` is cwd-relative (no `cd "$(dirname …)"`) | dev only | Low | +| 6.21 | scripts/release-gate.sh:49 | `grep -oE '[0-9]+ passed \([0-9]+\)'` | vitest summary format | cosmetic | dev only | Low | +| 6.22 | scripts/gitmem-admin.mjs:25-33; src/commands/activate.ts:133-142 | `SUPABASE_ACCESS_TOKEN` or `$HOME/.supabase/access-token` (`HOME \|\| USERPROFILE`) | Supabase CLI token location | fine | dev / `activate` | Low | +| 6.23 | bin/gitmem.js:22 (`cpSync`), bin/init-wizard.js:22 & bin/uninstall.js:20 (`readline/promises`) | Node ≥ 16.7 / ≥ 17 | – | fine for engines ≥ 18 | – | – | +| 6.24 | bin/gitmem.js:527,542; bin/init-wizard.js:384 | `chmodSync(…, 0o755)` | POSIX perms | no-op semantics on Windows/some mounts; harmless | – | Low | +| 6.25 | hooks/scripts/session-start.sh:78-79; auto-retrieve-hook.sh:157 | `/home/claude/mcp-config.json`, `/home/node/mcp-config.json`, `/workspace/gitmem/dist/…` | nTEG container layout | harmless probes | – | Low | +| 6.26 | hooks/scripts/credential-guard.sh:117 | interpolates `${FILE_PATH}` unescaped into JSON | path has no `"`/`\` | a path containing a double quote yields invalid JSON → Claude Code treats the hook output as non-JSON (allow) — the RED LINE block silently degrades to allow | Rare | Low | +| 6.27 | hooks/scripts/display-hook.sh (whole file) | – | wired somewhere | Not referenced by hooks.json, init-wizard, or install-hooks → shipped dead code | – | Low | + +**Count: 27 sites (1 Critical [6.1], 3 Medium [6.3, 6.4, 6.7], 21 Low, 2 informational). External binaries actually required for full function: `bash`, `node`, `pgrep`, `timeout`, `hostname`, `which`, `awk`, `sed`, `grep -E`, `tr`, `date`, `kill`; optional: `jq`.** + +### Category 7 — macOS vs Linux (completed by the lead auditor; the sweep was cut off here) + +| # | file:line | code | assumption | breaks when | reachable? | severity | +|---|---|---|---|---|---|---| +| 7.1 | hooks/scripts/auto-retrieve-hook.sh:174 | `RESULT=$(timeout 2.5 node … 2>/dev/null) \|\| true` | GNU `timeout` exists | **stock macOS has neither `timeout` nor `gtimeout`** [verified on audit host]: the call fails, stderr is discarded, `RESULT` is empty, the hook exits as "nothing relevant". Auto-retrieval never injects on a Mac without Homebrew coreutils. The CI no-jq job runs on Linux (which has `timeout`), so it cannot catch this. | yes — every Mac user of the hooks | **Critical** (= 6.1) | +| 7.2 | src/services/active-sessions.ts:320; 137-139; session-close.ts:1369 | `os.hostname()` equality gates resume, the registry slot and the close fallback | hostname is stable | macOS renames on Bonjour conflict / network change (`.local` ↔ `.lan`, `-4` suffix seen on the audit host). A session started under one name is not resumable under the next. | yes on laptops | High (= 4.1) | +| 7.3 | src/services/cache.ts:18-19 | `process.env.GITMEM_CACHE_DIR \|\| (process.env.HOME ? $HOME/.cache/gitmem : "/tmp/gitmem-cache")` | `HOME` set; cache belongs outside the store | A second, independent state root that ignores `GITMEM_DIR` / `GITMEM_HOME`; with `HOME` unset (some service managers, containers) it lands in world-readable `/tmp` shared by all users. | only-if HOME unset | Medium | +| 7.4 | src/services/file-lock.ts:96-105 | stale-lock break by mtime then `unlinkSync` | local POSIX fs | TOCTOU on any fs; on NFS/SMB-mounted homes O_EXCL and mtime are unreliable | only-if network home dir | Low | +| 7.5 | src/services/active-sessions.ts (isPidAlive) | `process.kill(pid, 0)`, EPERM ⇒ alive | POSIX signals | Windows (native, not WSL): `process.kill(pid,0)` semantics differ; package claims no Windows support but nothing refuses to start there | only-if native Windows | Low | + +### Category 8 — Node version (completed by the lead auditor) + +Swept `src/`, `bin/`, `hooks/`, `scripts/` for APIs newer than Node 18.0 (`findLast`, `toSorted`, `toReversed`, `toSpliced`, `.with(`, `import.meta.dirname/filename`, `Object.groupBy`, `Map.groupBy`, `Promise.withResolvers`, `Array.fromAsync`, `isWellFormed`, Set `union/intersection`, `structuredClone`, `using`). **None found** (the only `.union(` hits are zod schema calls). `engines: ">=18.0.0"` (package.json:46-48). `NODE_OPTIONS=--import` is used only by the test venue driver, never at runtime. `fetch` and `AbortSignal.timeout` (used in supabase-client.ts, license.ts) are stable from 18.0/17.3. **No finding.** + +### Category 9 — environment variables (completed by the lead auditor) + +Read in `src/`+`bin/` (count of read sites): SUPABASE_URL(12) SUPABASE_SERVICE_ROLE_KEY(8) GITMEM_CACHE_DIR(8) OPENROUTER_API_KEY(7) HOME(6) OPENAI_API_KEY(5) OLLAMA_URL(4) NO_COLOR(3) GITMEM_TABLE_PREFIX(3) GITMEM_RECORDING_PATH(3) GITMEM_NO_COLOR(3) GITMEM_DIR(3) DEBUG(3) USERPROFILE(2) SUPABASE_KEY(2) GITMEM_OLLAMA_MODEL(2) GITMEM_NUDGE_VARIANT(2) GITMEM_FULL_ALIASES(2) GITMEM_EMBEDDING_DIM(2) GITMEM_DEV(2) GITMEM_API_KEY(2) GITMEM_AGENT_HOSTNAME(2) TZ SUPABASE_ACCESS_TOKEN GITMEM_VECTOR_DISK_CACHE GITMEM_TIER GITMEM_STALE_CHECK GITMEM_SEARCH_MODE GITMEM_HOME GITMEM_EMBEDDING_PROVIDER GITMEM_DEFAULT_PROJECT GITMEM_CACHE_TTL_MINUTES CLAUDE_CODE_ENTRYPOINT (1 each). Hooks read GITMEM_DIR, GITMEM_HOME, HOME, CLAUDE_SESSION_ID, CLAUDE_PLUGIN_ROOT, GITMEM_ENABLED, GITMEM_AUTO_RETRIEVE. + +| # | variable | read by | must agree with | breaks when | severity | +|---|---|---|---|---|---| +| 9.1 | GITMEM_DIR / GITMEM_HOME | server (gitmem-dir.ts), hooks (resolve-root.sh), CLI | all three | The MCP server's env comes from the client's MCP config (`.mcp.json` / desktop config `env` block); hooks inherit Claude Code's own process env. Setting `GITMEM_DIR` only in the MCP `env` block — the documented way to pass server env — leaves the hooks on `~/.gitmem`: registry, payload path and Stop enforcement read a different store (the GIT-99 split, reopened by configuration). | High | +| 9.2 | CLAUDE_SESSION_ID | hooks (state dir key) | Claude Code | unset in practice (verified on the audit host) → `$$` per invocation → per-session state never accumulates (= 4.24) | High | +| 9.3 | SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY vs config.json | server reads env then config.json; `check` reads env only (check.ts) | — | config.json-activated Pro users fail `check`'s Supabase test while the server works (= F) | Medium | +| 9.4 | GITMEM_TABLE_PREFIX | server only | hooks never touch tables | none (hooks don't query the store) | — | +| 9.5 | GITMEM_CACHE_DIR / HOME | cache.ts, check.ts | GITMEM_DIR | independent root (= 7.3) | Medium | + +### Top 10 (class D) + +1. **7.1 / 6.1 — auto-retrieve hook dead on stock macOS** (`timeout` missing, failure discarded). Critical, every Mac hook user. +2. **1.1 — `init` seeds `/.gitmem`, the server reads `~/.gitmem`**: starter scars, config (project) and threads of a fresh install are never read; the next session_start calls the new store "NOT being read". Critical, every new install via the wizard. +3. **4.24 / 9.2 — hooks key state on `$$`** when `CLAUDE_SESSION_ID` is unset: recall nag and Stop-hook meaningful-session detection never accumulate. High. +4. **9.1 — GITMEM_DIR in the MCP env block isn't seen by hooks**: server and hooks on different stores. High. +5. **4.1 / 7.2 — hostname drift makes sessions unresumable** and orphans registry slots on laptops. High. +6. **4.5, 4.15, 4.22** — pid-as-identity in the desktop app's one-process model (see D4 table). High. +7. **3.x — remote recall RPC absent from setup.sql** (also C-1); pro "unlocks" a read path customers can't serve. High. +8. **7.3 / 9.5 — second cache root under `$HOME/.cache` or `/tmp`**, ignoring GITMEM_DIR. Medium. +9. **6.3, 6.4, 6.7** — see category 6 table (tool-dependence mediums). Medium. +10. **9.3 — `check` reads credentials from env only.** Medium. + +--- + +## 7. CLASS E — CONCURRENCY AUDIT (gitmem v1.11.0, f93c3a3, branch audit/GIT-113-phase-0) + + +Read-only audit. All line numbers are exact against the working tree at f93c3a3. Facts only; severity scale: CRITICAL / HIGH / MED / LOW. + +**Execution-model facts that everything below rests on** + +- The MCP SDK (`@modelcontextprotocol/sdk` 1.26.0) dispatches every incoming request with `Promise.resolve().then(() => handler(request, fullExtra))` (`node_modules/@modelcontextprotocol/sdk/dist/esm/shared/protocol.js:355-363`), invoked directly from the transport's `onmessage` (`:237`). Nothing serializes tool calls: two `tools/call` requests arriving on the same stdio transport run as two interleaving async handlers. `src/server.ts:127-469` registers one `CallToolRequestSchema` handler with no queue/mutex. So "tool calls are sequential" holds only when the client sends them one at a time (Claude Code CLI does; the desktop app serving several chats over one process does not). +- All local file I/O in `src/` is synchronous (`fs.*Sync`). Within one process a sync read-modify-write cannot interleave with another sync RMW; it *can* interleave with an async flow that read the same file before an `await`. Across processes there is no serialization except `withLockSync` (registry only) and the O_EXCL download lock (vector cache only). +- The `.gitmem` root is `~/.gitmem` for every process regardless of cwd (`src/services/gitmem-dir.ts:57-96`), so every CLI session, every Cursor window, the desktop app, every bash hook and every `bin/gitmem.js` command share one registry, one `threads.json`, one `closing-payload.json`, one set of free-tier collection files and one `cache/` directory. + +--- + +### 1. Shared mutable module state in `src/` + +| # | file:line | state | writer(s) | reader(s) | race scenario | consequence on the user's data | reachable in normal use? | severity | +|---|---|---|---|---|---|---|---|---| +| 1.1 | `src/services/session-state.ts:56` `currentSession` (one slot per process) | full SessionContext: sessionId, project, surfacedScars, confirmations, reflections, observations, children, threads, recallCalled, recallFailure | `setCurrentSession` :82 (called from `src/tools/session-start.ts:627, 699, 1230, 1456` and `session-state.ts:162`), `clearCurrentSession` :234 (`src/tools/session-close.ts:312, 330, 1677, 1745`), in-place mutation by `addSurfacedScars` :387, `addConfirmations` :469-472, `addReflections` :499-502, `addObservations` :546-549, `addChild` :575, `setThreads` :620, `setRecallCalled` :271, `setRecallFailure` :299, `clearRecallFailure` :326, `resolveThreadInState` :655-658, `incrementFeedbackCount` :643, `getSurfacedScars` :443 (assigns from disk) | every tool via `getCurrentSession`/`resolveCurrentSession`/`getProject`/`getThreads`/`getSurfacedScars`/`getConfirmations`/`getObservations`/`getChildren`; `src/services/enforcement.ts:95`; `src/tools/recall.ts:425, 580`; `src/tools/confirm-scars.ts:168, 184`; `src/tools/session-close.ts:581, 604, 608, 763, 788, 796, 1113, 1178, 1620`; `src/tools/create-thread.ts:134, 312`; `src/tools/resolve-thread.ts:70, 77` | Desktop app = one process, N chats. Chat B `session_start(project: Y)` → `setCurrentSession` overwrites chat A's context (session-start.ts:1230). Chat A's next `recall` reads `getProject()` → Y (recall.ts:425), `addSurfacedScars` → persisted into B's `session.json` (session-state.ts:392-410). Chat A's `session_close(session_id: S_A)` uses S_A for the row (session-close.ts:1331) but `getThreads()`/`getObservations()`/`getChildren()`/`getSurfacedScars()`/`getConfirmations()` from S_B (:581, 604, 608, 763, 788) → S_A's row carries S_B's threads/observations; scar_usage rows for S_B's scars are written under `session_id: S_A` with `reference_type: "none"` (:868-880); then `clearCurrentSession()` (:1677) wipes chat B's in-memory confirmations/reflections/observations (recovery at session-state.ts:162-171 restores only surfaced_scars/threads/recall flags). Same-project second chat instead *resumes* the first chat's session (session-start.ts:753-761) and both chats share one session. The code acknowledges the multi-chat case at session-start.ts:1104-1110 but only for the `force` carry-forward. Also in-process: recall.ts captures `currentSession` at :580, then `await`s variants/triples/dismissals (:595, 632, 642) before `addSurfacedScars` at :688 — the slot can change under it. | Cross-session contamination of scar tracking, thread state, observations, project scope; confirm_scars rejects scars the chat was shown; a closed session's reflection/threads attributed to the wrong row; other chat's enforcement state silently reset. | Yes — desktop app with ≥2 concurrent chats (the deployment the GIT-86 comments describe); any MCP client that issues parallel tool calls. | HIGH | +| 1.2 | `src/services/session-state.ts:60, 75` `recoveryAttempted`, `lastFailedRecoveryFingerprint` | recovery latch | `resolveCurrentSession` :212-214, `setCurrentSession` :93, `clearCurrentSession` :235 | `resolveCurrentSession` :208 | Fingerprint = sum of mtimes of registry + `sessions/` dir (`src/services/active-sessions.ts:73-85`). In-place `writeFileSync` of `session.json` does not change the `sessions/` dir mtime, so a session that gains a `session.json` in an *existing* dir does not change the fingerprint; only dir create/delete/rename and registry rename do. Adoption (`findResumableSessionOnDisk` :359-370) rebinds the newest dead-PID session to this process — two servers started within the same instant after a crash both see the dead PID (see 4(b)). | After a failed recovery the process retries only when an mtime changes; adoption can attach two processes to one session. | Rare (MCP restart with two servers) | LOW | +| 1.3 | `src/services/gitmem-dir.ts:34, 119` `cachedGitmemDir`, `strandedWarningIssued` | resolved root, once-per-process flag | `setGitmemDir` :39 (from `src/tools/session-start.ts:794` with `getGitmemDir()`'s own output), `getGitmemDir` :62 | `getGitmemDir` :69 | Since GIT-91 every path resolves to `GITMEM_DIR` or `~/.gitmem`; the cache can never diverge from the resolver. | None. | — | LOW | +| 1.4 | `src/services/tier.ts:22` `_tier` | cached tier | `getTier` :64, `setTier` :74 (only caller `src/server.ts:502`, asynchronously after license validation) | every `has*()` feature flag; `src/tools/definitions.ts` via `getRegisteredTools()` evaluated once at `src/server.ts:119`; `src/services/config.ts:95` (cached into `configInstance`); `src/services/storage.ts:144` (cached into `_storage`) | Server starts optimistic `pro` (`tier.ts:44`), registers pro tools, starts background cache init, and may already have served `session_start` on the pro path (Supabase row created, session-start.ts:1149) before `validateLicense` resolves invalid → `setTier("free")`. From then on `hasSupabase()` is false but: tool list stays pro; `configInstance.resolvedSearchMode` stays `local` so the 15-min periodic refresh keeps downloading from Supabase (`src/services/startup.ts:783-815`); `session_close` routes to `sessionCloseFree` (session-close.ts:1200) and writes to `~/.gitmem/sessions.json`; the Supabase row is never closed; `recall` takes the free BM25 path over a (usually empty) `learnings.json` (recall.ts:440). `validateLicense` returns `valid:false` when the license server is unreachable **and** `license-cache.json` is missing or older than 72 h (`src/services/license.ts:212-223`). | Session opened in Supabase, closed locally; learnings/decisions after the flip go to local JSON while reads before it came from Supabase; user is told only on stderr (`src/services/write-health.ts:160-168`). | Yes — offline/VPN start after 72 h without validation; license endpoint outage. | HIGH | +| 1.5 | `src/services/config.ts:135` `configInstance` | tier + resolvedSearchMode snapshot | `getConfig` :141-144 (first call is `startBackgroundInit` at `src/server.ts:523`) | `shouldUseLocalSearch` :158, `startup.ts:285, 398, 464, 512, 660, 716, 760` | Frozen at pro before license validation (see 1.4). | Periodic Supabase refresh continues after downgrade; `cache_status` reports `local`. | Yes (with 1.4) | MED | +| 1.6 | `src/services/storage.ts:137` `_storage` | backend singleton | `getStorage` :143-146 | all free-tier tool paths, `src/services/agent-briefing.ts:25`, `src/hooks/quick-retrieve.ts` | Chosen by `hasSupabase()` at first call; never re-chosen after `setTier`. If first call precedes the downgrade the process keeps `SupabaseStorage` while every `hasSupabase()` gate says free. | Mixed-backend session. | Yes (with 1.4, if any free-path code ran early — e.g. `writeAgentBriefing`) | MED | +| 1.7 | `src/services/startup.ts:42-44` `startupCompleted`, `startupPromises`, `backgroundRefreshInterval` | init state | `ensureInitialized` :370-376, `startPeriodicRefresh` :795-814 | `ensureInitialized` :359-365, `getInitStatus` :400 | `startBackgroundInit` (`src/server.ts:523`) calls `initializeGitMem()` **without** recording it in `startupPromises` (:770). The first `session_start` calls `ensureInitialized` (session-start.ts:1163) → sees no promise → launches a second `initializeGitMem()` concurrently in the same process. Second call's `tryAcquireDownloadLock` finds its own process's lock (no same-pid reentrance in `src/services/vector-disk-cache.ts:276-294`) → `waitForLeaderCache` polls up to 25 s. If the first init failed (`loadScarsFromSupabase` catch returns `scars: []`, startup.ts:225-227) → `initializeLocalSearch([])` sets `initialized=true` with 0 scars (`src/services/local-vector-search.ts:175-179`); the second init's good rows are then **dropped** by `initialize()`'s early return (:119). | Empty in-memory index for the life of the process after one transient failure at startup; all recalls fall back to remote search; `ensureInitialized` never retries because `startupPromises` now holds a settled promise (:363-365). | Yes on flaky network at startup | MED | +| 1.8 | `src/services/local-vector-search.ts:94-96, 324` `LocalVectorSearch.scars/initialized/initPromise`, `instances` | in-memory vector index | `initialize` :118-124, `reinitialize` :129-137 (from `flushCache` startup.ts:680, periodic refresh :805), `clear` :284 | `search` :219-231, `isReady` :203, `isLocalSearchReady` :381 (`src/tools/recall.ts:543`, `src/hooks/quick-retrieve.ts`) | Periodic refresh every `GITMEM_CACHE_TTL_MINUTES` (default 15) calls `flushCache` → `loadScarsFromSupabase({bypassDiskCache:true})`. A network failure there is caught and returns `scars: []` (startup.ts:225-227); `flushCache` then `reinitializeLocalSearch([])` (:680) → index emptied, and `persistScarsForHooks([])` (:683) overwrites `~/.gitmem/cache/hook-scars.json` with `[]` (startup.ts:80). `flushCache` still returns `success: true, new_scar_count: 0` (:690-695). A `recall` whose `await this._embed(query)` (:228) spans the refresh reads the emptied `this.scars` at :231 and returns `[]` → "No relevant scars found". | Silent loss of local recall + hook auto-retrieve until the next successful refresh (≥15 min) or process restart; the disk vector cache survives (never written empty, :220) but is not reloaded. | Yes — any transient Supabase/network error during a long session | HIGH | +| 1.9 | `src/services/embedding.ts:163` `_config` | provider + API key | `getEmbeddingConfig` :169-170 | `embed` :259, `isEmbeddingAvailable` :302 | Read once from env + `config.json`; `activate`/`deactivate` rewriting `config.json` while a server runs is invisible to it. | Stale key until restart. | Rare | LOW | +| 1.10 | `src/services/effect-tracker.ts:276` `instance`; `EffectTracker.stats/failures` :122-123 | fire-and-forget accounting | `track` :143-176 | `getHealthReport` :183 (health tool `src/server.ts:372`; `formatCloseDisplay` `src/tools/session-close.ts:440`) | Process-lifetime, not session-scoped: in the desktop app one chat's close warnings include other chats' failures. `formatCloseDisplay` runs at :1727 after `track(...)` calls at :1557, :1606 have incremented `attempted` but before their promises settle, so failures of this close's own embedding/scar_usage/transcript writes are not in the display. | Close display says "session content stored OK" while its own deferred writes may still fail; unrelated failures can appear. | Yes | LOW | +| 1.11 | `src/services/metrics.ts:153` `pendingSessionRows` | Map sessionId→Promise | `registerPendingSessionRow` :156 (session-start.ts:1149); never deleted (by design, comment :151) | `sessionRowLanded` :165 (only `recordMetrics`) | Only metrics wait on it; the `session_close` upsert (session-close.ts:1550) does not (see 5.1). Grows by one entry per `session_start` for the life of a desktop process. | Unbounded growth (small). | Yes | LOW | +| 1.12 | `src/services/write-health.ts:63` `lastVerdict` | last verdict | `record` :110 from both the timeout (:132) and the late completion (:122) | `getLastWritePathVerdict` :66 (health tool) | Late verdict replaces `timed_out` — intended and flagged `late: true`. `checkWritePath` never throws (:217-224). Runs *after* `setTier("free")` in the license path (`src/server.ts:502-508`) so its "free_with_credentials" warning is stderr-only. | None beyond 1.4's invisibility. | — | LOW | +| 1.13 | `src/services/store-columns.ts:24` `known` | column-probe cache | `supportedColumns` :51, :56 | :46, :62 | No in-flight dedup: two concurrent `session_close`/`create_learning` calls probe the same table twice. Never invalidated on `setTier`. | Extra probes only. | Yes | LOW | +| 1.14 | `src/services/active-sessions.ts:545` `migrationRan` | once-per-process flag | `migrateFromLegacy` :560 | :559 | Guarded by `withLockSync`; second process sees new registry and skips. | None. | — | LOW | +| 1.15 | `src/services/cache.ts:477` `cacheInstance`; `CacheService.hits/misses/enabled` :72-76 | result-cache stats | `getResult` :147, :163, :168; `disable` :112 | `getStats` :456 | Process-local counters; the files under `~/.cache/gitmem/results` are shared by all processes and written with a plain `writeFileSync` (:210, see 2). | None (stats only). | — | LOW | +| 1.16 | `src/services/doc-index.ts:112` `vectorIndex` | in-memory doc vectors | `index_docs` path :231, :447 | search :257, :286, `getIndexStats` :397 | One index per process for all projects/chats; `index_docs` RMW of `docs-index.json` (:149→:219) spans embedding `await`s. | Concurrent `index_docs` calls lose chunks (see 3.J). | Rare | LOW | +| 1.17 | `src/lib/telemetry.ts:316` `telemetryInstance`; `Telemetry.config` :45 | opt-in state | `enable`/`disable` :132-153 (CLI `src/commands/telemetry.ts:139-157`), `loadConfig` :58 | `track` :167 | CLI-only writer; server only reads/appends. | None. | — | LOW | +| 1.18 | `src/services/timezone.ts:14-15` `_timezone/_loaded` | tz | `loadTimezone` (once) | display formatting | Read once from `config.json`. | Stale display tz after a config edit. | Rare | LOW | +| 1.19 | `src/services/supabase-client.ts:62-67` `SUPABASE_URL/KEY/REST_URL` (module-load constants from env + `config.json`) | credentials | module load only | every REST call | `activate` writing new creds into `config.json` does not reach a running server; `behavioral-decay.ts:17-19` reads **env only**, so a `config.json`-based pro install calls `"/rpc/refresh_scar_behavioral_scores"` with an empty base URL and fails silently every `session_start` (:60-63 → :76-79). | Behavioral decay never refreshes for config.json-configured installs. | Yes (config.json-based pro) | LOW (not a race; noted because it is shared-state derived) | +| 1.20 | `src/diagnostics/collector.ts:402` `globalCollector` | diagnostics singleton | `getCollector` | `check` command | CLI-only. | None. | — | LOW | + +--- + +### 2. File writes — atomic vs direct overwrite vs append + +Legend: **A** = temp file + `rename` (reader sees old or new, never partial); **D** = direct `writeFileSync` (truncate-then-write; a crash or a concurrent reader mid-write yields a truncated file); **App** = `appendFileSync` / shell `>>`. + +#### 2.1 Every write site + +| # | file:line | file written | class | reader's behaviour on truncated/invalid JSON | +|---|---|---|---|---| +| 2.1 | `src/services/active-sessions.ts:33-48` `atomicWriteFileSync` (used by `writeRegistry` :124 and `persistSessionPid` :386) | `active-sessions.json`; `sessions//session.json` (pid rebind only) | **A** — but falls back to a **D** write on any rename error (:39-47) | `readRegistry` :95-113: parse/Zod failure → `{sessions: []}` "starting fresh" (:107-111) → next `registerSession` writes a registry containing only itself; every other live session's entry is dropped (consequence in 4(f)). | +| 2.2 | `src/services/vector-disk-cache.ts:242-243` | `cache/learnings-vectors-.json` | **A** (tmp name includes pid + `Date.now()`) | `readCacheFile` :207-217 → miss with reason; `deltaSyncFromCache` returns null → full download. Safe. | +| 2.3 | `src/commands/migrate-root.ts:283-284` | `learnings.json`, `threads.json`, `decisions.json`, `sessions.json` (merged) | **A** | n/a | +| 2.4 | `src/commands/migrate-root.ts:287` | `migrate-root-conflicts.json` | **D** | `loadConflicts` :217-224 → `[]` → previously logged conflicts are re-logged (duplicate entries), nothing lost. | +| 2.5 | `src/tools/session-start.ts:834` (`writeSessionFiles`, whole-object replace), `:667`, `:1307`, `:1500` (display RMW) | `sessions//session.json` | **D** | `session-state.ts:130` recover → throws → catch :192 → `null` → "No active session"; `active-sessions.ts:309-311` → skipped as not resumable; `pruneStale` :450 sees the file exists → **not** pruned; hooks `recall-check.sh:81-83` `jq ... \|\| echo "0"` → both counts 0 → confirmation gate bypassed and nag armed. | +| 2.6 | `src/services/session-state.ts:280, 307, 334, 410` (`setRecallCalled`, `setRecallFailure`, `clearRecallFailure`, `persistSurfacedScars`) | `session.json` | **D** (RMW) | as 2.5; on parse failure each writer catches (:281, 308, 335, 411) and keeps state in memory only. | +| 2.7 | `src/tools/confirm-scars.ts:154`; `src/tools/reflect-scars.ts:132` | `session.json` | **D** (RMW) | as 2.5; catch → memory only (:156, :134). | +| 2.8 | `src/services/active-sessions.ts:583` (`migrateFromLegacy`) | `sessions//session.json` | **D** (inside registry lock) | as 2.5 | +| 2.9 | `src/services/thread-manager.ts:271` `saveThreadsFile` | `threads.json` | **D** (whole array) | `loadThreadsFile` :249-260 → `[]` on any error. Next writer (session_start :862-894 merge, create_thread :319-321 push, resolve_thread :196) saves *only what it holds* → **every thread not in that process's memory is gone**. On free tier `threads.json` is the source of truth. | +| 2.10 | `src/services/thread-suggestions.ts:213` `saveSuggestions` | `suggested-threads.json` (the task brief calls it `thread-suggestions.json`; the on-disk name is `suggested-threads.json`, :32) | **D** | `loadSuggestions` :188-201 → `[]`; next `saveSuggestions` (session-close.ts:1593, promote :97, dismiss :66) replaces the file with the truncated view → all pending suggestions lost. | +| 2.11 | `src/services/local-file-storage.ts:66` (`writeCollection`) and `:62` (10 MB eviction path) | `learnings.json`, `sessions.json`, `decisions.json`, `scar_usage.json` (free tier SOT) | **D** (whole collection, pretty-printed; :54 warns at >1 MB, so multi-MB files are expected) | `readCollection` :37-47 → `[]` "starting fresh" (:44). The very next `upsert` (:132-141) or `delete` (:148-153) **writes a collection containing only the new record** — the whole store is overwritten. A crash between truncate and write of a 2 MB `learnings.json`, or a second free-tier process reading during the write, destroys every learning. Also `loadStarterScars` :217-231 re-adds the starter scars on top. | +| 2.12 | `src/services/doc-index.ts:102` `writeLocalIndex` | `docs-index.json` | **D** | `readLocalIndex` :72-82 → `[]` "starting fresh" → next `index_docs` writes only the re-indexed files; `clearDocIndex(project)` :444-452 writes `[]`-based subset. | +| 2.13 | `src/services/startup.ts:80` `persistScarsForHooks` (+ `chmodSync` :81) | `cache/hook-scars.json` | **D**; written by **every** server process at init and on every periodic refresh/flush (:321, :683) | `src/hooks/quick-retrieve.ts:100-101` parse → throws → outer catch → hook returns nothing (fail-open). Transient. | +| 2.14 | `src/services/cache.ts:210` `writeResult` | `~/.cache/gitmem/results/.json` | **D**; shared by all processes, key = hash(query)+project | `getResult` :172-176 → miss; `cleanup` :376-384 deletes corrupt files. Safe. | +| 2.15 | `src/services/license.ts:132` `writeLicenseCache` | `license-cache.json` | **D** | `readLicenseCache` :117 → `null` → tier optimistic pro, revalidate. Safe. | +| 2.16 | `src/services/agent-briefing.ts:83` | `agent-briefing.md` | **D** (markdown, not parsed by gitmem) | n/a | +| 2.17 | `src/tools/contribute-feedback.ts:105` | `feedback/--.json` | **D** but unique new file per call | n/a | +| 2.18 | `src/lib/telemetry.ts:112` (`saveConfig`), `:291` (`clearLog` truncates) | `telemetry.json`, `telemetry.log` | **D** | `loadConfig` :89-96 → resets to disabled. Safe. | +| 2.19 | `src/lib/telemetry.ts:200` | `telemetry.log` | **App** | line-oriented; `getRecentEvents` tolerates garbage lines. | +| 2.20 | `src/commands/migrate-local.ts:266` | `migration.log` | **D** (new content per run) | n/a | +| 2.21 | `src/commands/activate.ts:534`, `src/commands/deactivate.ts:98`, `bin/gitmem.js:132, 143, 234`, `bin/init-wizard.js:241` (`writeJson`, used at :444, :448) | `config.json` (`activate`/`deactivate` write `getGitmemDir()`; `bin/gitmem.js` `cmdInit`/`init-scars` writes **`process.cwd()/.gitmem`** :122, :224 — a project root the server no longer reads) | **D** | Server readers `gitmem-dir.ts:355-396`, `license.ts:49-96`, `timezone.ts` all `catch → null/""`. A server that starts while `config.json` is truncated resolves **no license key and no Supabase URL → free tier** (`tier.ts:35-49`) silently. | +| 2.22 | `src/commands/activate.ts:550` | project `.gitignore` | **App** | n/a | +| 2.23 | `bin/init-wizard.js:454` | `closing-payload.json` **template with empty reflection fields** (written once if absent) | **D** | `session-close.ts:984-992` consumes it as a real payload: `payloadConsumed = true`, `closing_reflection` = all-empty strings → validation (not concurrency) — noted because it seeds the shared file discussed in 3.F. | +| 2.24 | `bin/init-wizard.js:495, 500, 514`; `bin/gitmem.js:174, 180` | `learnings.json`, `threads.json`, `sessions.json` etc. | **D** (init-time RMW of learnings) | as 2.11 if a server is live during `init`. | +| 2.25 | `bin/gitmem.js:200, 292, 586, 660, 785, 807, 817`; `bin/init-wizard.js:565, 600, 703`; `bin/uninstall.js:153, 198, 390` | `.claude/settings.json`, `.cursor/hooks.json`, `.mcp.json`, `.gitignore` | **D** | Claude Code's own files; outside gitmem's readers. | +| 2.26 | `src/commands/check.ts:538` | `diagnostic-.json` | **D**, unique name | n/a | +| 2.27 | `hooks/scripts/session-start.sh:173-175`, `recall-check.sh:115, 201`, `session-close-check.sh:53-54, 95` | `/tmp/gitmem-hooks-/{start_time,tool_call_count,last_nag_time,stop_hook_active}` | **D** (shell `>`), `touch` | `cat` of a truncated count → empty → `$((TOOL_COUNT + 1))` with empty = 1; harmless. | +| 2.28 | `hooks/scripts/post-tool-use.sh:111` | `/tmp/gitmem-hooks-/audit.jsonl` | **App** | n/a | +| 2.29 | `hooks/scripts/session-start.sh:12, 16, 131…` | `/tmp/gitmem-hooks-plugin-debug.log` (world-shared path, all users/sessions) | **App** | n/a; logs full hook stdin (`:16`) including any prompt text Claude Code passes. | +| 2.30 | `src/tools/session-close.ts:1695` | `closing-payload.json` | **unlink** | see 3.F | +| 2.31 | `src/tools/session-close.ts:505`; `src/services/active-sessions.ts:515, 535`; `hooks/scripts/session-close-check.sh:110`; `bin/uninstall.js:369, 377`; `bin/gitmem.js:831, 844, 863` | `rmSync` / `rm -rf` of session dirs, `/tmp` state, whole `~/.gitmem` (`uninstall --all`) | delete | see 4(f) | + +#### 2.2 Counts + +- Atomic temp+rename: **3 helpers** (`atomicWriteFileSync`, `writeVectorCache`, `migrate-root` merged files) covering 2 JSON stores (`active-sessions.json`, `learnings-vectors-*.json`) plus one-off migration output. +- Direct-overwrite writes of **JSON stores the server later parses**: `session.json` (11 sites), `threads.json` (1 helper, 4 callers), `suggested-threads.json` (1 helper, 3 callers), free-tier collections (1 helper, all free-tier writes), `docs-index.json`, `hook-scars.json`, `license-cache.json`, `config.json` (6 CLI sites), `~/.cache/gitmem/results/*`, `telemetry.json`, `migrate-root-conflicts.json` — **~30 sites, 11 distinct stores**. +- Readers that respond to a truncated store by **silently starting from empty and then overwriting it on the next write**: `local-file-storage.ts:44` (4 collections), `thread-manager.ts:257`, `thread-suggestions.ts:198`, `active-sessions.ts:107-111`, `doc-index.ts:79`. These are the loss amplifiers: a one-time partial read becomes a permanent overwrite. + +--- + +### 3. Lock-free read→modify→write on JSON stores + +`withLockSync` (`src/services/file-lock.ts:145`) is used at exactly five sites, all on `active-sessions.json`: `registerSession` (`active-sessions.ts:150`), `unregisterSession` (:182), `reconcileRegistryEntry` (:402), `pruneStale` (:441), `migrateFromLegacy` (:563). Every other RMW below runs unlocked. + +| # | file:line (read → write) | store | interleaving processes / paths | what is lost | reachable? | severity | +|---|---|---|---|---|---|---| +| 3.A | `src/tools/session-start.ts:862` `loadThreadsFile()` → `:894` `saveThreadsFile(merged)`; `src/tools/create-thread.ts:319-321` load→push→save; `src/tools/resolve-thread.ts:70-73` (in-memory session array **or** file) → `:196` `saveThreadsFile(threads)`; `src/tools/session-close.ts:1450` `saveThreadsFile(openThreadsOnly)` where `closeThreads` derives from `getThreads()` + params (:581-590, :1414), **never from the file** | `threads.json` | (i) Two CLI windows: P1 `session_start` reads at t0; P2 `create_thread` reads+writes at t1; P1 saves its t0-based merge at t2 → P2's thread gone. (ii) `resolve_thread` in a session whose state has threads writes the **session array** back (:196), replacing every file entry the session never held — including threads created by another process since this session started. (iii) `session_close` prune (:1450) writes the close-time array from memory; anything another process added to the file after this session's `session_start` is dropped. (iv) Desktop: chat B's `session_start` `saveThreadsFile(merged)` while chat A's `create_thread` sits between its `await createThreadInSupabase` (:300) and its `loadThreadsFile()` (:319) — fine; but A's `resolve_thread` writes A-session array over B's merge. | Pro tier: cache loss only (Supabase is SOT; `list_threads` reads Supabase). Free tier: `threads.json` **is** the store; a thread that reaches only the file is lost unless its creating session closes and the thread lands in that session's `sessions.json` record (`aggregateThreads` re-derives from closed sessions ≤14 days, `thread-manager.ts:141-152`). | Yes — two CLI windows or CLI + desktop on the same machine (normal for this product) | MED (free tier HIGH) | +| 3.B | `src/services/session-state.ts:277→280, 305→307, 331→334, 408→410`; `src/tools/confirm-scars.ts:148→154`; `src/tools/reflect-scars.ts:130→132`; `src/tools/session-start.ts:664→667, 1305→1307, 1498→1500` (display), `:805-834` (`writeSessionFiles`: reads only `started_at`/`recall_called`, then **replaces the whole object**); `src/services/active-sessions.ts:384→386` `persistSessionPid` (unlocked, called at :368 before the lock at :369) | `sessions//session.json` | Per-session file, so cross-process interleaving needs two processes bound to one session: (i) adoption of the same dead-PID session by two restarting servers (4(b)); (ii) `bin/gitmem.js session-close --session_id` / any process passing an explicit `session_id` (session-close.ts:1331 does no ownership check). Within one process all writers are sync, so no interleaving — but the **wholesale rewrite** at `writeSessionFiles` :816-834 is itself lossy: `session_refresh` (:1444) rewrites `session.json` with `surfaced_scars`, `threads`, `started_at`, `recall_called` only → `confirmations` (confirm-scars.ts:149), `reflections` (reflect-scars.ts:131), `recall_failure`, `display`, `recording_path` are **dropped from disk**. `setCurrentSession` at :1456 also drops in-memory confirmations/reflections/observations/children. | After `session_refresh`: hook `recall-check.sh:97-99` reads `confirmations` = 0 with `surfaced_scars` > 0 → **hard block** on the next consequential Bash/Write/Edit (:147-166) for scars the agent already confirmed; in-memory `hasUnconfirmedScars()` (session-state.ts:520) also flips true → server-side enforcement banner. | Yes — `session_refresh` is the documented post-compaction step | MED | +| 3.C | `src/services/active-sessions.ts` all writers under lock; readers `listActiveSessions` :206, `findSessionByHostPid` :218, `findSessionById` :228 unlocked; hooks `resolve-root.sh:64-66` (`jq`) | `active-sessions.json` | Reads without lock are safe because writes are `rename`d (2.1). Only the rename-failure fallback (:46) exposes a partial file. | — | — | LOW | +| 3.D | `src/tools/session-close.ts:1586→1593` (inside the fire-and-forget embedding `track`, i.e. **after** the close has returned and after `cleanupSessionFiles`); `src/tools/promote-suggestion.ts:60→97` (with `await createThread` between); `src/tools/dismiss-suggestion.ts:53→66` | `suggested-threads.json` | Two sessions closing within seconds; or (desktop) a `dismiss_suggestion` while another chat's `promote_suggestion` awaits `createThread`. | A pending suggestion silently reverts or disappears; a promoted suggestion's `promoted_thread_id` link lost. | Occasional | LOW | +| 3.E | `src/services/local-file-storage.ts:132→141` `upsert` (read whole collection → replace/push → write whole collection); `:149→152` `delete`; `:217→231` `loadStarterScars`; callers: `session-start.ts:584` (sessions), `session-close.ts:289` (sessions), `:294-303` (scar_usage, one RMW **per scar**), free-tier `create_learning`/`create_decision`/`archive_learning` via `getStorage().upsert` | `learnings.json`, `sessions.json`, `decisions.json`, `scar_usage.json` | Two free-tier processes (two CLI windows, or CLI + hook `quick-retrieve` reads): P1 `session_start` upsert reads `sessions.json`; P2 `session_close` reads; P1 writes; P2 writes → P1's session row is gone → P1's later `session_close` `storage.get` returns `null` (session-close.ts:246) → record written as `{id, close_compliance, closing_reflection…}` **without** `session_date`/`agent`/`project` (:248-251) → `aggregateThreads` drops it (`s.session_date >= cutoffStr` with `undefined`, thread-manager.ts:151) → its open threads never resurface. Two `create_learning` calls in two windows: one learning lost outright. | Free tier: **permanent loss of a learning/decision/session/scar_usage record**; combined with 2.11 ("starting fresh") a single torn read wipes the collection. | Yes — free tier + more than one Claude Code window is the default install shape | HIGH | +| 3.F | `src/tools/session-close.ts:976` `getGitmemPath("closing-payload.json")` — **one path per `~/.gitmem`, not per session**; read :984-992, deleted :1695; hooks direct the agent to write exactly this path (`hooks/scripts/session-close-check.sh:97-101`, `resolve-root.sh:44`); `bin/init-wizard.js:454` pre-seeds it | `closing-payload.json` | Agent A writes payload; agent B (other window/chat) writes payload (overwrites); A calls `session_close(S_A)` → reads **B's reflection**, stores it on S_A, deletes the file; B's `session_close(S_B)` → `payloadConsumed=false`, no inline reflection → returns "closing-payload.json not found … write the closing payload" (:1005-1025); B rewrites, closes with its own — but S_A now permanently carries B's reflection, decisions, `scars_to_record`, `open_threads` (:987 merges file into params). Ordering variants: B's close first → A's reflection lost and A told to write it again. | Wrong session gets another session's closing reflection/decisions/threads; the other session's ceremony must be redone. | Yes — any two concurrent sessions on one machine that close within the same window | HIGH | +| 3.G | `src/services/license.ts:132` write; `:101-120` read | `license-cache.json` | Two servers validating at once; `deactivate` unlink (:145) during a server's read. | None material (readers null → revalidate). | Yes | LOW | +| 3.H | `src/commands/activate.ts:252→534` (reads `config.json` early, prompts interactively, writes back minutes later); `deactivate.ts:70→98`; `bin/gitmem.js:141→143, 231→234`; `bin/init-wizard.js:446-448` | `config.json` | A running server never writes `config.json`, so the only RMW race is CLI-vs-CLI (rare). But `activate` rewrites the whole object from a stale read taken before the wizard prompts. | Fields another tool added meanwhile are lost. | Rare | LOW | +| 3.I | `src/services/startup.ts:57-82` (write only); `src/services/vector-disk-cache.ts:115-139` (read only) | `cache/hook-scars.json`, `cache/learnings-vectors-*.json` | Vector cache: atomic, plus single-flight lock (4(e)). `hook-scars.json`: N servers × (startup + every 15 min) overwrite the same file; content is the same corpus, so only the torn-read window matters (2.13) — **except** the empty-overwrite path in 1.8. | Transient miss; permanent `[]` after a failed refresh in any one process. | Yes | MED (via 1.8) | +| 3.J | `src/services/doc-index.ts:149→219` (`await embed` per chunk between read and write) | `docs-index.json` | Two `index_docs` calls (desktop chats, or two windows). | Chunks from the slower call overwritten by the faster one's merge. | Rare | LOW | +| 3.K | `src/lib/telemetry.ts` `loadConfig` :70→`saveConfig` :112 | `telemetry.json` | CLI only. | — | — | LOW | + +--- + +### 4. Two processes sharing `~/.gitmem` — scenario walkthroughs + +#### 4(a) CLI session and desktop app both call `session_start` + +Two processes P_cli, P_desk, distinct PIDs, same `~/.gitmem`. + +1. Both run `checkExistingSession` (`src/tools/session-start.ts:732-773`): `migrateFromLegacy()` (:744, locked, idempotent via new-registry check `active-sessions.ts:569`), `pruneStale()` (:747, locked), `findSessionByHostPid(host, ownPid, project)` (:754) — misses for a fresh process, then `findResumableSessionOnDisk(project)` (`active-sessions.ts:286`): scans every `sessions/*/session.json`; the other process's session has a live foreign PID → `pidAlive: true` (:341) → never adopted (:359). Each gets `null` → new UUID. +2. `writeSessionFiles` (:779): `session.json` into its own dir (:834), then `registerSession` under the lock (:844); slots differ by PID so no displacement (`sameSlot` :137-139). Then the **unlocked** `threads.json` merge (:862-894): both read, both write; on pro both hold the Supabase set plus ≤24 h local-only threads (:873-877) so the last writer's file is a superset of what it read but may omit a thread the *other* process added between its read and write (3.A). On free tier both read the same file and write merged-with-file, so the only loss is a `create_thread` that lands between one process's read and write. +3. Both call `ensureInitialized` (:1163) → both cold-start the vector cache → 4(e) single-flight across processes works (one downloads, one waits). +4. Both write `cache/hook-scars.json` (`startup.ts:80`) — same content, torn-read window only. +5. Both share **one** `closing-payload.json` → 3.F: whichever closes second reads the other's reflection or finds the file gone. +6. `currentSession` is per process, so identity does not cross. **Inside** the desktop process, however, a second chat's `session_start` is the 1.1 case: different project → new session, the module slot is overwritten and chat 1 is now operating under chat 2's identity; same project → chat 2 **resumes** chat 1's session (:753-761, `findSessionByHostPid` matches own PID + project). + +Net: cross-process is sound for identity, unsound for `threads.json` and `closing-payload.json`; intra-desktop is unsound for identity. + +#### 4(b) `session_close` in one process while `recall` in another rewrites the same `session.json` + +Two processes bound to one session S arise via: + +- **Explicit `session_id`**: `sessionClose` (`session-close.ts:1331`) never checks that S belongs to this process (the registry ownership check exists only on the *fallback* path, :1368-1369). A CLI `bin/gitmem.js session-close` cannot resolve S without an id (its PID owns nothing; `findResumableSessionOnDisk` refuses live foreign PIDs) but with `--session_id`/inline id it proceeds: reads `session.json` (:1346-1357), upserts the row (:1550), `unregisterSession` + `rmSync(sessionDir)` (:496-505). The owning server's next `recall` → `persistSurfacedScars` → `fs.existsSync(sessionFilePath)` is false → **returns silently** (`session-state.ts:407`); same for `setRecallCalled` :276, `setRecallFailure` :304, `confirm-scars.ts:146`, `reflect-scars.ts:128`. Note `getSessionPath` → `getSessionDir` **recreates the empty directory** (`gitmem-dir.ts:334-336`) on every call, so from now on `sessions/S/` exists without `session.json`; `pruneStale` :450-460 treats the registry-less dir via `pruneOrphanedDirs` (deleted after 1 h). The server keeps working from memory; hooks see no live session (`gitmem_live_session_ids` empty) → confirmation gate and Stop-hook enforcement switch off. The server's own later `session_close` re-upserts the row (merge-duplicates, `supabase-client.ts:434`) and overwrites the first close's `close_compliance`/`closing_reflection`. +- **Double adoption**: server A dies; servers B and C both start (e.g. two clients restarting) and both run `findResumableSessionOnDisk` before either has written: both see `pid` dead (:341), both `persistSessionPid(S, self)` (:368, unlocked, atomic rename — last writer wins), both `reconcileRegistryEntry` (:369, locked; second removes the first's entry by `session_id` match :408-411). Both hold `currentSession = S`; both write `session.json` with their own surfaced scars — each `persistSurfacedScars` writes `session.surfacedScars` from **its** memory (`session-state.ts:409`), so the two processes alternately erase each other's scars on disk. First `session_close` deletes the dir; the other continues as above. + +Consequence: scar tracking silently becomes memory-only; a second close overwrites the first's compliance record. Reachable but needs an explicit foreign `session_id` or a simultaneous double restart — **MED**. + +#### 4(c) SessionStart hook (bash) reads the registry while the server writes it + +- `hooks/scripts/session-start.sh` does **not** read the registry or any session file; it only waits for a `gitmem` process (:124-164) and creates `/tmp/gitmem-hooks-` (:170-175). No race. +- `recall-check.sh` (PreToolUse), `post-tool-use.sh`, `session-close-check.sh` (Stop) read `active-sessions.json` through `gitmem_live_session_ids` (`resolve-root.sh:59-87`, `jq`/`node`). The server writes it by rename (2.1), so the hook sees a complete old or new file; the `jq` failure path (`|| return 0`, :66) yields "no session" only on the rename-failure fallback (2.1). Safe. +- `recall-check.sh:43-44` then picks **the newest live session in the registry, not this Claude Code session's** (`head -n 1` over `sort_by(.started_at) | reverse`), and reads its `session.json` (:81-83, `jq … || echo "0"`). With two CLI windows the hook in window 1 reads window 2's `session.json`: the confirmation gate blocks/passes window 1 on window 2's scars. And `session.json` is written non-atomically by the server (2.5-2.7); a hook `jq` that lands mid-write parses garbage → `"0"` for both counts → gate silently open. In the CLI the server's `recall` write completes before the tool result is returned, and the PreToolUse hook fires later, so the overlap requires a *second* process to be writing — again the two-window case. + +Consequence: enforcement keyed to the wrong session; torn read = gate off. **MED** (two windows), LOW otherwise. + +#### 4(d) `migrate-root` / `migrate-local` (activate) while a server is live + +- `migrate-root` (`src/commands/migrate-root.ts:228-290`): `walk` copies every file absent at the destination with `copyFileSync` (:212) — including `sessions//session.json` from the old project root (not in `isPerInstall` :167-168). A copied session dir with a dead PID and `started_at` < 24 h is then **adopted** by the next `findResumableSessionOnDisk` (`active-sessions.ts:357-370`) in any server that has no in-memory session — a stale session resurrected into a live process. Memory files are read at :239-240 and written by temp+rename at :283-284 minutes later (after conflict planning); a live free-tier server's `create_learning` between read and write is overwritten (the backup at :271-276 preserves the pre-merge destination, **not** the concurrent write). `threads.json` merged by id likewise races 3.A. +- `migrate-local` (called from `activate`, `src/commands/activate.ts:559-569`): reads collections (`migrate-local.ts:200`), POSTs each row, then `archiveLocalData` **renames** `learnings.json` → `learnings.json.pre-migration` (:352). A live free-tier server: `readCollection` → `existsSync` false → `[]` (`local-file-storage.ts:39`) → its next `upsert` creates a new `learnings.json` with one record; on restart the server reads `config.json` with `api_key` → pro → the one stray record stays local and unmigrated (`hasLocalData` on the next `activate` would pick it up, but `activate` is not re-run). Meanwhile the live server's `_tier` is still `free` (1.4) until restart. `activate` also `clearLicenseCache()` (:537, unlink) — harmless to readers. + +Consequence: resurrected stale sessions; a learning written during migration ends up in a shadow local file. **MED**, rare. + +#### 4(e) Two servers cold-starting → vector cache lock + +`src/services/startup.ts:149-231` + `src/services/vector-disk-cache.ts:262-322`: + +1. Both fetch the fingerprint (`startup.ts:161`), both miss the cache (:164-172). +2. `tryAcquireDownloadLock` (:178): `openSync(O_CREAT|O_EXCL)` (`vector-disk-cache.ts:278`) — exactly one wins. Loser: if the lock's mtime is >60 s old it unlinks and retries once (:286-289); a lock that vanished between `stat` and `unlink` is retried (:290); otherwise `null`. +3. Loser → `waitForLeaderCache` (:181): polls every 150 ms up to 25 s (:310-321); returns on a fingerprint-matching cache hit, or `null` as soon as the lock disappears without a hit, or on timeout → downloads itself. +4. Leader: delta or full download, `writeVectorCache` (atomic, :242-243) under the fingerprint it observed **before** downloading, then `finally releaseLock()` (:229). A leader that fails returns `scars: []` (:225-227) and still releases → follower sees no lock, no hit → downloads itself. A leader that **crashes** leaves the lock; follower gives up at 25 s and downloads; the lock is broken by the next process after 60 s. +5. Stale-break TOCTOU: two followers both `stat` a >60 s lock, both `unlinkSync`; the second unlink can remove a **fresh** lock created by the first follower after its unlink (:286-288) → third process acquires too → two leaders. Both write atomically; harmless (duplicate download only). +6. Fingerprint skew: the follower's `expected` fingerprint (its own observation) may differ from the leader's if the store changed in between (e.g. `refreshBehavioralScores` RPC from the leader's own `session_start`, `session-start.ts:1169`, bumps `updated_at`) → follower never hits → downloads after the leader releases. Correct, just costly. +7. Same-process double init (1.7): second `initializeGitMem` is a follower to its own process's lock; works, but if the first init ended empty the follower's rows are discarded by `initialize()`'s early return. + +Verdict: the cross-process protocol is correct for its stated goal (bounded wait, never stale, never empty-cached). Defects are cost-only, plus the 1.7/1.8 in-process empties. **LOW** for (e) itself. + +#### 4(f) `pruneStale` in A deleting a session dir B just created + +`src/services/active-sessions.ts:440-541`, under the registry lock: + +- Registry entry without `session.json`: pruned (and dir removed) only if `started_at` > 60 s old (:450-461). B writes `session.json` **before** `registerSession` (`session-start.ts:834` then :844), so a registered-but-fileless entry only exists if B's `writeFileSync` failed (caught at :836-838) — then B is pruned at 60 s and B's process keeps a phantom session whose every persist silently no-ops (4(b) behaviour). +- Dir without registry entry (`pruneOrphanedDirs` :496-525): deleted if the **directory** mtime is > 1 h. Directory mtime changes only on entry create/delete/rename — in-place `writeFileSync` of `session.json` (all of 2.5-2.7) does **not** touch it; only `persistSessionPid`'s rename (:386) does. So any session dir older than 1 h whose registry entry is missing is deleted by the next `pruneStale` in **any** process. Registry entries go missing when: (i) `readRegistry` fails validation/parse → `{sessions: []}` (:107-111) and the next locked write persists that empty view (every other live session becomes an orphan dir); (ii) the rename-fallback direct write (:46) is torn; (iii) an explicit-id foreign `session_close` unregisters it (4(b)); (iv) `unregisterSession` by a process that adopted the same session (4(b) double adoption). A live server never repairs its own entry unless `currentSession` is null — `reconcileRegistryEntry` is only reached through `findResumableSessionOnDisk` (:353, :369), which runs only from `checkExistingSession` at `session_start` and from `recoverSessionFromDisk` when memory is empty. +- Age prune: `started_at` > 24 h → entry **and dir** deleted regardless of PID liveness (:463-470). A desktop-app session, or a CLI session left open overnight, is destroyed by the first `session_start` anywhere on the machine after 24 h; the owning process then silently loses persistence (4(b)) and its eventual `session_close` finds no local file and, if the Supabase row is missing/free tier, fails with "Session … not found" (:1383-1392) or writes a bare record (3.E). +- B's brand-new dir is safe from the 1 h rule; the 60 s rule is safe because file precedes registration. The unsafe cases are lost registry entries and >24 h sessions. + +Consequence: a live session's `session.json` (surfaced scars, confirmations, reflections, threads) deleted from under it; all later persistence silently no-ops. **HIGH** (24 h path is routine for desktop; registry-reset path is one torn read away). + +#### 4(g) `registerSession` displacement rule (hostname+pid+project) + +`active-sessions.ts:137-139, 148-175`: an entry with a different `session_id` but the same host+pid+project is removed and reported as displaced; `session_start` then PATCHes it `superseded` in Supabase (`session-start.ts:853-855, 481-507`). + +- Same process, same project, new id: only via `force: true` (`checkExistingSession` returns null at :737-740; otherwise the existing session is resumed). Intended. +- **PID reuse**: after a reboot or a process exit, a new server can receive the PID of a dead session's owner. `findSessionByHostPid(host, pid, project)` (:754) matches the dead entry → "Resuming own session" (:759) → the new process **adopts a stale session** (≤24 h old, survived `pruneStale` because dead PIDs are deliberately kept, :473-475) with its surfaced scars/threads/`started_at`; the duration clock and close ceremony then belong to the old session. `findResumableSessionOnDisk` has the same hole: `pid === currentPid` takes the "mine" path (:351) with no `started_at`-vs-process-start check. macOS/Linux PID reuse within 24 h on a busy dev machine is plausible. +- Different project, same process: left open by design (GIT-86) — this is what makes 1.1 possible: two open sessions per process but one `currentSession` slot. +- `hostname` is part of the slot but `findResumableSessionOnDisk` also refuses other hosts (:320); a synced/backup-restored `~/.gitmem` is safe. + +**MED** (PID reuse), otherwise as designed. + +#### 4(h) Hooks' `/tmp/gitmem-hooks-$SESSION_ID` + +`SESSION_ID="${CLAUDE_SESSION_ID:-$$}"` in `session-start.sh:170`, `recall-check.sh:105`, `post-tool-use.sh:102`, `session-close-check.sh:24`. `$$` is the PID of **that hook's bash process** — a fresh value on every invocation. + +- If `CLAUDE_SESSION_ID` is **not** exported to hook processes (the scripts never read the `session_id` field of the stdin JSON, which is where Claude Code delivers it): every hook invocation gets its own state dir. `tool_call_count` is always 1 → the recall nag (needs >10, `recall-check.sh:191`) never fires; `session-close-check.sh:48-58` finds no `STATE_DIR`, creates one, exits 0 → the Stop-hook "session still open" block **never fires**; `stop_hook_active` is never consulted. `/tmp` accumulates one dir per tool call (cleaned only by `uninstall-hooks`, `bin/gitmem.js:837-851`). +- If it **is** exported: state is per Claude session, correctly isolated across windows. Then `session-close-check.sh:27-30` blocks at most once per session (`stop_hook_active`), and `:109-111` removes the dir once the registry is empty — but "registry empty" is evaluated machine-wide (`gitmem_live_session_ids`), so with two windows open the Stop hook in window 1 blocks because window 2's session is still registered, and after its one allowed block it never cleans up. +- `/tmp/gitmem-hooks-*` and `/tmp/gitmem-hooks-plugin-debug.log` (`session-start.sh:11`) are world-visible fixed paths; on a multi-user host a pre-existing dir/file owned by another user makes `echo "0" > …` fail under `set -e` → hook exits non-zero. + +**MED** (enforcement either dead or cross-window, depending on the env var), LOW security. + +--- + +### 5. In-process races (fire-and-forget vs later tool calls) + +| # | file:line | race | ordering that loses | consequence | reachable? | severity | +|---|---|---|---|---|---|---| +| 5.1 | `src/tools/session-start.ts:1149-1152` `createSessionRecord` (fire-and-forget `directUpsert`, body :444-457 sets `decisions: [], open_threads: [], closing_reflection: null, close_compliance: null`) vs `src/tools/session-close.ts:1550` `directUpsert(sessionsTable, sessionRow)`. Both are `POST … Prefer: resolution=merge-duplicates` (`src/services/supabase-client.ts:434`), i.e. `ON CONFLICT DO UPDATE` of **every column sent** — full-row last-writer-wins, no version check. The close does not await `pendingSessionRows` (only `recordMetrics` does, `metrics.ts:194`). | Create request delayed/retried past the close's upsert (fast `quick` close, automation, network reorder); or `session_close` reaches Supabase while the create is still in flight so `getRecord` (:1335) 404s → falls back to the local file (:1346-1357) and builds the row from `session.json` → after both land, the fields the create carries win if it lands last. | Closed session reverts to `close_compliance: null`, `closing_reflection: null`, `decisions: []`, `open_threads: []` — invisible to `loadLastSession` (filters `close_compliance != null`, session-start.ts:243) and to `aggregateThreads`; the reflection is gone (threads survive in the threads table). Also `markSessionSuperseded` (:481-507, fire-and-forget PATCH) can land after the displaced session's own close in another chat and overwrite its `close_compliance` with `superseded`. | Occasionally (short sessions / tests / flaky network) | MED | +| 5.2 | `src/tools/session-start.ts:1169-1173` `refreshBehavioralScores` (RPC that bumps `updated_at` on scars) vs the vector cache fingerprint observed by `startBackgroundInit`/`ensureInitialized` (`startup.ts:161`) in the same or any other process | RPC lands after the fingerprint was read and before/after the download → cache is written under a pre-RPC fingerprint → next start misses (delta sync, GIT-98) — and, across a fan-out, followers in 4(e) never hit. The in-memory index keeps stale `decay_multiplier` until the next 15-min refresh. | Extra network only. | Every pro `session_start` | LOW | +| 5.3 | `startBackgroundInit` (`src/server.ts:523`) vs `ensureInitialized` (`session-start.ts:1163`) — see 1.7; and `recall` (`recall.ts:543`) during warmup → `isLocalSearchReady` false → `cachedScarSearch` remote (:557) — correct fallback. | Double download; empty-index poisoning if the first init failed. | As 1.7. | Yes | MED | +| 5.4 | `validateLicense(...).then(setTier("free"))` (`src/server.ts:499-503`) vs everything that already cached the tier: `getRegisteredTools()` (:119), `configInstance` (`config.ts:141`), `_storage` (`storage.ts:143`), and any `session_start` already served on the pro path. See 1.4-1.6. | Validation resolves after the first tool call (it is a 10 s-timeout network call started after `server.connect`, :489-499). | Pro-created session closed to local JSON; pro tools advertised but routed free; periodic refresh keeps hitting Supabase; `create_learning` after the flip goes to `~/.gitmem/learnings.json` while the user believes they are on pro (stderr-only warning). | Yes (expired cache + unreachable license server) | HIGH | +| 5.5 | `checkWritePathWithTimeout` (`src/services/write-health.ts:101-148`): `deadline` timer resolves `timed_out` at 10 s; the real check may finish later and `record(r, true)` (:120-127) replaces it. Two invocations are never concurrent (`server.ts:508` **or** :515). | Late verdict replaces the timeout verdict — intended and marked `late`. `checkWritePath` swallows all errors (:217-224), so the `void` promise cannot reject. | None. | — | LOW | +| 5.6 | `pendingSessionRows` (`src/services/metrics.ts:153-178`): `recordMetrics` for `session_close` (session-close.ts:1653) races nothing (row long settled). `recall`'s metrics carry `session_id` only in `metadata` (recall.ts:745) — correct. Growth: one entry per `session_start` forever (comment :151). | — | Memory only. | — | LOW | +| 5.7 | `src/tools/resolve-thread.ts:70` takes the **live** `currentSession.threads` array (`session-state.ts:628` returns the reference), mutates elements in place (`thread-manager.ts:229-232`), then `saveThreadsFile(threads)` (:196) writes the session's subset over `threads.json` (3.A ii). In the desktop app, chat B's session array is what chat A's `resolve_thread` mutates and writes (1.1). | Any `resolve_thread` while the file holds threads the session does not. | Threads dropped from `threads.json`; on free tier lost. | Yes | MED | +| 5.8 | Periodic refresh (`startup.ts:802-815`, every TTL) → `flushCache` → failed `loadScarsFromSupabase` returns `[]` → `reinitializeLocalSearch([])` + `persistScarsForHooks([])` (:677-683). See 1.8. A `recall` whose `await this._embed()` (`local-vector-search.ts:228`) spans the refresh reads the emptied index. | One failed 15-min refresh. | Local recall and hook auto-retrieve return nothing until the next successful refresh; `cache_status` reports `initialized: true, scar_count: 0`. | Yes | HIGH | +| 5.9 | `src/tools/session-close.ts:1557-1598` embedding `track` runs after the close returned and after `cleanupSessionFiles` (:1691) and `clearCurrentSession` (:1677): it reads `existingSession?.project` (captured) and then `loadSuggestions()/saveSuggestions()` (:1586-1593) unlocked (3.D). `recordScarUsageBatch` (:1606-1611) and `updateRelevanceData` (:1622) also run after the close's display was rendered (:1727) — their failures appear only in a later `health` call. | Next tool call in the same process (desktop: another chat) between close-return and effect completion. | Suggestion file lost update; close display claims OK for writes that then fail. | Yes | LOW | +| 5.10 | `src/tools/recall.ts:580-688`: `currentSession` captured at :580; `addSurfacedScars` at :688 resolves the session **again** (`session-state.ts:378`) after `await`s at :595, :632, :642 → if the slot changed meanwhile (1.1), the scars are tracked and persisted on the *new* session while `variantMetadata.sessionId` (:583) and the metrics row (:745) name the old one. | Desktop: another chat's `session_start`/`session_close` during a recall. | Scar surfacing recorded on the wrong session; `confirm_scars` in the originating chat then finds them (because it, too, reads the wrong slot) — consistent but misattributed in Supabase `scar_usage`. | Yes (desktop) | MED | +| 5.11 | `src/tools/session-start.ts:1456-1464` `sessionRefresh` rebuilds the session **without** `confirmations`, `reflections`, `observations`, `children`, `feedbackSubmitCount` (all reset to `[]`/0 by `setCurrentSession` :82-92) and rewrites `session.json` without them (:1444, 3.B). Not a race, but the same class of state loss a race would produce and it re-arms both enforcement gates. | Every `session_refresh`. | Confirmed scars become unconfirmed (server banner `enforcement.ts:112-121`; hook hard-block `recall-check.sh:147`); `contribute_feedback` rate limit resets. | Yes | MED | + +--- + +### 6. `src/services/file-lock.ts` review + +Read in full (152 lines). + +| aspect | finding | file:line | +|---|---|---| +| Atomic create | `fs.openSync(lockPath, O_CREAT \| O_EXCL \| O_WRONLY)` — correct on local POSIX filesystems and NTFS; `O_EXCL` is not honoured by NFSv2/v3 servers that lack the `EXCLUSIVE` create mode, so a networked `~` (NFS/SMB home, some container volume mounts) gives no mutual exclusion. Not detected or documented. | :69 | +| Holder identity | `{pid, hostname, acquired_at}` written **after** create; a crash between `openSync` and `writeSync` leaves an empty lock → `isLockStale` parse fails → treated as stale → broken immediately (:45-47). Benign (microsecond window). | :70-76, :39-49 | +| Reentrance | Same `pid`+`hostname` → returns without taking or counting the lock (:84-91). The inner `withLockSync`'s `finally` then **unlinks the outer's lock** (:149-151) while the outer critical section is still running; the outer's own release hits ENOENT and is ignored (:134-136). No current call site nests (`registerSession`/`unregisterSession`/`reconcileRegistryEntry`/`pruneStale`/`migrateFromLegacy` never call each other), so latent. PID reuse across a reboot (lock file left by a dead process whose PID the new one inherited) is misread as reentrant → lock skipped; the 30 s stale rule would have broken it anyway. | :84-91, :145-152 | +| Stale detection | Wall-clock age of `acquired_at` > 30 s (:16, :43-44). Critical sections are milliseconds, so a 30 s holder is a crash — except `sleepSync`/slow disks in the desktop app. Clock skew between processes on one host is nil; NTP step during the window would break a live lock. | :16, :39-49 | +| Stale-break TOCTOU | `isLockStale` (read) → `unlinkSync` (:97-99) are not atomic: waiter W1 unlinks the stale file and creates a fresh lock; waiter W2, which also judged it stale before W1's create, unlinks **W1's fresh lock** (the unlink error branch at :102-104 only catches ENOENT). A third waiter (or W2's own retry) then creates a lock while W1 believes it holds it → two holders → both `readRegistry`/`writeRegistry` → one registration lost (last rename wins). Requires a crashed holder plus ≥2 concurrent waiters — e.g. a fan-out of N sub-agent servers starting within 5 s of a crashed one. | :96-105 | +| Release on exception | `withLockSync` uses `try/finally` → yes. | :145-152 | +| Release on crash | No (advisory file); recovered by the stale rule after 30 s. | — | +| Timeout behaviour | 5 s of 25 ms polls, then **throws** (:107-119). Callers: `registerSession` throws out of `writeSessionFiles` → caught at `session-start.ts:1219-1223` → **session file exists but the session is never registered** (hooks see no session; `pruneOrphanedDirs` deletes its dir after 1 h — 4(f)). `pruneStale`/`migrateFromLegacy` throw inside `checkExistingSession` → caught at :768-770 → returns `null` → **a new session is created even though this process already owns one** (duplicate session for the same PID+project; the old one is later displaced or pruned). `unregisterSession` throws inside `cleanupSessionFiles` → caught :497 → dir still removed → registry keeps a dangling entry (pruned after 60 s). `reconcileRegistryEntry` throws → caught :417-420. So a single crashed lock holder makes every `session_start` on the machine for the next 30 s open a phantom/duplicate session. | :107-119; `session-start.ts:742-770, 1219-1223`; `session-close.ts:493-499` | +| Blocking | `sleepSync` uses `Atomics.wait` on the main thread (:30-34) — permitted in Node, blocks the whole event loop for up to 5 s: the desktop app's other chats and all in-flight fire-and-forget promises stall. | :30-34, :121 | +| Cross-process scope | Lock path is `/active-sessions.lock` (`active-sessions.ts:23, 87-89`) → shared by every process on the machine using that root. Correct scope for the registry. | — | +| Windows | `O_EXCL` works; `unlinkSync` of the closed lock file works; `hostname()` stable. `renameSync` in `atomicWriteFileSync` over an existing file works on Node (uses `MoveFileEx` with replace). No known Windows-specific defect. | — | +| Tests | `tests/unit/services/file-lock.test.ts` covers acquire/release, stale-break (30 s and corrupt), reentrance, timeout, `withLockSync` exception path. `tests/unit/services/active-sessions-locking.test.ts` runs **sequential** register/unregister/prune in one process ("simulated concurrency", :82) — there is no multi-process test, no test of the stale-break TOCTOU, no test of the timeout-throw consequences in `session_start`. | — | + +**Call sites that use `withLockSync`:** `active-sessions.ts:150, 182, 402, 441, 563`. + +**Sites that RMW a shared JSON store and should hold a lock but do not:** `thread-manager.ts:271` (+ callers 3.A), `local-file-storage.ts:141, 152, 231`, `thread-suggestions.ts:213` (+ callers 3.D), `doc-index.ts:102`, `active-sessions.ts:386` (`persistSessionPid`, called outside the lock at :368), every `session.json` RMW when two processes are bound to one session (3.B), `migrate-root.ts:283-287`, `startup.ts:80`, `license.ts:132`, `session-close.ts:976-998/1695` (`closing-payload.json`, which needs per-session pathing more than a lock). + +--- + +### Counts + +- **Shared mutable module-level state items:** 20 (table 1), of which 3 HIGH (`currentSession`, `_tier` + its cached derivatives, vector index emptied by failed refresh), 4 MED, 13 LOW. +- **File write sites:** 31 rows in table 2; atomic temp+rename **3** helpers (covering 2 stores + migration output); direct-overwrite **~30** sites across **11** parsed JSON stores; append **6**; delete **7**. +- **Readers that turn a torn read into a permanent overwrite ("starting fresh"):** 5 (`local-file-storage.ts:44`, `thread-manager.ts:257`, `thread-suggestions.ts:198`, `active-sessions.ts:107-111`, `doc-index.ts:79`). +- **Lock-free RMW groups on JSON stores:** 11 (table 3); 2 HIGH (`closing-payload.json`, free-tier collections), 3 MED, 6 LOW. +- **In-process races:** 11 (table 5); 2 HIGH, 5 MED, 4 LOW. +- **`withLockSync` call sites:** 5, all on the registry. **Unlocked RMW sites that touch cross-process stores:** 10 groups. +- **file-lock defects:** 1 correctness (stale-break TOCTOU), 1 latent (reentrance releases outer lock), 1 environmental (NFS), 1 blast-radius (timeout → duplicate/phantom sessions), 1 liveness (5 s main-thread block). + +### Ranked top 10 + +| rank | issue | where | why it ranks here | +|---|---|---|---| +| 1 | One `currentSession` slot per process while the desktop app serves many chats: scars, confirmations, threads, observations and project scope cross between sessions; `session_close` in one chat clears the other's state and records the other's scars under its own id. | `session-state.ts:56`; `session-start.ts:1230, 753-761, 1104-1110`; `session-close.ts:581-608, 763, 788, 1677`; `recall.ts:425, 580, 688` | Wrong data written to durable stores (`scar_usage`, session rows) in a supported deployment; partially acknowledged in code but only the `force` path handles it. | +| 2 | `closing-payload.json` is a single machine-wide file shared by every session; two concurrent sessions read/overwrite/delete each other's closing reflection. | `session-close.ts:976-998, 1695`; `resolve-root.sh:44`; `session-close-check.sh:97-101`; `init-wizard.js:454` | Reflection/decisions/threads from session B stored on session A's row; B told its payload is missing. Two open Claude Code windows is the common case. | +| 3 | `pruneStale` destroys live sessions: any session > 24 h old, or any session whose registry entry was lost (one torn registry read → `{sessions: []}` → the next locked write persists it), has its `session.json` deleted by the next `session_start` in **any** process; the owning process then no-ops every persist silently. | `active-sessions.ts:107-111, 463-470, 496-525`; `session-state.ts:276, 304, 407`; `confirm-scars.ts:146`; `session-start.ts:834` | Silent, cross-process, destructive; the long-lived desktop app crosses 24 h routinely. | +| 4 | Free tier: whole-collection direct overwrite + lock-free RMW + "starting fresh" on parse error. Two windows lose learnings/decisions/session rows; one torn read wipes `learnings.json` on the next write. | `local-file-storage.ts:37-47, 62-66, 132-141`; `session-start.ts:584`; `session-close.ts:289-303` | The free tier's only store; loss is permanent and unreported. | +| 5 | Periodic refresh / `cache_flush` network failure empties the in-memory vector index **and** overwrites `hook-scars.json` with `[]`, reports `success: true`, and `ensureInitialized` never re-initializes. | `startup.ts:225-227, 359-365, 677-683, 80, 802-815`; `local-vector-search.ts:119, 129-137, 175-179` | Recall silently returns nothing for the rest of the session on a transient error; happens on a 15-minute timer. | +| 6 | Async license validation flips `_tier` to `free` after tools, config, storage and possibly a session were set up as pro. | `server.ts:499-508`; `tier.ts:73`; `config.ts:141`; `storage.ts:143`; `session-start.ts:1119, 1149`; `session-close.ts:1200` | Session opened in Supabase, closed to local JSON; subsequent writes go local while the user believes they are on pro; warning is stderr-only. Triggered by any 72 h-expired cache + unreachable license server. | +| 7 | `threads.json` lock-free RMW, and `resolve_thread`/`session_close` writing the in-memory subset over the file. | `thread-manager.ts:271`; `session-start.ts:862-894`; `create-thread.ts:319-321`; `resolve-thread.ts:70-73, 196`; `session-close.ts:1414-1450` | Cross-process thread loss; on free tier the file is the store. | +| 8 | `createSessionRecord` fire-and-forget and `session_close` are both full-row `merge-duplicates` upserts with no ordering guarantee; a late create reverts a closed session to unclosed and drops its reflection. | `session-start.ts:444-457, 1149`; `session-close.ts:1550`; `supabase-client.ts:434`; `metrics.ts:194` (only metrics wait) | Silent loss of the closing reflection; the same pattern (`markSessionSuperseded` PATCH) can overwrite another chat's close. | +| 9 | `file-lock` timeout throws → `checkExistingSession` opens a **duplicate** session for a process that already owns one, and `registerSession` failure leaves a session file with no registry entry (→ #3 after 1 h); stale-break TOCTOU allows two holders. | `file-lock.ts:96-119`; `session-start.ts:742-770, 1219-1223`; `session-close.ts:493-499` | A single crashed lock holder degrades every `session_start` on the machine for 30 s into phantom/duplicate sessions. | +| 10 | Hook enforcement keyed to the wrong session or to a per-invocation `/tmp` dir: `recall-check.sh` gates on the **newest** registry session, not this window's; state dir keyed on `$$` when `CLAUDE_SESSION_ID` is absent so the nag and the Stop-hook close reminder never fire; `session_refresh` wipes confirmations from `session.json` and re-arms the hard block. | `recall-check.sh:43-44, 81-83, 105, 147`; `session-close-check.sh:24, 43, 48-58`; `session-start.sh:170`; `session-start.ts:816-834, 1444, 1456` | Enforcement either silently off or blocking the wrong window; not data loss but it is the product's stated safety layer. | + +Honourable mentions (LOW, but real): `session.json` non-atomic writes read by hook `jq` → `"0"` → gate off (2.5, 4(c)); `behavioral-decay.ts:17-19` reads env only so config.json-based pro installs never refresh decay (1.19); `bin/gitmem.js init`/`init-scars` writes `process.cwd()/.gitmem/config.json` and `learnings.json` — a root the server no longer reads (2.21, 2.24); `bin/gitmem.js session-start` (:371-391) still creates a session under a CLI PID that exits immediately, leaving a dead-PID session for the next server to adopt (4(g)). + +--- + +## 8. Class F — Tool Contract Review (gitmem v1.11.0, f93c3a3, branch audit/GIT-113-phase-0) + + +Read-only audit. All paths relative to `/Users/chriscrawford/nTEG-Labs/gitmem`. "Customer store" = Supabase project provisioned from `schema/setup.sql` (pro tier). "Free" = local JSON in `.gitmem/`. + +### Cross-cutting facts (apply to many rows) + +- **Dispatch (src/server.ts:429-435):** client receives ONLY `result.display` if it is a string; else `result.text`; else `JSON.stringify(result)`. Every result field not rendered into `display` never reaches the agent. `durable` / `stored_in` (GIT-101, src/services/write-result.ts:34-42) are returned by write tools but **no tool renders them into `display`** (verified per row below). +- **Tool registration is computed once at server creation** (src/server.ts:119-120) with the optimistic tier (src/services/tier.ts:38-45: key present + no cache ⇒ "pro"). Async license validation may later `setTier("free")` (src/server.ts:500-502) but `registeredTools` is not recomputed, so a downgraded server still advertises pro-only tools (`analyze`, `graph_traverse`, cache tools) whose handlers then run on the free path. +- **Zod validation (src/schemas/registry.ts:35-55)** exists for 19 of 30 canonical tools. Zod objects are non-strict, and the server passes the ORIGINAL `toolArgs` to the handler (src/server.ts:145,167), so unknown keys are neither rejected nor stripped. Tools with no Zod schema: confirm_scars, reflect_scars, session_refresh, create_thread, promote_suggestion, dismiss_suggestion, cleanup_threads, graph_traverse, health, index_docs, search_docs. +- **setup.sql does NOT create:** any RPC named `match_*` (only `gitmem_semantic_search` :287 and `gitmem_scar_search` :322), `transcript_chunks`, `match_transcript_chunks`, a storage bucket, `variant_assignments`, `variant_performance_metrics`, `scar_enforcement_variants.active`, `gitmem_feedback`, sessions columns `task_observations` / `children` / `claude_code_session_id` (src/services/session-columns.ts:75-87 lists 11 production-only columns). +- **RPC name mismatch (customer-store breaking):** src/services/supabase-client.ts:190 calls `match_gitmem_learnings`, :693 calls `match_gitmem_learnings_weighted`. Neither exists in setup.sql. Every code path that reaches these RPCs on a customer store gets PGRST202. (GIT-93's comment at :680-688 says the survey was of "deployed functions" — the internal store — not setup.sql.) +- **Local vector index (pro):** `isLocalSearchReady()` is `initialized && scars.length > 0` (src/services/local-vector-search.ts:204). Index is cross-project and ignores `project` (src/services/local-vector-search.ts:369-378, `_project` unused). Entries without an embedding are not indexed (src/services/startup.ts:249 comment; supabase-client `INDEXED_LEARNING_FILTERS`). +- **Embedding provider absent (pro):** `embed()` returns `null` (src/services/embedding.ts:261-263); `scarSearch`/`semanticSearch` then return `[]` silently (supabase-client.ts:675-678, :183-186); `LocalVectorSearch._embed` throws (local-vector-search.ts:262-264). + +--- + +### Part 1 — MCP tools + +| tool | description promises (definitions.ts) | result/display on success | verifiable in store at that moment | gap | severity | +|---|---|---|---|---|---| +| **recall** (def :22-51; handler src/tools/recall.ts) | "Check institutional memory for relevant scars… Returns matching scars and their lessons. Integrates variant assignment when issue_id provided." `similarity_threshold`: "Default: 0.4 (free tier BM25), 0.35 (pro tier embeddings)". `project`: "Scopes sessions and searches." `match_count` "default: 3". | `display` = nudge header + per-scar lines (sev, title, similarity, 8-char id, tiers stub/compact/extended, :251-381) + "Acknowledge these lessons before proceeding." + untracked-surfacing notice when no session (:175-185). Result also carries `activated`, `scars[]` (full UUIDs), `performance`, `formatted_response` — not in display beyond what is rendered. | Read-only. Surfacing is written to `session.json` via `addSurfacedScars` (src/services/session-state.ts:377-394); `untrackedSurfacingNotice` discloses when not tracked. Error path: catch → `setRecallFailure` + "⚠️ Error querying institutional memory" (:758-783) — distinguishable from "no scars". | (1) **Variant promise dev-only**: `hasVariants()` is `tier==="dev"` (tier.ts:112); on pro/free `issue_id` has no effect (recall.ts:586). (2) **Threshold text untrue**: pro default is 0.45 (recall.ts:436), not 0.35. (3) **`project` does not scope recall on pro**: local index is unified cross-project (local-vector-search.ts:369-378) and the Supabase fallback deliberately omits `project_filter` (supabase-client.ts:707). (4) **Silent empty when no embedding provider on pro with cold index**: `scarSearch` returns `[]` (supabase-client.ts:675-678) → recall renders "No relevant scars found" and calls `clearRecallFailure()` (recall.ts:572) → confirm_scars then says "Proceed freely" (confirm-scars.ts:222). Indistinguishable from a real empty result. (5) **Cold-index fallback broken on customer store**: `match_gitmem_learnings_weighted` RPC absent from setup.sql → PGRST202 (surfaced as error banner, so at least visible). (6) **Schema vs handler**: `similarity_threshold` is in definitions but NOT in Zod `RecallParamsSchema` (src/schemas/recall.ts) — passes through only because Zod is non-strict; handler fallback to `action` param (recall.ts:409) is dead code because Zod rejects a missing `plan` first (server.ts:145). (7) Free tier drops `applies_when` (always `[]`, recall.ts:457) so the compact tier can never render it. (8) `performance.search_mode`/`cache_hit` not in display — agent cannot tell local-index vs remote fallback. | (4) High; (5) High; (1)(2)(3) Medium; (6)(7)(8) Low | +| **confirm_scars** (def :53-89; src/tools/confirm-scars.ts) | "REQUIRED after recall() before consequential actions. Each recalled scar must be addressed." APPLYING past-tense evidence, N_A explanation, REFUTED risk ack; min 50 chars; `relevance` "Helps improve future recall quality." | `display`: "SCAR CONFIRMATIONS ACCEPTED" + per-scar decision lines + "All recalled scars addressed. Consequential actions are now unblocked." (:102-110). Rejection lists errors/unaddressed. No-session and recall-failed paths render explicit rejections (:171, :198-209). | Confirmations written to in-memory session state + `session.json` (`persistConfirmationsToFile` :140-159, non-fatal on failure). Not written to Supabase until session_close bridges them (session-close.ts:787-836). File write failure is stderr-only; display still says ACCEPTED. | (1) "REQUIRED" is advisory only: server enforcement is a prepended warning, never a block (src/services/enforcement.ts:8, :116-130) — and only for create_learning/create_decision/session_close (`CONSEQUENTIAL_TOOLS` :53-57). (2) Display omits `relevance`; relevance is only consumed by `updateRelevanceData` at close (session-close.ts:1620-1622), i.e. dependent on `gitmem_query_metrics` rows from recall (metrics.ts) — on free tier there is no metrics table so "helps improve future recall quality" is a no-op. (3) No Zod schema; alias `gitmem-cs` schema lacks `relevance` (def :834-855). (4) Persist-to-file failure not disclosed in display. | (1) Medium; (2)(4) Low | +| **reflect_scars** (def :91-122; src/tools/reflect-scars.ts) | "Call BEFORE session_close… OBEYED (min 15) / REFUTED (min 30). Session close uses reflections to set execution_successful accurately." | `display`: "SCAR REFLECTIONS ACCEPTED" + per-scar outcome + "Session close will use these for execution_successful." | Written to memory + `session.json` (`persistReflectionsToFile` :122-137). Consumed by session_close bridge only when a confirmation also exists for that scar (session-close.ts:803-836: reflections are looked up inside the `if (confirmation)` branch). A scar reflected but never confirmed falls to pass 2/3 and is recorded `"none"`/`execution_successful:false` (:868-880) regardless of the OBEYED reflection. | (1) **Promise partially implemented**: reflections only influence `execution_successful` for scars that were ALSO confirmed via confirm_scars; session_start-surfaced scars (source "session_start", which reflect_scars explicitly includes at :161-162) can never have confirmations (confirm_scars filters to source "recall", confirm-scars.ts:185) so their reflections are ignored at close. (2) Missing scars are advisory here (:234-245) while the display header says ACCEPTED — fine, but "Unreflected scars" only appears on the rejected branch (:105-111), so partial coverage is not shown on success. (3) No Zod schema. (4) Free tier: session_close free path never calls `bridgeScarsToUsageRecords` (session-close.ts:223-344) — reflections are discarded entirely on free. | (1) Medium; (4) Medium; (2)(3) Low | +| **session_start** (def :124-162; src/tools/session-start.ts) | "Initialize session, detect agent, load institutional context (last session, recent decisions, open threads)… DISPLAY: … Output the display field verbatim." Params: agent_identity, linear_issue, issue_title, issue_description, issue_labels, project, force. | `display` (formatStartDisplay :919-1065): product line + `session_id · agent · project` + resumed-project note + stranded-store block + Threads (top 5, omission line) + Decisions (top 3) + "No threads or decisions." + Pro Insights + first-session nudge + DISPLAY PROTOCOL footer. **Not in display:** `last_session` (title/date/key_decisions), `project_state`, `gitmem_dir`, `closing_payload_schema`, `detected_environment`, `recording_path`, `performance`. | Pro: session row insert is fire-and-forget (`registerPendingSessionRow(createSessionRecord…)` :1149-1152); `stored` boolean is discarded. Session files written locally (:816-838). Reads of `sessions_lite` / threads use `allSettled` (:190-212): a failed half is stderr-only and the display shows "No threads or decisions." — indistinguishable from empty. Free: local `storage.upsert("sessions")` awaited (:584-595), failure stderr-only. | (1) **"load last session" loaded but never displayed** — `last_session` is only used to extract `PROJECT STATE` (:1178-1181), which is also not displayed. (2) **`gitmem_dir` omitted from display** while session_close's description tells the agent to write to `{gitmem_dir}/closing-payload.json` (def :179); the agent must guess the path (session_close's GIT-99 error names it after the fact, session-close.ts:1020). (3) **Session-row durability unknown**: display says "active" whether or not the Supabase insert landed. (4) **Failed context reads not disclosed** (:204, :211 stderr only). (5) `issue_labels` accepted by schema and Zod but never read by any handler (grep: no reads outside definitions/schemas/types). `issue_title`/`issue_description` are only used as metrics `query_text` (:1276), not "for scar context" as described (def :139-145). (6) Pro Insights depends on ≥5 sessions and `gitmem_scar_usage` (analytics.ts) — silently absent otherwise (`hasProInsights` only). (7) Fire-and-forget `refreshBehavioralScores` (:1169-1173) and `archiveDormantThreads` (:228) failures are stderr-only. (8) `agent_identity` definitions enum is 5 values (def :131) but Zod also accepts legacy "CLI/DAC/CODA-1/Brain_Local/Brain_Cloud/Unknown" (src/schemas/common.ts:11-24). | (2) High; (1)(3)(4) Medium; (5)(6)(7)(8) Low | +| **session_refresh** (def :164-176; session-start.ts:1329-1504) | "Re-surface institutional context (threads, decisions) for the current active session… DISPLAY: The result includes a pre-formatted 'display' field." | Pro: same `formatStartDisplay` with "refreshed" label. **Free tier (:1368-1376) and no-active-session (:1354-1361) return NO `display` and NO `text`** → server dumps raw JSON (`{"session_id":"…","refreshed":true,"message":"Free tier — limited context available…"}`). | Pro: rewrites `session.json` with `isRefresh=true` (:1444) — preserves `recall_called`/`started_at` (:801-814). No store writes. | (1) **Description untrue on free tier**: no display field is produced (:1368-1376); the agent receives raw JSON. (2) Free tier refresh does nothing useful ("limited context available") though free session_start does load local threads/decisions — the free refresh path is not the "same lean pipeline" the docstring claims (:1316-1318). (3) No Zod schema. (4) `session_refresh` is in `SESSION_REQUIRED_TOOLS` (enforcement.ts:37) so an agent whose in-memory session was lost gets both an enforcement warning AND a JSON dump. | (1) Medium; (2) Medium; (3)(4) Low | +| **session_close** (def :178-211; src/tools/session-close.ts) | "Persist session with compliance validation. Two modes: (1) write payload to {gitmem_dir}/closing-payload.json … (2) pass closing_reflection directly… task_completion is auto-generated. DISPLAY: Output the display field verbatim." Advertised params: session_id, close_type ∈ {standard,quick,autonomous}, closing_reflection, human_corrections, linear_issue, ceremony_duration_ms. | `display` (formatCloseDisplay :358-447): product line with COMPLETE/FAILED; stats "N scars applied · N learnings · N decisions · N threads"; `id · agent · close_type`; errors; reflection highlights; decisions; scar lines; blindspot; transcript (failure only); write warnings from effect tracker (failure only, :440-444). | Pro: session row `directUpsert` awaited with `return=representation` (:1548-1553) — verified. Threads: `syncThreadsToSupabase` awaited, partial sync ⇒ `success:false`, files retained (:1420-1455, :1687-1708). **Scar usage batch is fire-and-forget** (`getEffectTracker().track("scar_usage", …)` :1606-1611) and **the display is built immediately after** (:1727), so a failed scar_usage write cannot appear in this close's warnings — only in a later `health` call. Embedding/suggestions fire-and-forget (:1557-1598). `learnings_stored` = `params.learnings_created?.length` (:1308) — agent-asserted, never checked against the store. Free: local upsert awaited (:289). | (1) **Advertised schema ≠ accepted params**: Zod `SessionCloseParamsSchema` (src/schemas/session-close.ts) and the handler accept `decisions`, `open_threads`, `project_state`, `learnings_created`, `scars_to_record`, `task_completion`, `capture_transcript`, `transcript_path`, and `close_type:"retroactive"` — none in definitions.ts :180-210. The display's stats line is driven entirely by these undocumented params. (2) **`durable`/`stored_in` not in display**; free-tier close renders the same "complete" as a Supabase close. (3) **"N learnings" / "N scars applied" are claims, not evidence**: learnings count is self-reported; scars_applied count is `params.scars_to_record` length (:378) computed before the async write. (4) **`session_id` is `required` in definitions (:209) but optional in Zod and recoverable from disk (:927-945)** — harmless, but description mode (1) relies on `gitmem_dir` the agent was never shown (see session_start). (5) Transcript: on a customer store `storeHasTable("gitmem_transcript_chunks")` is false → skipped silently (:1479-1481), display says nothing; `claude_code_session_id` column is probe-dropped (session-columns.ts) — the "traceability" comment (:186-188) is not honoured on customer stores. (6) `open_threads` written as `ThreadObject[]` (:587) into `gitmem_sessions.open_threads TEXT[]` (setup.sql:78) — type fit not verified here; flagged for Class B. (7) Enforcement hard gates (quick ≥30 min :1145; standard needs recall :1178) return `validation_errors` WITHOUT a `display` → raw JSON. (8) Free path: no `bridgeScarsToUsageRecords`, no thread sync, `scars_to_record` written locally only if passed explicitly (:292-305); confirmations/reflections never persisted anywhere durable. | (1) High; (3) High; (2) Medium; (5)(7)(8) Medium; (4)(6) Low | +| **create_learning** (def :213-281; src/tools/create-learning.ts) | "Create scar, win, or pattern entry in institutional memory." Scars require severity + ≥2 counter_arguments. | `display`: "{icon} Created {type}: "{title}"\n{sev} ID: {uuid}" (:322). Failure: "Failed to create learning: {msg}" (:336). Result has `durable`, `stored_in`, `embedding_generated` — not displayed. | Pro: `directUpsert` with `return=representation`, throws on empty result (supabase-client.ts:453-461); id re-checked (:233-238) — genuinely read-after-write. Triples fire-and-forget (:243-255). `flushCache` fire-and-forget full reload (:277). Free: local upsert awaited. | (1) **Silent unreachability on pro without an embedding provider**: stored with no `embedding` (:213-215 warns to stderr only); the local index and the remote RPC both require an embedding, so `recall`/`search` can never surface it; display still says "Created". `embedding_generated:false` is in the result but not the display. (2) **Zod accepts `why_this_matters`, `action_protocol`, `self_check_criteria`** (src/schemas/create-learning.ts) and the handler writes them (:119-121) — none are in definitions.ts :215-278. `action_protocol`/`self_check_criteria` are arrays in Zod but `TEXT` columns in setup.sql:30-31 → an array write on a customer store is a type mismatch (not exercised here). (3) `durable`/`stored_in` omitted from display; free-tier "Created" is indistinguishable from a Supabase write. (4) Triple write predicates must satisfy setup.sql:199 CHECK — see graph_traverse row. (5) `scar_type` default "process" vs setup.sql default 'operational' (setup.sql:17) — cosmetic. | (1) High; (2) Medium; (3) Medium; (4)(5) Low | +| **create_decision** (def :283-332; src/tools/create-decision.ts) | "Log architectural/operational decision to institutional memory" | `display`: "Decision logged: "{title}"\nID: {uuid}". Failure display: **"Failed to log decision"** with no reason (:158). | Pro: `directUpsert` awaited (row returned). `session_id` has FK to `gitmem_sessions(id)` (setup.sql:114): if session_start's fire-and-forget row never landed, the insert fails with a 409/23503 that the display reduces to "Failed to log decision". Free: local upsert. | (1) **Error message swallowed** — the agent cannot tell FK-race from RLS from network. (2) `durable`/`stored_in` not displayed. (3) Embedding absent ⇒ decision stored but not semantically searchable (no stderr warning even, unlike create_learning). | (1) Medium; (2)(3) Low | +| **record_scar_usage** (def :334-387; src/tools/record-scar-usage.ts) | "Track scar application for effectiveness measurement" | `display`: "Scar usage recorded\nID: {uuid}"; failure: "Failed to record scar usage: {msg}". | Pro: `directUpsert` awaited (row returned). `scar_id` FK → learnings, `session_id` FK → sessions (setup.sql:134-135). Free: local upsert. | (1) `durable`/`stored_in` not displayed. (2) `variant_id` param described as "UUID of the assigned variant from scar_enforcement_variants (for A/B testing)" — variants are dev-only; on pro/free nothing ever assigns one. (3) `session_id` description "GitMem session UUID (for non-issue session tracking)" — handler also auto-fills from current session (:41), fine. | Low | +| **record_scar_usage_batch** (def :389-453; src/tools/record-scar-usage-batch.ts) | "Track multiple scar applications in a single batch operation (reduces session close latency)"; `scar_identifier` "UUID or title/description of scar (tool resolves to UUID)". | No `display`, no `text` → raw JSON of `{success,durable,stored_in,usage_ids,resolved_count,failed_count,failed_identifiers,performance}`. | Registered only when `hasBatchOperations()` (dev) (definitions.ts:2541-2543). Pro path (reached internally from session_close :1607) inserts each row with `directUpsert` in parallel; a single failure rejects the whole `Promise.all` (:175) and returns `notStored()` even for rows that did land (`usage_ids` still empty). Free: returns `success:true, stored_in:null` (:105-114) — contradicts write-result.ts:15-16 ("false whenever nothing was stored"). | (1) Not a customer-facing tool (dev-only) — description is fine for dev. (2) Partial-failure reporting: rows written before the first rejection are reported as not stored. (3) Free-tier `success:true` with nothing stored. (4) Title resolution loads up to 100 learnings and substring-matches title OR description (:66-83) — a short identifier can resolve to the wrong scar silently. | (2)(4) Medium (internal); (3) Low | +| **save_transcript** (def :455-481; src/tools/save-transcript.ts) | "Save full session transcript to storage for training data and post-mortems" | No `display`/`text` → raw JSON `{success,durable,stored_in,transcript_path,size_bytes,size_kb,estimated_tokens,patch_warning,performance}` or `{…notStored(), error}`. | Registered only on dev (`hasTranscripts`, definitions.ts:2544). Writes to Supabase Storage bucket `session-transcripts` (supabase-client.ts:804) and PATCHes `sessions.transcript_path`. setup.sql creates no bucket; session_close additionally gates on a `transcript_chunks` table (session-close.ts:1479) that setup.sql does not create. | (1) Not customer-visible (dev-only) — but `session_close` description/docstring (:186-188, :1457) still describes transcript capture as default for cli/desktop; on a customer store it is silently skipped. (2) `writeResult(true)` asserts durable on any non-throwing upload; `patch_warning` (sessions row PATCH failed) is not a failure. | Low (dev-only); Medium as a session_close side-promise | +| **get_transcript** (def :483-495; src/tools/get-transcript.ts) | "Retrieve a session transcript from storage" | Raw JSON (no display/text): `{success, transcript, transcript_path, size_bytes}` or `{success:false,error}`. | dev-only. Reads `sessions.transcript_path` then downloads from bucket. Full transcript is returned inline as JSON — no size cap. | (1) dev-only; fine. (2) Unbounded payload into the agent context. | Low | +| **search_transcripts** (def :497-522; src/tools/search-transcripts.ts) | "Semantic search over session transcript chunks… calls match_transcript_chunks RPC… across all indexed sessions." | `display` on success (:196); on any error returns `{error}` with no display → raw JSON. | dev-only. Reads `process.env.SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` **directly** (:124-125), not `getProConfig()` — a store configured via `config.json` (the `activate` path) gets "Supabase configuration missing". `match_transcript_chunks` RPC and `transcript_chunks` table absent from setup.sql. | (1) dev-only. (2) Credential source inconsistent with every other tool. (3) `project` filter passed as `filter_project` — fine. | Low (dev-only) | +| **search** (def :527-558; src/tools/search.ts) | "Search institutional memory by query… returns matching scars/wins/patterns without side effects." `project`: "Scopes sessions and searches." | `display`: product line "N results", citation line, per-result `type sev title (sim) issue [low confidence]` + description + `id:xxxxxxxx`; "No results found." on empty. | Pro: local index (cross-project, `project` ignored) else `cachedScarSearch` → `match_gitmem_learnings_weighted` (absent on customer store). **Every error path renders "No results found."** (:218-230, :350-363) — a failed query and an empty result are identical to the agent. Free: BM25 over learnings.json + token match over decisions.json. | (1) **Failure = empty**: no error disclosure on pro (unlike recall). (2) **recall advertises `Full body: search("")`** (recall.ts:387) but search has no id lookup — BM25 docs index title/keywords/description only (local-file-storage.ts:166-172) and the vector path embeds the UUID string; an id query returns noise or nothing. Promise not implemented. (3) Pro path searches learnings only; free path also searches decisions (:156-193) — scope differs by tier and neither matches the description exactly. (4) `project` not applied on pro (local index unified; RPC fallback omits project). (5) No embedding provider on pro → `[]` → "No results found." (6) Pro `is_starter` never set (:295-304) so the `[starter]` tag only exists on free. | (1)(2) High; (3)(4)(5) Medium; (6) Low | +| **log** (def :560-590; src/tools/log.ts) | "List recent learnings chronologically (like git log). Shows scars, wins, and patterns ordered by creation date." | `display`: product line "N most recent · project", per-entry `type sev title reltime issue`, totals by type; "No entries found." on empty. | Pro: `directQuery(learnings)` with `project`, `is_active=eq.true`, optional type/severity/since — honest scope. **Error path renders "No entries found."** (:283-294). Free: local list + decisions merged (unless type/severity filter). | (1) Failure = empty on both tiers (:191-200, :283-294). (2) Free tier merges decisions (rendered as `[d]`) — description says scars/wins/patterns only; pro shows no decisions. (3) `since` uses `created_at` ISO string compare on free (:167) — fine. | (1) Medium; (2) Low | +| **prepare_context** (def :596-626; src/tools/prepare-context.ts) | "Generate portable memory payload for sub-agent injection… compact or gate payloads that fit in Task tool prompts." `agent_role` "reserved for Phase 3". | `display`: product line `format · N scars (M blocking) · ~T tokens` + payload. Error path renders `[INSTITUTIONAL MEMORY — error: …]` (:378-388) — disclosed. | Read-only; same search pipeline as search (local index / RPC fallback / BM25). `matchCount` fixed at 5 (:283). | (1) `agent_role` ignored — documented. (2) Same customer-store RPC/`project`/no-embedding caveats as search, but errors are disclosed here. (3) Free path drops `why_this_matters`/`action_protocol`/`self_check_criteria` (:291-298) so "full" format is thinner on free. (4) `required_verification` only ever exists on nTEG rows (not in setup.sql) — "gate" format is always empty on a customer store. | (4) Medium; others Low | +| **absorb_observations** (def :632-658; src/tools/absorb-observations.ts) | "Capture observations from sub-agents and teammates… calls this to persist and analyze them. Identifies scar candidates." | `display`: "Absorbed N observations (M scar candidates)" + suggestions. | In-memory only (`addObservations`, session-state.ts:537-556 — **not written to session.json**). Supabase persist is probe-gated on `sessions.task_observations` (:71-80), absent from setup.sql. session_close free path never reads observations (session-close.ts:223-344); pro path probe-drops `task_observations` (session-columns.ts:107-121). `addObservations` returns 0 with no session (:538-541) — display then says "Absorbed 0 observations". | (1) **"persist" is untrue on every customer store and on free**: observations live in process memory until close, then are dropped. An MCP restart loses them (no file write). (2) With no active session the tool reports "Absorbed 0" but still lists suggestions; no explicit "no session" message. | (1) High; (2) Low | +| **list_threads** (def :663-685; src/tools/list-threads.ts) | "List open threads across recent sessions… Use resolve_thread to mark threads as done." `status` ∈ {open,resolved}; `include_resolved`. | `display`: product line "N open · M resolved" + markdown table (#, 10-char id, text, date). "No threads found." on empty. | Pro: `threads_lite` scoped by project via `resolveThreadScope` (thread-supabase.ts:370-397); Supabase null (unconfigured/failed) → session aggregation → local file. `source` is in metrics only, not display. | (1) Fallback source (Supabase vs aggregation vs file) is not disclosed in display — a failed Supabase read silently shows local threads. (2) On pro with `status:"open"`, `total_resolved` is hard-coded 0 (:158-160) — the header "· 0 resolved" is not a count. (3) `resolveThreadScope` may cap/hide dormant threads (thread-scope.ts) — no omission line here, unlike session_start. | (1)(2) Medium; (3) Low | +| **resolve_thread** (def :687-708; src/tools/resolve-thread.ts) | "Mark an open thread as resolved… Updates session state and .gitmem/threads.json." | `display`: "Thread resolved: …"; on pro-not-durable: **"RESOLVED LOCALLY ONLY — not durable (stored_in: local_only, durable: false)"** (:284-290); cross-session/project refusals disclosed (:160-164). | Local file saved first (:196), Supabase `resolveThreadInSupabase` awaited (:202), outcome drives `writeResult` (:283). **This is the one tool whose display states `durable`/`stored_in`.** Triples fire-and-forget with `project: "default"` hard-coded (:246, :259). | (1) Thread-resolution triples always land in project "default" — `graph_traverse` with any other `project` never sees them. (2) Description says only ".gitmem/threads.json" but the durable write is Supabase on pro — under-promise, fine. (3) `#N` positional ids resolve against in-memory/file threads, not the Supabase list the user saw in list_threads (:83-99) — ordering can differ. | (1) Medium; (3) Medium; (2) Low | +| **create_thread** (def :710-727; src/tools/create-thread.ts) | "Create an open thread… Includes semantic dedup: if a similar open thread exists (cosine similarity > 0.85), returns the existing thread instead. Check the 'deduplicated' field in the response." Schema: `text`, `linear_issue`. | `display`: "Thread created: … ID: … · N open threads · stored in supabase/local file"; dedup refusal "NOT STORED … (reason: duplicate_candidate)"; pro-failure "SAVED LOCALLY ONLY — not durable". | Best-in-class: post-write read-back from the store rendered (:363-372). | (1) **Description untrue**: dedup no longer "returns the existing thread" — it REFUSES with no `thread` field (:217-281, comment :263-265). `deduplicated` is in the result but **not in the display** (display says NOT STORED, which is the honest part). (2) **Handler reads `project` and `allow_duplicate`** (:48-61, :136, :217) — neither is in definitions.ts :713-726 (no Zod schema either). The display tells the agent to pass `allow_duplicate: true` (:255) — a parameter the advertised schema does not have. (3) Dedup candidate set is own-session only on pro (`scope.sessionId`, :184) — "if a similar open thread exists" is scoped narrower than the sentence implies. (4) `linear_issue` only reaches triples (:350), not the row. | (2) High; (1) Medium; (3)(4) Low | +| **promote_suggestion** (def :732-750; src/tools/promote-suggestion.ts) | "Takes a suggestion_id from session_start's suggested_threads list and creates a real thread from it." | `display`: "Promoted suggestion to thread\nID: …"; failure "Failed to promote suggestion" (reason only in `error`, not display :96). | Delegates to create_thread; suggestions file written only by session_close's embedding chain (session-close.ts:1576-1594). | (1) **`suggested_threads` is produced by no tool**: only `types/index.ts:192` mentions it; session_start removed it (session-start.ts:45 "Suggested threads removed from start display"). The agent has no way to learn a `suggestion_id`. (2) Suggestions require pro + embeddings; on free the file never exists. (3) Failure display drops the reason. | (1) High (dead feature advertised); (3) Low | +| **dismiss_suggestion** (def :752-765; src/tools/dismiss-suggestion.ts) | "Dismiss a suggested thread. Incremented dismiss count — suggestions dismissed 3+ times are permanently suppressed." | `display`: "Dismissed suggestion: id" / "Suggestion not found". | Local suggestions.json only. | Same as promote: nothing surfaces suggestion ids. No Zod schema. | Medium (dead feature) | +| **cleanup_threads** (def :770-787; src/tools/cleanup-threads.ts) | "Triage open threads by lifecycle health… Use auto_archive=true to archive threads dormant 30+ days." | `display`: product line counts + per-group tables + "Archived: N". | Pro: `threads_lite` project-filtered, `archiveDormantThreads` awaited when requested; errors propagate to server's generic error JSON. Free: local threads with `touch_count:1`, `thread_class:"backlog"` assumed; `archived_count` always 0. | (1) Free tier silently ignores `auto_archive` (always "Archived: 0") — no notice. (2) Free vitality is computed from fabricated defaults (:143-147). (3) No Zod schema. | Low–Medium | +| **health** (def :792-804; src/server.ts:367-383) | "Show write health for the current session. Reports success/failure rates for all tracked fire-and-forget operations (metrics, cache, triple writes, embeddings, scar usage). Use this to diagnose silent failures." | `text` = write-path verdict line (GIT-102) + effect-tracker summary or "No tracked effects this session." | Reads in-process effect tracker + last write-path verdict. | (1) **Not "all" fire-and-forget writes**: session_start's session-row insert (`registerPendingSessionRow`, metrics.ts:156-158) is not tracked; `flushCache`, `archiveDormantThreads`, `refreshBehavioralScores`, `markSessionSuperseded`, `updateRelevanceData` (tracked as `relevance_update` — ok) — the first four are `.catch(console.error)` only. (2) Per-process only — an MCP restart empties it; not disclosed. (3) Free tier: nothing is ever tracked except feedback; the tool exists but is mostly empty. | (1) Medium; (2)(3) Low | +| **analyze** (def :1944-1974; src/tools/analyze.ts) | "Session analytics and insights engine. Returns formatted markdown by default. Use format=json for raw data." Lenses summary/reflections/blindspots. | `text` (markdown) on success; `{success:false,error}` raw JSON on failure (no display/text). Free: honest "Not Available in Local Mode" text. | Registered only when `hasSupabase()` (definitions.ts:2550-2551). Reads `sessions`, `scar_usage`, `learnings` (analytics.ts). `blindspots` needs `repeat_mistake` columns — present in setup.sql. | (1) `format` is read by the handler (:182) but absent from `AnalyzeParamsSchema` (src/schemas/analyze.ts) — works only because Zod is non-strict. (2) Metrics row recorded with `tool_name:"search"` (:173) — pollutes search metrics. (3) Pro Insights (`hasProInsights`) depend on ≥5 sessions with usage rows; empty results are not distinguished from "not enough data". | Low | +| **archive_learning** (def :2141-2157; src/tools/archive-learning.ts) | "Archives a learning… by setting is_active=false and recording archived_at timestamp. Archived learnings are excluded from recall and search results but preserved for audit trail." | `display`: "Archived learning {id}. Reason: … (Nms)". Result includes `archived_at`. | Pro: `directPatch` awaited (returns rows, not checked for 0 matches); `archived_at` probe-dropped when the column is absent (:132-136) — **setup.sql has no `archived_at`**, so on every customer store it is never recorded while the result still carries `archived_at`. `flushCache` awaited. Free: local upsert. | (1) "recording archived_at timestamp" untrue on customer stores; result field is fabricated. (2) `directPatch` with an id that matches 0 rows returns `[]` and the tool still says "Archived" (full-UUID path skips the existence check, :49-51). (3) `durable`/`stored_in` not displayed. | (1)(2) Medium | +| **graph_traverse** (def :2198-2233; src/tools/graph-traverse.ts) | "Traverse the knowledge graph over institutional memory triples… Four lenses." | `display` = summary sentence(s); full `node`/`chain`/`stats` objects are **not** in display. Errors disclosed ("Graph traversal failed: …"). | Registered only on pro/dev. Queries literal `knowledge_triples` (matches setup.sql:196) filtered by `project`. `provenance`/`stats` fetch up to 2000 rows client-side (:173-180). | (1) Display omits the triples themselves — the agent gets counts and a 5-edge-per-hop précis only; the full graph data exists in the result but never reaches the client. (2) Triples written by resolve_thread are always project "default" (see above). (3) setup.sql predicate CHECK (:199) includes every predicate triple-writer emits (created_in, influenced_by, supersedes, affects_doc, created_thread, relates_to_thread, resolves_thread) — OK; but the definitions `predicate` enum lists only 4 of 8 (def :2214). (4) No Zod schema; `depth`/`limit` unvalidated. | (1) Medium; (3) Low | +| **contribute_feedback** (def :2297-2331; src/tools/contribute-feedback.ts) | "Always saved locally to .gitmem/feedback/. If opted in, sent anonymously to improve gitmem. 10/session limit." | `display`: "Feedback recorded: {uuid} (N remaining)… Saved to .gitmem/feedback/{file} (queued for remote)". | Local write is synchronous (`writeFileSync`, :105); remote is effect-tracked fire-and-forget to `https://cjptxyezuxdiinufgrrm.supabase.co/rest/v1/community_feedback` (feedback-remote.ts:9); `remote_submitted:true` means queued, and the display says so. | (1) "anonymously" — payload carries a persistent `install_id`, `session_id`, `agent_identity` (:110-134); text is sanitized. (2) Display path is relative ".gitmem/feedback/…" while the real dir is `~/.gitmem` (getGitmemDir) — the `path` field is absolute but not displayed. | Low | +| **index_docs** (def :2381-2410; src/tools/index-docs.ts) | "Scan a directory of markdown files, chunk them, embed them, and store them in a local doc index… incremental indexing." | `display`: counts (files scanned/changed/unchanged, chunks indexed/embedded, errors, categories, totals). | Local `~/.gitmem/docs-index.json` (doc-index.ts:59-66). Embeddings only if a provider exists; otherwise `chunks_embedded: 0` is shown. | (1) "embed them" is conditional; the display shows `Chunks embedded: 0` so it is disclosed. (2) No Zod schema — `directory` is unvalidated (absolute path required, any path readable by the server is indexed). (3) 20MB warning is stderr-only (:99). | Low | +| **search_docs** (def :2412-2436; src/tools/search-docs.ts) | "…semantic similarity (pro/dev tier) or BM25 keyword search (free tier). Returns relevant chunks with file paths… Index docs first with index_docs." | `display`: results with file path, section, similarity, content; explicit "no docs indexed" state; "No matching docs found." with index stats. | Local index; vector path only when embeddings exist, BM25 otherwise (doc-index.ts:257-262, :281). | (1) Mode actually used (vector vs BM25) is not disclosed in display; a pro user without an embedding key gets BM25 silently. (2) No Zod schema. | Low | + +#### Tier-gated / dev-only claims summary (tools) +- `hasVariants`, `hasTranscripts`, `hasBatchOperations`, `hasCompliance`, `hasEnforcementFields` are all `tier === "dev"` (tier.ts:112-135). Customer-facing descriptions that still mention these: recall (`issue_id` variants), record_scar_usage (`variant_id`), session_close docstring (compliance validation, transcripts). +- `hasProInsights`/`hasMetrics` are pro+dev but depend on `gitmem_query_metrics` / `gitmem_scar_usage` rows that only accumulate when the fire-and-forget writes succeed. + +--- + +### Part 2 — CLI commands (bin/gitmem.js, bin/init-wizard.js, bin/uninstall.js, src/commands/*.ts) + +**Root mismatch (applies to several rows):** the server, hooks (`hooks/scripts/resolve-root.sh`) and `migrate-root` resolve the store as `GITMEM_DIR` → `GITMEM_HOME/.gitmem` → `~/.gitmem` (src/services/gitmem-dir.ts:57-97, CHANGELOG:197). The CLI commands `init` (bin/init-wizard.js:25,169), `init-scars` (bin/gitmem.js:122), `install-hooks` (bin/gitmem.js:532), `telemetry` (src/commands/telemetry.ts:26-28) and `uninstall` (bin/uninstall.js:125) use `process.cwd()/.gitmem`. + +| command | what --help/docs promise | prints on success | verifiable | gap | severity | +|---|---|---|---|---|---| +| **init** (bin/init-wizard.js) | "Interactive setup wizard (recommended)"; README:35 "`.gitmem/` directory with starter scars"; footer "installed successfully!" | Per-step ✔ lines: "Created .gitmem/ — your local memory store / N lessons…", "Configured MCP server", "Created CLAUDE.md", "Auto-approved gitmem tools", "Added automatic memory hooks", "Updated .gitignore", AGENTS.md, feedback opt-in; then "gitmem installed successfully!" | Writes `/.gitmem/{config.json,learnings.json,threads.json,sessions.json,decisions.json,scar-usage.json,closing-payload.json,closing-payload-template.json,hooks/}` (:169, :396-548). Does not start the server or verify anything. | (1) **Writes the memory store to `/.gitmem`, which the server does not read** (server root is `~/.gitmem`, gitmem-dir.ts:93-95). Starter scars, `config.json.project`, and the closing-payload template land in a root the server treats as *stranded*; the very next `session_start` prints "Memory store found but NOT being read … npx gitmem-mcp migrate-root" (session-start.ts:966-983). "installed successfully" is untrue for the memory-store step on the free tier. (2) `init` writes a pre-filled `closing-payload.json` with empty answers and a `task_completion` of nulls (:452-479); if it were in the read root, session_close would consume it as a real payload (`payloadConsumed=true`, session-close.ts:984-992) with blank reflections. (3) Writes `feedback_enabled:false, telemetry_enabled:false` into config; `telemetry_enabled` is never read by the server (no `getTelemetry` call outside the CLI). (4) `--project` sets config in the unread root. (5) Hook set installed here includes `UserPromptSubmit → auto-retrieve-hook.sh` (:286-296) which `install-hooks` does not install — the two installers disagree. (6) `buildMcpConfig` writes a placeholder `""` into `.mcp.json` when `SUPABASE_URL` is set but `OPENAI_API_KEY` is not (:265-266) — a literal placeholder becomes the embedding key. | (1) High; (2)(5)(6) Medium; (3)(4) Low | +| **init-scars** (legacy `cmdInit`, bin/gitmem.js:100-307) | Not in help. Free: "initializing free tier (local storage)"; pro: "Loading N starter scars into Supabase". | "Done: N new scars added to .gitmem/learnings.json" or "Done: N loaded, M failed" + "Starter scars are ready!". | Free: `/.gitmem` (unread root). Pro: POSTs each scar with `Prefer: return=representation` to `${prefix}learnings` — HTTP status only; starter-scars.json fields are sent verbatim (no column filtering). | (1) Same root mismatch. (2) Pro path reads env vars only (not config.json). (3) "Starter scars are ready! … recall(…)" — on pro, starter scars have no embedding (none generated) so recall's local index will never include them (index requires embedding). | (1)(3) Medium | +| **setup** (bin/gitmem.js:85-98) | "Output SQL for Supabase schema setup (pro/dev tier)"; header comments "Copy and paste this SQL into your Supabase SQL Editor". | The full `schema/setup.sql` to stdout. | Applies nothing, verifies nothing (by design). | (1) Prints the **license-management section too** (setup.sql:504-665 — `gitmem_licenses`, `gitmem_license_activations`, `gitmem_validate_license`, `gitmem_deactivate_device`) which `activate` deliberately strips (activate.ts:172-176); a customer pasting the output creates gitmem's admin tables and a SECURITY DEFINER RPC in their own project. (2) The SQL it prints does not define the RPCs the server calls (`match_gitmem_learnings*`), so "schema setup" leaves semantic fallback and `search` broken on a cold index. | (1) Medium; (2) High (root cause tracked above) | +| **activate** (src/commands/activate.ts) | "Activate Pro tier (license key + credentials wizard)"; docstring "4. Test Supabase connection (verify tables exist)". | "✓ Key validated (pro tier)", "✓ Connected to Supabase", "✓ Schema verified (all tables present)" or "✓ Schema applied automatically", OpenRouter status, migration counts, "Pro tier activated! Restart your editor to apply." + config path. | Validates key via remote RPC (device registered). `checkSchemaExists` probes **4 tables only** (`gitmem_learnings/sessions/decisions/scar_usage`, :105) — not threads, knowledge_triples, query_metrics, views or RPC functions. Writes `~/.gitmem/config.json` (getGitmemDir — correct root). Migration is per-record HTTP with a log. | (1) "Schema verified (all tables present)" is a 4-table check; a store missing `gitmem_threads`/`knowledge_triples`/`gitmem_query_metrics` passes. (2) `applySchemaViaPg` does `await import("pg")` (:193) but **`pg` is not a dependency** (package.json deps: sdk, uuid, zod) — the DATABASE_URL path fails with a module-not-found unless the user's project happens to have `pg`. The wizard still advertises it (:465-468). (3) Connection test skipped when both creds come from config (:368) — re-activation never re-verifies. (4) Non-TTY with bad creds: "credentials saved anyway" then tier is optimistically pro at next start; the server's write-path check catches it only on stderr (write-health.ts). (5) Hard-codes `gitmem_` table names in `checkSchemaExists` — ignores `GITMEM_TABLE_PREFIX`. (6) Threads are not migrated (documented in migrate-local.ts:14-15) but the summary does not say so. (7) Migration `TEXT_COERCE_FIELDS` (migrate-local.ts:116-120) confirms the schema/code type drift for `action_protocol`/`self_check_criteria`. | (2) High; (1)(3)(4)(5) Medium; (6)(7) Low | **[Corrected in §11: `pg` IS a dependency, package.json:42 — not a finding.]** +| **deactivate** (src/commands/deactivate.ts) | "Remove Pro credentials from config"; docstring: calls remote RPC, removes keys, clears cache. | "✓ Device deactivated", then "Pro tier deactivated. - Device removed from license server - License key removed … - Supabase and OpenRouter credentials removed - License cache cleared". | Config rewritten (correct root); cache cleared. | (1) Summary line "Device removed from license server" is printed unconditionally in the `hadKey` branch (:97-101) even when the remote call failed (the failure was printed above as "⚠ Server deactivation failed"). (2) Embedded anon key for the license project is in source (:20) — by design, noted. | (1) Medium | +| **check** (src/commands/check.ts) | "Run diagnostic health check"; `--full` "Full diagnostic with benchmarks". | Health lines: supabase / embedding / cache / recall / write with ✓/✗/○, "Report saved to: …", "Summary: N passed…", "✓ All health checks passed." | Supabase check = GET `gitmem_learnings?select=id&limit=1`; embedding = provider `/models` ping; cache = local cache stats; **recall = a local cache read** (:278-296); **write = a local cache write** (:298-314). Report written to `~/.gitmem/diagnostic-.json`. | (1) **"recall: Recall infrastructure operational" and "write: Write operations functional" never touch Supabase or the learnings store** — they test the result cache only; a customer whose writes 404 gets "All health checks passed". (2) Reads `SUPABASE_URL`/`SUPABASE_SERVICE_ROLE_KEY` **from env only** (:81-82): a store configured through `activate` (config.json) has `hasSupabase()` true but `supabaseUrl` undefined → "Supabase connection failed: … undefined/rest/v1…". (3) Hard-codes `gitmem_` prefix (:145, :354, :452) ignoring `GITMEM_TABLE_PREFIX`. (4) Embedding detection ignores `config.json.openrouter_key` (:83-99) — reports "No embedding provider configured" for activate-configured users. (5) Does not check RPC functions, `knowledge_triples`, views, or the write path (`checkWritePath` exists in write-health.ts but is not used here). | (1)(2) High; (3)(4)(5) Medium | +| **configure** (bin/gitmem.js:309-360) | "Generate .mcp.json config for Claude Code / Cursor". | JSON snippet + instructions. | Prints only. | (1) Pro snippet omits `GITMEM_API_KEY`; the "backward compat" tier path (tier.ts:48-56) makes SUPABASE_URL-without-key resolve to pro, so it works, but the license flow is bypassed. Low. | Low | +| **install-hooks** (bin/gitmem.js:491-701) | "Install hooks (standalone)"; "Copy hook scripts to .gitmem/hooks/"; `--force` "reinstall (overwrite)". | "Hooks written to .claude/settings.json", "Scripts at: .gitmem/hooks/", MCP-not-detected warning, "Installed! Hooks will activate on next Claude Code session." | Copies `hooks/scripts/*.sh` to `/.gitmem/hooks/` (:532-544); writes `settings.hooks = gitmemHooks` **wholesale** (:659) — with `--force`, or when no existing hook mentions "gitmem", **all pre-existing non-gitmem hooks are replaced**. `init-wizard` merges instead (:762-771). | (1) **Destroys user hooks** on `--force` or first install into a settings.json that has non-gitmem hooks (the `hasGitmem` early-return only fires when gitmem hooks exist). (2) Hook set differs from `init` (no `UserPromptSubmit/auto-retrieve-hook.sh`). (3) cwd assumption is correct for hook scripts (Claude Code runs hooks from project root and the command is `bash .gitmem/hooks/x.sh`), but the copied scripts read state from `~/.gitmem` via resolve-root.sh — the `.gitmem/hooks` dir in the project is *only* scripts, which is fine, but `session-start.sh` still reads project from `./.gitmem/config.json` (cwd) while everything else reads `~/.gitmem/config.json` — two different `project` values can be in play. | (1) High; (2)(3) Medium | +| **uninstall-hooks** (bin/gitmem.js:735-875) | "Remove hooks (standalone)"; docstring "removes from .claude/settings.json". | "[uninstall] Removed hooks from .claude/settings.json", cleanup lines, "Uninstall complete." | For Claude: `delete cfg.hooks` (:805-808) — **removes every hook, not only gitmem's** (the Cursor branch and bin/uninstall.js filter by `includes("gitmem")`). | (1) Deletes non-gitmem hooks. (2) `.gitmem/hooks/` scripts are not removed. | (1) High | +| **uninstall** (bin/uninstall.js) | "Clean removal of gitmem from project"; `--all` "Also delete .gitmem/ data directory"; README:130 "(preserves `.gitmem/` data)". | Per-step ✔/·: stripped CLAUDE.md, removed MCP server, removed hooks (filtering), removed permissions, ".gitmem/ preserved" or "Deleted .gitmem/ directory", "Cleaned .gitignore", "gitmem-mcp has been removed." | Operates on `/.gitmem` (:125). | (1) `--all` deletes `/.gitmem` only; the live store `~/.gitmem` (sessions, threads, learnings on free) is untouched while the output says "Deleted .gitmem/ directory". (2) `--yes` answers the "Keep .gitmem/?" prompt with its default (keep), so `--yes --all` is the only deleting path — consistent with help. | (1) Medium | +| **migrate-root** (src/commands/migrate-root.ts) | "Copy a project-scoped .gitmem into ~/.gitmem"; `--dry-run`. | Plan: files copied, memory files merged with counts, NOT-merged failures, backup path, conflicts file, skipped list, "The source was NOT modified." | Copy-never-move; merge by id with backup and conflict log; dry-run walks the same plan (:232-234). Exit code 1 on unmergeable files. | Honest. (1) `sessions.json` merge treats a sessions list by id; per-session dirs (`sessions//`) are copied as ordinary files only if absent. (2) Skips `cache/`, `license-cache.json`, `backups/` by design and says so. | None material | +| **migrate-local** (src/commands/migrate-local.ts, invoked by `activate`) | Docstring: migrates learnings/sessions/decisions/scar_usage; "Threads are NOT migrated"; idempotent upsert. | " Migrating N learnings…", " Migration log: …", per-collection "✓ col: N migrated, M failed", "✓ Migrated N records", archive note. | Per-record POST with `return=minimal` — HTTP status only, no read-back. Local files renamed to `.pre-migration` only when `totalMigrated > 0` (activate.ts:620-630), even if some records failed. | (1) Partial success archives the local files (moves them out of the free-tier read path) while failed records exist only in `.pre-migration` + `migration.log`; the re-import path exists (`reimportFromBackups`) but the summary says "Local data preserved" only in the zero-success case. (2) Arrays coerced to newline-joined TEXT for `action_protocol`/`self_check_criteria` — a lossy conversion of structured data, undisclosed to the user. | (1) Medium; (2) Low | +| **session-start** / **session-refresh** CLI (bin/gitmem.js:371-480) | Not listed in `--help`. Docstring: "for hook scripts that need context without MCP". | session-start: `result.display`. session-refresh: hand-formatted block including `Relevant scars`, `Recent wins` sections. | Runs `sessionStart()` in a short-lived process: registers a session with the CLI's own `pid` (session-start.ts:844-851), writes `sessions//session.json`, then exits. Hooks skip registry entries whose pid is dead (`gitmem_live_session_ids`, resolve-root.sh:67-70); the server's `findSessionByHostPid(process.pid)` never matches; `findResumableSessionOnDisk` may adopt it (dead-pid candidates are allowed, active-sessions.ts:331+). `session-start.sh` itself says CLI invocation "was removed because the CLI PID dies immediately" (hooks/scripts/session-start.sh:~225). | (1) Legacy, undocumented, and contradicts the identity model — creates a registry entry that every hook treats as dead and that Stop-hook enforcement will ignore. (2) `session-refresh` renders `relevant_scars` / `recent_wins` fields (:442-468) that `sessionRefresh` no longer returns (session-start.ts:1424-1438) — dead output. (3) On free tier `session-refresh` prints "No active session"? No — free returns `session_id` set, so it prints a header only. | (1) Medium (dead surface); (2) Low | +| **telemetry** (src/commands/telemetry.ts) | help: "status / enable / disable / show / clear"; enable text: "All events logged to .gitmem/telemetry.log"; show: "Events are logged automatically as you use gitmem tools." | status/enable/disable/show/clear blocks as shown in the file. | Reads/writes `/.gitmem/telemetry.log` and config via `getTelemetry` (src/lib/telemetry.ts). **No code in `src/server.ts`, `src/index.ts` or any tool imports `getTelemetry`** (grep: only `src/lib/telemetry.ts` and `src/commands/telemetry.ts`). | (1) **"Events are logged automatically as you use gitmem tools" (:180) is untrue** — nothing in the server emits telemetry events; enabling telemetry sends nothing. (2) `getPackageVersion()` reads `process.cwd()/package.json` (:17-20) — the *user's* project version, not gitmem's. (3) Uses `/.gitmem` rather than the server root. (4) `init-wizard` writes `telemetry_enabled:false` to `config.json`; `telemetry enable` writes via `Telemetry.enable()` — whether both read the same key is not verified here. | (1) High (dead feature with a privacy narrative); (2)(3) Medium | +| **server** / default (bin/gitmem.js:922-934) | "Start MCP server (default)". | stderr: `[gitmem] Tier: … | Storage: … | Tools: N`, cache/licence lines, write-path verdict (write-health.ts). | Registration computed once with the optimistic tier (see cross-cutting). | (1) Tool list not recomputed after license downgrade. (2) All durability/tier warnings are stderr-only; `health` is the only in-band surface. | Medium | +| **help** | Usage text (bin/gitmem.js:35-83). | — | — | Omits `init-scars`, `session-start`, `session-refresh`, `telemetry show/clear`; the "Pro Tier (with Supabase)" quick start (:76-81) describes the env-var path and `init`, not `activate`, and step 4 says "init (auto-detects pro tier)" — `init-wizard` only inserts env vars into `.mcp.json`, it does not detect or validate a tier. | Low | + +--- + +### Summary 1 — Top 10 gaps, ranked + +1. **`init` provisions the memory store in a root the server never reads.** `bin/init-wizard.js:25,169` writes `/.gitmem/{learnings.json,config.json,threads.json,closing-payload.json}`; the server, hooks and `migrate-root` resolve `~/.gitmem` (`src/services/gitmem-dir.ts:93-95`, `hooks/scripts/resolve-root.sh:31-36`). The first `session_start` after a fresh free-tier install reports the just-created directory as "Memory store found but NOT being read" (`src/tools/session-start.ts:966-983`). Same defect in `init-scars` (`bin/gitmem.js:122`), `telemetry` (`src/commands/telemetry.ts:26-28`), `uninstall --all` (`bin/uninstall.js:125`). +2. **Semantic-search RPCs the server calls do not exist in `schema/setup.sql`.** `src/services/supabase-client.ts:190` → `match_gitmem_learnings`; `:693` → `match_gitmem_learnings_weighted`; setup.sql defines only `gitmem_semantic_search` (:287) and `gitmem_scar_search` (:322). On a customer store: recall (cold index) shows an error banner; `search`, `prepare_context`'s fallback and `SupabaseStorage.search` (storage.ts:87-97) render "No results found." / `[]`. +3. **`search` and `log` render every failure as an empty result** (`src/tools/search.ts:218-230, 350-363`; `src/tools/log.ts:191-200, 283-294`). Combined with (2), a customer with a cold index sees "No results found." for a broken RPC. `recall` and `prepare_context` disclose errors; these two do not. +4. **Pro tier without an embedding provider is silently non-functional for retrieval.** `create_learning` stores rows with no `embedding` and prints "Created" (`src/tools/create-learning.ts:213-215, 322`); the local index skips them (`startup.ts:249`, `INDEXED_LEARNING_FILTERS`) and the RPC path returns `[]` (`supabase-client.ts:675-678`). `recall` then reports "No relevant scars found" and `clearRecallFailure()` (`recall.ts:572`) lets `confirm_scars` answer "Proceed freely" (`confirm-scars.ts:222`). `activate` merely warns "Skipped (semantic search will not work…)" (`activate.ts:520`). +5. **`session_close`'s advertised schema is a fraction of what it reads, and its stats are self-reported.** `definitions.ts:180-210` lists 6 params; Zod (`src/schemas/session-close.ts`) and the handler also read `decisions`, `open_threads`, `project_state`, `learnings_created`, `scars_to_record`, `task_completion`, `capture_transcript`, `transcript_path`, `close_type:"retroactive"`. "N learnings" = `params.learnings_created.length` (`session-close.ts:1308`); "N scars applied" counted before the fire-and-forget batch write (`:378`, `:1606-1611`) whose failure cannot appear in this close's display (`:1727`). `durable`/`stored_in` never rendered. +6. **`check` passes a broken store.** "recall: Recall infrastructure operational" and "write: Write operations functional" test the local result cache only (`src/commands/check.ts:278-314`); Supabase creds are read from env only (`:81-82`), prefix hard-coded (`:145,:354,:452`), embedding key from env only (`:83-99`). A config.json-activated Pro user gets "Supabase connection failed … undefined/rest/v1" while `hasSupabase()` is true. +7. **`install-hooks` / `uninstall-hooks` destroy non-gitmem hooks.** `bin/gitmem.js:659` assigns `settings.hooks = gitmemHooks` wholesale (no merge); `:806` `delete cfg.hooks` removes every Claude Code hook. `init-wizard.js:762-771` and `bin/uninstall.js` merge/filter correctly — the standalone commands do not. +8. **`session_start` display omits what the description promises and what `session_close` needs.** `last_session` is loaded but not rendered (`session-start.ts:919-1065`, used only at `:1178-1181`); `gitmem_dir` is not rendered although `session_close`'s description (`definitions.ts:179`) tells the agent to write `{gitmem_dir}/closing-payload.json`. Session-row insert is fire-and-forget with `stored` discarded (`:1149-1152`); failed `sessions_lite`/thread reads are stderr-only (`:204,:211`) and render as "No threads or decisions." +9. **Dead features advertised in tool descriptions.** `promote_suggestion`/`dismiss_suggestion` reference "session_start's suggested_threads list" (`definitions.ts:734`) that no tool produces (`session-start.ts:45`; only `types/index.ts:192` mentions it). `recall` advertises `Full body: search("")` (`recall.ts:387`) but `search` has no id lookup (`search.ts`; BM25 docs index title/keywords/description only, `local-file-storage.ts:166-172`). `telemetry` CLI says "Events are logged automatically as you use gitmem tools" (`telemetry.ts:181`) but nothing in `src/server.ts`/`src/index.ts`/tools imports `getTelemetry`. +10. **`activate`'s DATABASE_URL schema path cannot work as shipped.** `src/commands/activate.ts:193` `await import("pg")`; `package.json` dependencies are `@modelcontextprotocol/sdk`, `uuid`, `zod` only. The wizard advertises `DATABASE_URL` as an automatic option (`:465-468`). Its "Schema verified (all tables present)" (`:476`) probes 4 tables (`:105`) and no views/RPCs. **[Corrected in §11: `pg` IS a dependency, package.json:42 — not a finding.]** + +Honourable mentions (Medium): `absorb_observations` "persist" is memory-only on every customer store and free (`absorb-observations.ts:66-80`, `session-columns.ts:107-121`, `session-close.ts:223-344`); `archive_learning` fabricates `archived_at` on customer stores (`archive-learning.ts:132-136`; column absent from setup.sql); `resolve_thread` writes triples with `project:"default"` hard-coded (`resolve-thread.ts:246,259`); `session_refresh` returns raw JSON on free tier (`session-start.ts:1368-1376`); tool registration frozen at optimistic tier (`server.ts:119-124`, `:500-502`); `deactivate` prints "Device removed from license server" unconditionally (`deactivate.ts:105`); `setup` prints the license-admin SQL section (`bin/gitmem.js:93`, `setup.sql:504-665`). + +### Summary 2 — Description sentences simply untrue on a customer (setup.sql-provisioned, pro) store + +| quote | file:line | why untrue | +|---|---|---| +| "Integrates variant assignment when issue_id provided." | `src/tools/definitions.ts:23` | `hasVariants()` is dev-only (`src/services/tier.ts:112`); `recall.ts:586` never assigns on pro. | +| "Default: 0.4 (free tier BM25), 0.35 (pro tier embeddings)." | `src/tools/definitions.ts:46` | Pro default is 0.45 (`src/tools/recall.ts:436`). | +| "Project namespace … Scopes sessions and searches." (on `recall`, `search`, `prepare_context`) | `src/tools/definitions.ts:34, 543, 621` | Local index is cross-project and ignores `project` (`local-vector-search.ts:369-378`); RPC fallback omits `project_filter` (`supabase-client.ts:707`). | +| "UUID of the assigned variant from scar_enforcement_variants (for A/B testing)" | `src/tools/definitions.ts:382` | No variant is ever assigned on pro/free; setup.sql's `scar_enforcement_variants` lacks the `active` column the dev path reads (tier.ts:106-110 comment). | +| "DISPLAY: The result includes a pre-formatted 'display' field visible in the tool result." (session_refresh) | `src/tools/definitions.ts:165` | Free tier and no-session paths return no `display` (`src/tools/session-start.ts:1354-1361, 1368-1376`) → raw JSON. | +| "Write closing_reflection and other payload to {gitmem_dir}/closing-payload.json" | `src/tools/definitions.ts:179` | `gitmem_dir` is in the session_start result but never in its display (`session-start.ts:919-1065`); the agent is never shown the path. | +| "Semantic search over session transcript chunks … calls match_transcript_chunks RPC" | `src/tools/definitions.ts:499` | Neither the RPC nor `transcript_chunks` exists in setup.sql (dev-only tool; also reads env creds only, `search-transcripts.ts:124-125`). | +| "Capture observations … calls this to persist and analyze them." | `src/tools/definitions.ts:633` | `task_observations` column absent from setup.sql; persistence is probe-skipped (`absorb-observations.ts:71-80`), close drops it (`session-columns.ts:107-121`), in-memory only (`session-state.ts:537-556`). | +| "if a similar open thread exists (cosine similarity > 0.85), returns the existing thread instead. Check the 'deduplicated' field" | `src/tools/definitions.ts:712` | Dedup refuses and returns no `thread` (`create-thread.ts:217-281`, comment `:263-265`); `deduplicated` is not in the display. | +| "Takes a suggestion_id from session_start's suggested_threads list" | `src/tools/definitions.ts:734` | No tool returns `suggested_threads` (`session-start.ts:45`). | +| "Reports success/failure rates for all tracked fire-and-forget operations" | `src/tools/definitions.ts:794` | Session-row insert (`metrics.ts:156-158`), `flushCache`, `archiveDormantThreads`, `refreshBehavioralScores`, `markSessionSuperseded` are not tracked. | +| "recording archived_at timestamp" | `src/tools/definitions.ts:2142` | Column absent from setup.sql; write is probe-dropped (`archive-learning.ts:128-136`) while the result still reports `archived_at`. | +| `Full body: search("")` | `src/tools/recall.ts:387` | `search` has no id lookup (`src/tools/search.ts`). | +| "Save full session transcript to storage" | `src/tools/definitions.ts:456` | Bucket `session-transcripts` (`supabase-client.ts:804`) is not created by setup.sql; dev-only. | +| "Events are logged automatically as you use gitmem tools." | `src/commands/telemetry.ts:181` | No server/tool code calls `getTelemetry`. | +| "Recall infrastructure operational" / "Write operations functional" | `src/commands/check.ts:288, 306` | Both test the local result cache only (`:278-314`). | +| "✓ Schema verified (all tables present)" | `src/commands/activate.ts:476` | 4-table probe (`:105`); no threads/triples/metrics/views/RPCs. | +| "Device removed from license server" | `src/commands/deactivate.ts:105` | Printed even after "Server deactivation failed" (`:83-87`). | +| "gitmem installed successfully!" (free tier) | `bin/init-wizard.js:1056` | Store written to `/.gitmem` (`:169`), unread by the server (`gitmem-dir.ts:93-95`). | +| "Persist session with compliance validation." | `src/tools/definitions.ts:179` | `hasCompliance()` is dev-only (`tier.ts:100-102`); on pro the "compliance" is `buildCloseCompliance` over self-reported params. | + +### Summary 3 — Input-schema (definitions.ts) vs Zod vs handler mismatches + +| tool | advertised (definitions.ts) but ignored/absent in handler or Zod | read by handler / accepted by Zod but NOT advertised | +|---|---|---| +| recall | `similarity_threshold` (def :44-47) absent from `RecallParamsSchema` (`src/schemas/recall.ts:20-26`) — passes only because Zod is non-strict; `issue_id` (def :40-43) ignored on pro/free. | `action` fallback (`recall.ts:409`) — dead code (Zod rejects missing `plan` first). | +| session_start | `issue_labels` (def :146-150) never read anywhere; `issue_title`/`issue_description` only feed metrics `query_text` (`session-start.ts:1276`), not "scar context". `agent_identity` enum (def :131) narrower than Zod (`schemas/common.ts:11-24` adds legacy names). | — | +| session_close | `close_type` enum lacks `retroactive` (def :189 vs `schemas/common.ts CloseTypeSchema`, handler `:1321`). `session_id` required (def :209) but optional in Zod and recoverable (`:927-945`). | `decisions`, `open_threads`, `project_state`, `learnings_created`, `scars_to_record`, `task_completion`, `capture_transcript`, `transcript_path` (`src/schemas/session-close.ts:73-88`; read throughout `session-close.ts`). | +| create_learning | — | `why_this_matters`, `action_protocol` (array), `self_check_criteria` (array) (`src/schemas/create-learning.ts:18-20`; written `create-learning.ts:119-121`); setup.sql columns are `TEXT` (:30-31). | +| create_thread | — | `project`, `allow_duplicate` (`create-thread.ts:48-61`, `:136`, `:217`); display instructs the agent to pass `allow_duplicate: true` (`:255`). No Zod schema. | +| analyze | — | `format` (`analyze.ts:46, :182`) absent from `AnalyzeParamsSchema` (`src/schemas/analyze.ts`). | +| confirm_scars | `relevance` advertised on canonical (def :76-80) but not on alias `gitmem-cs` (def :839-855). No Zod schema. | — | +| record_scar_usage_batch | `project` advertised; `variant_id` accepted by Zod `ScarUsageEntrySchema` but not in the advertised item schema (def :398-440). | — | +| list_threads | `status:"resolved"` with `include_resolved:false` is honoured on pro (`thread-supabase.ts:383-384`) but header shows "0 resolved" for `status:"open"` (`list-threads.ts:158-160`). | — | +| graph_traverse | `predicate` enum lists 4 values (def :2214) vs 8 in setup.sql:199 / triple-writer. No Zod schema; `depth`/`limit` unvalidated. | — | +| prepare_context | `agent_role` documented as reserved and ignored (`prepare-context.ts:250`). | — | +| health | `failure_limit` read (`server.ts:370`) — consistent. | — | +| cleanup_threads | `auto_archive` ignored on free (`cleanup-threads.ts:129-195`). | — | +| session_refresh, reflect_scars, promote_suggestion, dismiss_suggestion, index_docs, search_docs | No Zod schema (`src/schemas/registry.ts:35-55`); handlers do their own minimal checks. | — | + +Server-level: `validateToolArgs` resolves aliases via `ALIAS_MAP` (`registry.ts:60-141`), which maps cache aliases to canonical names `cache_status`/`cache_health`/`cache_flush` that are not registered tools (server dispatch uses the `gitmem-cache-*` names, `server.ts:393-406`) — harmless because no schema exists for them, but the map is inconsistent with the dispatch table. + +--- + +## 9. CLASS G — TEST BLINDNESS + +gitmem @ `audit/GIT-113-phase-0` = `origin/main` f93c3a3 (v1.11.0). Read-only audit, 2026-09-22. + +### 0. Method and ground facts + +**Inputs.** `analysis.json` (from `analyze.mjs`): TypeScript-AST scan of 114 `src/**/*.ts` and 132 `tests/**/*.{ts,mjs}`; per-test import graph, `vi.mock` factories (186 calls in 33 files; 178 resolve to a `src/` module), reachability through the src graph stopping at mocked modules, and a factory-vs-real-exports diff. Spot-checked by hand: line counts (`wc -l` = JSON `lines`−1 for every file checked, JSON counts the trailing newline), the `session-close-bridge.test.ts:165` active-sessions factory, the three `vi.stubGlobal("fetch")` sites, and every "live omitted" call site listed in §3. + +**Refinement applied.** The analyzer marks a namespace import (`import * as supabase from "./supabase-client.js"`) as importing *every* export, so its `liveOmitted` over-reports (e.g. 24 "live" names for `metrics.ts`). `refine-live.mjs` re-scans each reachable module for actual `ns.name` references / named-import uses. §3 uses the refined numbers: 147 factories omit ≥1 real export; **21 factories** omit an export that reachable code-under-test actually references. + +**Empirical probe.** The 14 unit files affected by those 21 factories were run once (`vitest run`, no coverage, cache dir and `cacheDir` pointed at the scratchpad, `GITMEM_HOME` tmp per the repo config). Result: 14 files / 144 tests pass; repo untouched (`git status --porcelain` line count and `node_modules/.vite/vitest/*` mtime identical before/after). vitest's `No "" export is defined on the "" mock` message appeared **21 times, all for `getSessionDir`**, all swallowed by a `try/catch` in `session-close.ts` — see §3. + +**What actually runs in CI** (`.github/workflows/ci.yml`): + +| job | what | trigger | +|---|---|---| +| `build` (node 18/20/22) | `typecheck`, `build`, `npm run test:unit` (`tests/unit/**/*.test.ts`, 94 files, 1,272 `it(`), `test:smoke:free` (`tests/smoke/smoke-free.test.ts`, 3 tests, spawns `dist/index.js` on GITMEM_TIER=free), one e2e file `tests/e2e/git-89-session-identity.test.ts` (free tier, spawns dist), depcheck | push main / PR / tags | +| `hooks-without-jq` | `bash hooks/tests/test-hooks.sh` in `node:20-bookworm-slim` with **no jq and no ps** (48 `pass` assertions) | same | +| `venue-gate` | `scripts/venue-schema.sh` + `node tests/e2e/blank-supabase.mjs flow|egress|projects` against a disposable real Supabase (pro tier) on v1.8.0 then current `schema/setup.sql` | **v\* tags and `workflow_dispatch` only** | +| `publish` | needs all three | tags | + +**Not in CI at all:** `tests/integration/**` (5 files, 63 tests, Docker testcontainers), `tests/e2e/**` except git-89 (8 other files), `tests/smoke/smoke-pro.test.ts` (needs live `SUPABASE_URL`), `tests/performance/**` (42 benches), `tests/compliance/mcp-protocol-compliance.mjs`, `hooks/tests/test-hooks.sh` **with** jq (only the no-jq variant runs). + +**Two facts that reframe the non-unit tiers:** +1. `tests/integration/*.test.ts` import **nothing from `src/`**. They run raw `pg` SQL against a pgvector container (`tests/integration/setup.ts:101-104` sets `SUPABASE_URL` to a `postgres://` URI). The `describe("session_start")` / `describe("session_close")` blocks in `session-lifecycle.test.ts` are labels around hand-written `INSERT`/`UPDATE` statements ("simulating session_start component", line 61). They test `schema/setup.sql`, not gitmem. +2. `tests/e2e/pro-fresh.test.ts` and `pro-mature.test.ts` (Docker-gated) hand the spawned server `SUPABASE_URL = container.getConnectionUri()` — a `postgres://` URI (pro-fresh.test.ts:84). `supabase-client.ts` speaks PostgREST over `fetch`, so every store call fails; the suite's own comment (pro-fresh.test.ts:130-132) says recall "may fail with URL error. Key assertion: the tool doesn't crash". Assertions are `isToolError === false` and `text.length > 0`. These suites exercise the **fail-open** path of pro tier, not the store. + +So the only places gitmem's Supabase boundary is exercised for real are: (a) `tests/e2e/blank-supabase.mjs` on tags/dispatch; (b) `tests/smoke/smoke-pro.test.ts` when someone runs it with live credentials (it does verify the session row via REST: `querySessionRow`, lines 31-33, 99-100); (c) three unit files with a `vi.stubGlobal("fetch")` (§5a). + +**Class key for §1.** a = direct tests with the risky layer REAL (temp dir for fs; `vi.stubGlobal("fetch")` with `supabase-client.ts` unmocked for HTTP); a\* = the HTTP layer is real only as a *throwing* stub (write-result-contract.test.ts:91-95 throws `TypeError` on every fetch — exercises the failure branch, never a request/response shape); b = direct tests but the risky layer mocked in all of them; c = transitive only; d = zero unit coverage. "pure logic" = no fs/HTTP/process boundary in the file. + +Counts from the classifier (hand-overridden rows noted inline): **a 61 · b 16 · c 15 · d 22** (of 114). 14 files are not reachable from `src/server.ts` at all (commands/\*, diagnostics/\*, hooks/quick-retrieve, lib/telemetry, schemas/index, types/index) — they can only be exercised by CLI/hook paths, none of which run in CI except via `test-hooks.sh` (bash) and the smoke/e2e spawns of `dist/index.js`. + +### 1. Coverage map — every src file (sorted by lines desc) + +| # | src file | lines | direct test files | mocked layers in those tests | class | integration/e2e coverage (CI?) | where real risk lives | blind? | +|---|---|---|---|---|---|---|---|---| +| 1 | `src/tools/definitions.ts` | 2564 | tools/session-close-validation | agent-detection, supabase-client, embedding, tier, analytics, storage, session-state, thread-manager, thread-dedup, thread-supabase, compliance-validator, metrics, tools/record-scar-usage-batch, effect-tracker, tools/save-transcript, transcript-chunker, gitmem-dir, active-sessions, thread-suggestions | a-shallow (only the session_close definition is read by session-close-validation; getRegisteredTools/hasFullAliases never called) | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | tool registration / tier gating (hasFullAliases at definitions.ts:2535) | no | +| 2 | `src/tools/session-close.ts` | 1760 | services/relevance-metadata, services/write-health-timeout, tools/session-close-bridge, tools/session-close-payload-path, tools/session-close-supabase, tools/session-close-validation, tools/write-result-contract | supabase-client, tier, agent-detection, embedding, analytics, storage, session-state, thread-manager, thread-dedup, thread-supabase, compliance-validator, metrics, tools/record-scar-usage-batch, effect-tracker, tools/save-transcript, transcript-chunker, gitmem-dir, active-sessions, thread-suggestions, store-columns | a* (fs real in 3 tests; Supabase real only as a THROWING fetch stub in write-result-contract; success path mocked in 6 tests) | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP + ~/.gitmem fs | partial — real HTTP only as a throwing fetch stub (failure branch); success branch mocked | +| 3 | `src/tools/session-start.ts` | 1505 | tools/session-start-cross-project | agent-detection | a (fs) / Supabase path: no unit test — session-start-cross-project runs GITMEM_TIER=free | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP + ~/.gitmem fs | YES at unit level (no fetch stub, HTTP never exercised); real only on tag-gated venue run | +| 4 | `src/services/supabase-client.ts` | 933 | golden-regressions, services/scar-search-rpc-name, services/security-high, services/supabase-client-rest | embedding | a (fetch stub) for listRecords/getRecord/scarSearch/semanticSearch + REST error path; other 19 exports: only via throwing stub or mocked | CI e2e/smoke (free): module loaded, Supabase branch NOT exercised; venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP | partial — 4 of 25 exports hit a fetch stub; PATCH/upsert/RPC bodies, storage upload/download, manifest/fingerprint loaders never asserted against HTTP | +| 5 | `src/services/thread-supabase.ts` | 843 | services/thread-sync-result | supabase-client, tier | b | CI e2e/smoke (free): module loaded, Supabase branch NOT exercised; venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP | YES at unit level (Supabase mocked); real only on tag-gated venue run | +| 6 | `src/services/startup.ts` | 827 | services/relevance-metadata, services/vector-delta-sync | supabase-client, tier | b for Supabase (mocked in both direct tests) / a for vector-disk-cache fs (tmp) | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP + ~/.gitmem fs | YES at unit level (Supabase mocked); real only on tag-gated venue run | +| 7 | `src/services/analytics.ts` | 823 | services/analytics-scar-usage-columns | supabase-client, cache | b | CI e2e/smoke (free): module loaded, Supabase branch NOT exercised; venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP | YES at unit level (Supabase mocked); real only on tag-gated venue run | +| 8 | `src/tools/recall.ts` | 785 | tools/provenance-citation, tools/recall-surfacing, tools/variants-dev-only | supabase-client, local-vector-search, tier, storage, metrics, session-state, agent-detection, variant-assignment, behavioral-decay, gitmem-dir, variant-generation, embedding, startup, triple-writer | b | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP | YES at unit level (Supabase mocked); real only on tag-gated venue run | +| 9 | `src/commands/activate.ts` | 663 | — | — | d | no suite runs `gitmem activate` | Supabase/HTTP + ~/.gitmem fs + process/CLI | YES — no unit test at all | +| 10 | `src/services/session-state.ts` | 663 | services/enforcement, services/security-high, services/session-refresh-project, services/session-state-force, services/session-state-recovery, services/session-state-surfacing, tools/confirm-scars-retrieval-failure, tools/recall-surfacing, tools/session-close-identity, tools/session-start-cross-project, tools/write-result-contract | tier, supabase-client, local-vector-search, agent-detection | a (tmp real in 8 tests) | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | ~/.gitmem fs | no | +| 11 | `src/types/index.ts` | 640 | tools/session-close-validation | agent-detection, supabase-client, embedding, tier, analytics, storage, session-state, thread-manager, thread-dedup, thread-supabase, compliance-validator, metrics, tools/record-scar-usage-batch, effect-tracker, tools/save-transcript, transcript-chunker, gitmem-dir, active-sessions, thread-suggestions | a-shallow (type-only usage) | not reachable from server.ts; no real-run suite | pure logic | no | +| 12 | `src/services/active-sessions.ts` | 622 | services/active-sessions-locking, services/active-sessions, services/session-state-recovery, tools/session-start-cross-project | agent-detection | a | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | ~/.gitmem fs | no | +| 13 | `src/commands/check.ts` | 563 | diagnostics/check-command | — | b | cli-fresh-install.test.ts runs `gitmem check` (local e2e, NOT in CI) | Supabase/HTTP + ~/.gitmem fs + process/CLI | YES — no fetch stub, HTTP never exercised in every direct test; no real-run suite reaches it | +| 14 | `src/tools/graph-traverse.ts` | 557 | — | — | d | loaded by dist/index.js only; tool never called by any real-run suite | Supabase/HTTP | YES — no unit test at all; only reachable through a spawned server | +| 15 | `src/server.ts` | 531 | — | — | d | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | MCP transport, tool dispatch, display wrapping, startup init | YES — no unit test at all; only reachable through a spawned server | +| 16 | `src/services/triple-writer.ts` | 500 | services/thread-triples-write, services/thread-triples, services/tracked-writers-surface-failures | supabase-client, tier, license | b | CI e2e/smoke (free): module loaded, Supabase branch NOT exercised; venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP | YES at unit level (Supabase mocked); real only on tag-gated venue run | +| 17 | `src/services/cache.ts` | 498 | performance/cache.bench.ts, performance/cold-start.bench.ts, performance/session-start.bench.ts, golden-regressions | — | a | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | ~/.gitmem fs | no | +| 18 | `src/services/metrics.ts` | 487 | golden-regressions, services/metrics-session-fk, services/relevance-metadata | supabase-client, tier | b | CI e2e/smoke (free): module loaded, Supabase branch NOT exercised; venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP | YES at unit level (Supabase mocked); real only on tag-gated venue run | +| 19 | `src/services/doc-index.ts` | 456 | — | — | d | loaded by dist/index.js only; tool never called by any real-run suite | ~/.gitmem fs | YES — no unit test at all; only reachable through a spawned server | +| 20 | `src/tools/create-thread.ts` | 426 | tools/write-result-contract | — | a* (fetch stub throws; tmp threads file) | free-tier.test (local only, not CI); venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase (via thread-supabase) + ~/.gitmem threads file (thread-manager) | partial — real HTTP only as a throwing fetch stub (failure branch); success branch mocked | +| 21 | `src/diagnostics/collector.ts` | 417 | diagnostics/collector | — | a | via `gitmem check` in cli-fresh-install (local, not CI) | diagnostics_channel + report build | no | +| 22 | `src/services/local-vector-search.ts` | 409 | — (transitive only: 4 files) | — | c (transitive via startup/recall tests with supabase mocked; mocked in 5) | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | in-memory vectors fed by startup (Supabase) + embedding provider | YES — only transitive, Supabase layer mocked/unreached | +| 23 | `src/services/gitmem-dir.ts` | 407 | setup/isolate-gitmem-root.ts, services/active-sessions-locking, services/active-sessions, services/gitmem-dir-multisession, services/gitmem-root-resolution, services/gitmem-root-symlink, services/relevance-metadata, services/sanitize-path, services/session-state-recovery, services/session-state-surfacing, tools/confirm-scars-retrieval-failure, tools/recall-surfacing, tools/session-close-identity, tools/session-start-cross-project, tools/variants-dev-only | supabase-client, tier, local-vector-search, agent-detection, variant-assignment, variant-generation, embedding, startup, triple-writer | a | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | ~/.gitmem fs | no | +| 24 | `src/tools/prepare-context.ts` | 390 | tools/prepare-context, tools/provenance-citation | supabase-client, local-vector-search, tier, storage, metrics, session-state, agent-detection, variant-assignment, behavioral-decay, gitmem-dir | b | loaded by dist/index.js only; tool never called by any real-run suite | Supabase/HTTP | YES — Supabase mocked in every direct test; no real-run suite reaches it | +| 25 | `src/tools/search.ts` | 366 | tools/provenance-citation | supabase-client, local-vector-search, tier, storage, metrics, session-state, agent-detection, variant-assignment, behavioral-decay, gitmem-dir | b | free-tier.test (local only, not CI) | Supabase/HTTP | YES — Supabase mocked in every direct test; no real-run suite reaches it | +| 26 | `src/commands/migrate-root.ts` | 362 | commands/migrate-root | — | a | no suite runs `gitmem migrate-root` (unit only) | ~/.gitmem fs + process/CLI | no | +| 27 | `src/commands/migrate-local.ts` | 360 | — | — | d | no suite runs it | Supabase/HTTP + ~/.gitmem fs | YES — no unit test at all | +| 28 | `src/services/variant-generation.ts` | 347 | services/tracked-writers-surface-failures | supabase-client, tier, license | b | CI e2e/smoke (free): module loaded, Supabase branch NOT exercised | Supabase/HTTP | YES — Supabase mocked in every direct test; no real-run suite reaches it | +| 29 | `src/lib/telemetry.ts` | 345 | — | — | d | only via `gitmem telemetry` CLI; nothing runs it | Supabase/HTTP + ~/.gitmem fs | YES — no unit test at all | +| 30 | `src/tools/create-learning.ts` | 340 | tools/create-learning-errors, tools/create-learning-field-persistence, tools/variants-dev-only, tools/write-result-contract | supabase-client, tier, embedding, agent-detection, display-protocol, startup, triple-writer, effect-tracker, storage, session-state, metrics, variant-generation, local-vector-search, variant-assignment | a*/b (3 tests mock 12-14 layers; write-result-contract: fetch throws) | CI e2e/smoke (free tier, real dist server): yes | Supabase/HTTP | partial — real HTTP only as a throwing fetch stub (failure branch); success branch mocked | +| 31 | `src/services/thread-manager.ts` | 335 | services/thread-manager, services/thread-supabase, tools/write-result-contract | supabase-client | a (tmp threads file real in thread-supabase.test/write-result-contract) | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | ~/.gitmem fs | no | +| 32 | `src/tools/resolve-thread.ts` | 332 | tools/resolve-thread, tools/write-result-contract | session-state, thread-supabase, triple-writer, effect-tracker, agent-detection, metrics, timezone, thread-manager | a*/b (resolve-thread.test mocks 8 layers; write-result-contract: fetch throws) | venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase (via thread-supabase) + threads file | partial — real HTTP only as a throwing fetch stub (failure branch); success branch mocked | +| 33 | `src/tools/confirm-scars.ts` | 327 | tools/confirm-scars-retrieval-failure, tools/confirm-scars | session-state, metrics, gitmem-dir, display-protocol | a | free-tier.test (local only, not CI); venue-gate (real Supabase, pro): v* tags / dispatch only | ~/.gitmem fs | no | +| 34 | `src/services/transcript-chunker.ts` | 326 | — (transitive only: 3 files) | — | c | CI e2e/smoke (free): module loaded, Supabase branch NOT exercised; venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP | YES — only transitive, Supabase layer mocked/unreached | +| 35 | `src/services/vector-disk-cache.ts` | 323 | services/vector-delta-sync, services/vector-disk-cache | supabase-client | a | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | ~/.gitmem fs | no | +| 36 | `src/services/compliance-validator.ts` | 319 | services/compliance-validator | — | a | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | pure | no | +| 37 | `src/tools/cleanup-threads.ts` | 312 | — | — | d | loaded by dist/index.js only; tool never called by any real-run suite | Supabase/HTTP | YES — no unit test at all; only reachable through a spawned server | +| 38 | `src/services/embedding.ts` | 311 | — (transitive only: 13 files) | — | c (transitive in 13 files with no provider configured; mocked in 8; provider HTTP never tested) | CI e2e/smoke (free): module loaded, Supabase branch NOT exercised; venue-gate (real Supabase, pro): v* tags / dispatch only | HTTP to embedding provider (OpenAI/Voyage/Ollama) + Supabase-free | YES — only transitive, Supabase layer mocked/unreached | +| 39 | `src/services/thread-suggestions.ts` | 310 | services/thread-suggestions, tools/write-result-contract | — | a (tmp; write-result-contract fetch throws) | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP + ~/.gitmem fs | partial — real HTTP only as a throwing fetch stub (failure branch); success branch mocked | +| 40 | `src/services/thread-dedup.ts` | 298 | services/thread-dedup | — | a | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | pure logic | no | +| 41 | `src/tools/log.ts` | 296 | — | — | d | free-tier.test (local only, not CI) | Supabase/HTTP | YES — no unit test at all; only reachable through a spawned server | +| 42 | `src/services/variant-assignment.ts` | 285 | — (transitive only: 1 files) | — | c | CI e2e/smoke (free): module loaded, Supabase branch NOT exercised; venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP | YES — only transitive, Supabase layer mocked/unreached | +| 43 | `src/services/effect-tracker.ts` | 284 | services/effect-tracker, services/tracked-writers-surface-failures | supabase-client, tier, license | a | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | in-memory failure ledger (wraps Supabase writers) | YES at unit level (Supabase mocked); real only on tag-gated venue run | +| 44 | `src/tools/reflect-scars.ts` | 266 | — | — | d | loaded by dist/index.js only; tool never called by any real-run suite | ~/.gitmem fs | YES — no unit test at all; only reachable through a spawned server | +| 45 | `src/services/thread-scope.ts` | 257 | services/thread-scope | session-state | a | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | session-state read | no | +| 46 | `src/services/local-file-storage.ts` | 255 | services/local-file-storage | — | a | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | ~/.gitmem fs | no | +| 47 | `src/services/doc-chunker.ts` | 250 | — | — | d | loaded by dist/index.js only; tool never called by any real-run suite | pure chunking | YES — no unit test at all; only reachable through a spawned server | +| 48 | `src/services/thread-vitality.ts` | 234 | services/thread-lifecycle, services/thread-vitality | — | a | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | pure logic | no | +| 49 | `src/services/license.ts` | 232 | services/license | gitmem-dir | b (getProConfig/getCachedLicenseTier/clearLicenseCache w/ mocked gitmem-dir; validateLicense HTTP never tested) | CI e2e/smoke (free): module loaded, Supabase branch NOT exercised; venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP + ~/.gitmem fs | YES at unit level (no fetch stub, HTTP never exercised); real only on tag-gated venue run | +| 50 | `src/commands/telemetry.ts` | 229 | — | — | d | no suite runs `gitmem telemetry` | process/CLI | YES — no unit test at all | +| 51 | `src/diagnostics/channels.ts` | 229 | diagnostics/channels, diagnostics/collector | — | a | via `gitmem check` in cli-fresh-install (local, not CI) | pure logic | no | +| 52 | `src/tools/index-docs.ts` | 227 | — | — | d | loaded by dist/index.js only; tool never called by any real-run suite | fs scan + embedding HTTP + Supabase/local doc index | YES — no unit test at all; only reachable through a spawned server | +| 53 | `src/services/write-health.ts` | 226 | services/write-health-probe, services/write-health-timeout | supabase-client, tier | b | loaded by dist/index.js only; tool never called by any real-run suite | Supabase/HTTP | YES — Supabase mocked in every direct test; no real-run suite reaches it | +| 54 | `src/tools/record-scar-usage-batch.ts` | 223 | tools/write-result-contract | — | a* (only write-result-contract, fetch throws) | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP | partial — real HTTP only as a throwing fetch stub (failure branch); success branch mocked | +| 55 | `src/tools/analyze.ts` | 220 | — | — | d | loaded by dist/index.js only; tool never called by any real-run suite | Supabase via analytics | YES — no unit test at all; only reachable through a spawned server | +| 56 | `src/diagnostics/anonymizer.ts` | 218 | diagnostics/anonymizer | — | a | via `gitmem check` in cli-fresh-install (local, not CI) | pure logic | no | +| 57 | `src/tools/search-transcripts.ts` | 213 | — | — | d | loaded by dist/index.js only; tool never called by any real-run suite | Supabase/HTTP | YES — no unit test at all; only reachable through a spawned server | +| 58 | `src/services/display-protocol.ts` | 209 | tools/provenance-citation | supabase-client, local-vector-search, tier, storage, metrics, session-state, agent-detection, variant-assignment, behavioral-decay, gitmem-dir | a | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | pure logic | no | +| 59 | `src/constants/closing-questions.ts` | 197 | — (transitive only: 8 files) | — | c | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | pure | no (pure; exercised transitively) | +| 60 | `src/tools/archive-learning.ts` | 197 | tools/archive-learning, tools/write-result-contract | store-columns, supabase-client, tier, storage, startup, metrics, display-protocol | a*/b (archive-learning.test mocks 7 layers; write-result-contract: fetch throws) | loaded by dist/index.js only; tool never called by any real-run suite | Supabase/HTTP | partial — real HTTP only as a throwing fetch stub (failure branch); success branch mocked | +| 61 | `src/services/thread-drift.ts` | 196 | services/thread-drift | — | a | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | pure logic | no | +| 62 | `src/tools/list-threads.ts` | 192 | — | — | d | venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP | YES — no unit test at all; only reachable through a spawned server | +| 63 | `src/schemas/registry.ts` | 177 | schemas/registry | — | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 64 | `src/services/tier.ts` | 177 | services/tier-license, tools/session-start-cross-project, tools/variants-dev-only, tools/write-result-contract | gitmem-dir, agent-detection, supabase-client, local-vector-search, variant-assignment, variant-generation, embedding, startup, triple-writer | a (pure env/tier logic; license cache read w/ mocked gitmem-dir in tier-license) | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | pure logic | no | +| 65 | `src/hooks/quick-retrieve.ts` | 176 | hooks/quick-retrieve | tier, local-vector-search, storage | b | only via hooks/scripts/auto-retrieve-hook.sh, which test-hooks.sh does NOT exercise | ~/.gitmem fs + process/CLI | YES — fs layer mocked | +| 66 | `src/services/bm25.ts` | 168 | services/bm25 | — | a | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | pure logic | no | +| 67 | `src/services/config.ts` | 168 | — (transitive only: 4 files) | — | c | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | ~/.gitmem/config.json read | no (pure; exercised transitively) | +| 68 | `src/tools/create-decision.ts` | 162 | tools/write-result-contract | — | a* (only write-result-contract, fetch throws) | free-tier.test (local only, not CI) | Supabase/HTTP | partial — real HTTP only as a throwing fetch stub (failure branch); success branch mocked | +| 69 | `src/services/storage.ts` | 155 | — (transitive only: 6 files) | — | c (transitive via free-tier tools; Supabase branch never reached; mocked in 10) | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP + ~/.gitmem fs | YES — only transitive, Supabase layer mocked/unreached | +| 70 | `src/tools/contribute-feedback.ts` | 155 | — | — | d | loaded by dist/index.js only; tool never called by any real-run suite | ~/.gitmem fs | YES — no unit test at all; only reachable through a spawned server | +| 71 | `src/services/file-lock.ts` | 153 | services/file-lock | — | a | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | ~/.gitmem fs | no | +| 72 | `src/services/timezone.ts` | 153 | services/timezone | gitmem-dir | b | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | ~/.gitmem fs | YES — fs layer mocked | +| 73 | `src/hooks/format-utils.ts` | 150 | hooks/format-utils, tools/provenance-citation | supabase-client, local-vector-search, tier, storage, metrics, session-state, agent-detection, variant-assignment, behavioral-decay, gitmem-dir | a | loaded by dist/index.js only; tool never called by any real-run suite | pure logic | no | +| 74 | `src/tools/search-docs.ts` | 150 | — | — | d | loaded by dist/index.js only; tool never called by any real-run suite | embedding HTTP + doc index | YES — no unit test at all; only reachable through a spawned server | +| 75 | `src/services/enforcement.ts` | 148 | services/enforcement | — | a | loaded by dist/index.js only; tool never called by any real-run suite | pure logic | no | +| 76 | `src/schemas/session-close.ts` | 145 | schemas/session-close, tools/session-close-identity | — | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 77 | `src/services/behavioral-decay.ts` | 143 | — (transitive only: 3 files) | — | c | CI e2e/smoke (free): module loaded, Supabase branch NOT exercised; venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP | YES — only transitive, Supabase layer mocked/unreached | +| 78 | `src/services/session-columns.ts` | 140 | schema-drift, services/session-columns, services/store-columns | supabase-client | a | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | pure logic | no | +| 79 | `src/tools/save-transcript.ts` | 133 | tools/write-result-contract | — | a* (only write-result-contract, fetch throws) | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP | partial — real HTTP only as a throwing fetch stub (failure branch); success branch mocked | +| 80 | `src/tools/record-scar-usage.ts` | 124 | tools/write-result-contract | — | a* (only write-result-contract, fetch throws) | free-tier.test (local only, not CI) | Supabase/HTTP | partial — real HTTP only as a throwing fetch stub (failure branch); success branch mocked | +| 81 | `src/commands/deactivate.ts` | 116 | commands/deactivate | gitmem-dir | b | no suite runs `gitmem deactivate` | Supabase/HTTP + ~/.gitmem fs + process/CLI | YES — no fetch stub, HTTP never exercised in every direct test; no real-run suite reaches it | +| 82 | `src/tools/absorb-observations.ts` | 116 | tools/absorb-observations | store-columns, supabase-client, tier, metrics, session-state | b | loaded by dist/index.js only; tool never called by any real-run suite | Supabase/HTTP | YES — Supabase mocked in every direct test; no real-run suite reaches it | +| 83 | `src/tools/promote-suggestion.ts` | 110 | tools/write-result-contract | — | a* (only write-result-contract, fetch throws) | loaded by dist/index.js only; tool never called by any real-run suite | Supabase (via thread-supabase/create-thread) + suggestions file | partial — real HTTP only as a throwing fetch stub (failure branch); success branch mocked | +| 84 | `src/services/agent-detection.ts` | 105 | — (transitive only: 8 files) | — | c | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | env-var detection | no (pure; exercised transitively) | +| 85 | `src/schemas/common.ts` | 94 | schemas/common, tools/session-close-validation | agent-detection, supabase-client, embedding, tier, analytics, storage, session-state, thread-manager, thread-dedup, thread-supabase, compliance-validator, metrics, tools/record-scar-usage-batch, effect-tracker, tools/save-transcript, transcript-chunker, gitmem-dir, active-sessions, thread-suggestions | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 86 | `src/services/agent-briefing.ts` | 90 | — (transitive only: 7 files) | — | c | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | ~/.gitmem fs | partial — only transitive | +| 87 | `src/services/nudge-variants.ts` | 79 | — (transitive only: 4 files) | — | c | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | env var read | no (pure; exercised transitively) | +| 88 | `src/schemas/create-learning.ts` | 76 | schemas/create-learning | — | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 89 | `src/tools/dismiss-suggestion.ts` | 76 | — | — | d | loaded by dist/index.js only; tool never called by any real-run suite | suggestions file via thread-suggestions | YES — no unit test at all; only reachable through a spawned server | +| 90 | `src/services/store-columns.ts` | 74 | services/store-columns | supabase-client | b | CI e2e/smoke (free): module loaded, Supabase branch NOT exercised; venue-gate (real Supabase, pro): v* tags / dispatch only | Supabase/HTTP | YES at unit level (Supabase mocked); real only on tag-gated venue run | +| 91 | `src/tools/get-transcript.ts` | 74 | — | — | d | loaded by dist/index.js only; tool never called by any real-run suite | Supabase/HTTP | YES — no unit test at all; only reachable through a spawned server | +| 92 | `src/diagnostics/index.ts` | 62 | — (transitive only: 1 files) | — | c | via `gitmem check` in cli-fresh-install (local, not CI) | diagnostics_channel | no (pure; exercised transitively) | +| 93 | `src/schemas/recall.ts` | 62 | golden-regressions, schemas/recall | — | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 94 | `src/services/write-result.ts` | 48 | — (transitive only: 12 files) | — | c | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | pure | no (pure; exercised transitively) | +| 95 | `src/schemas/prepare-context.ts` | 44 | schemas/prepare-context | — | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 96 | `src/schemas/record-scar-usage.ts` | 44 | schemas/record-scar-usage | — | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 97 | `src/schemas/session-start.ts` | 44 | schemas/session-start | — | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 98 | `src/schemas/thread.ts` | 44 | schemas/thread | — | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 99 | `src/services/feedback-remote.ts` | 44 | — | — | d | loaded by dist/index.js only; tool never called by any real-run suite | Supabase/HTTP | YES — no unit test at all; only reachable through a spawned server | +| 100 | `src/schemas/analyze.ts` | 43 | schemas/analyze | — | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 101 | `src/schemas/save-transcript.ts` | 43 | schemas/transcript | — | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 102 | `src/schemas/create-decision.ts` | 42 | schemas/create-decision | — | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 103 | `src/schemas/contribute-feedback.ts` | 38 | schemas/contribute-feedback | — | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 104 | `src/schemas/log.ts` | 38 | schemas/log | — | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 105 | `src/schemas/search.ts` | 38 | schemas/search | — | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 106 | `src/schemas/search-transcripts.ts` | 37 | schemas/search-transcripts | — | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 107 | `src/schemas/absorb-observations.ts` | 36 | schemas/absorb-observations | — | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 108 | `src/schemas/record-scar-usage-batch.ts` | 36 | schemas/record-scar-usage | — | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 109 | `src/schemas/get-transcript.ts` | 33 | schemas/transcript | — | a | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no | +| 110 | `src/services/feedback-sanitizer.ts` | 30 | services/feedback-sanitizer | — | a | loaded by dist/index.js only; tool never called by any real-run suite | pure logic | no | +| 111 | `src/schemas/index.ts` | 25 | — | — | d | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | re-exports | YES — no unit test at all | +| 112 | `src/schemas/active-sessions.ts` | 22 | — (transitive only: 19 files) | — | c | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no (pure; exercised transitively) | +| 113 | `src/index.ts` | 20 | — | — | d | CI e2e/smoke (free tier, real dist server): yes; venue-gate (real Supabase, pro): v* tags / dispatch only | process entry | YES — no unit test at all; only reachable through a spawned server | +| 114 | `src/schemas/archive-learning.ts` | 19 | — (transitive only: 1 files) | — | c | exercised by server-side param validation whenever the tool is called over MCP (CI: session_start/session_close/recall/create_learning; others local suites only) | pure logic | no (pure; exercised transitively) | + +Notes on §1: "direct test files" are `tests/unit/...` unless prefixed (`performance/`, `setup/`). "mocked layers" is the union of `vi.mock` targets across that file's direct tests — a layer listed there is mocked in at least one direct test, not necessarily all; the class column already accounts for whether any direct test leaves the risky layer real. The e2e column is derived from the src import closure of the tools each real-run suite calls (CI free-tier: `session_start`, `session_close`, `recall`, `create_learning` from git-89 + smoke-free; venue-gate: `session_start`, `list_threads`, `recall`, `confirm_scars`, `create_thread`, `resolve_thread`, `session_close` from `blank-supabase.mjs` flow mode), so "module loaded" means the file is imported by a called tool, not that its branches ran. + +### 2. Mock census + +Source: `analysis.json testInfo[*].mocks` (186 `vi.mock`/`vi.doMock` calls in 33 test files; no `vi.doMock`, no automocks — every call has a factory; 10 factories spread `importOriginal`). All in `tests/unit/`. + +| mocked module | files | test files (`:line` of the `vi.mock`) | +|---|---|---| +| `src/services/supabase-client.ts` | **22** | services/analytics-scar-usage-columns:17, services/metrics-session-fk:13, services/relevance-metadata:22, services/store-columns:14, services/thread-supabase:29, services/thread-sync-result:22, services/thread-triples-write:18, services/tracked-writers-surface-failures:13, services/vector-delta-sync:26, services/write-health-probe:11, tools/absorb-observations:21, tools/archive-learning:23, tools/create-learning-errors:14, tools/create-learning-field-persistence:29, tools/prepare-context:15, tools/provenance-citation:117, tools/recall-surfacing:42, tools/session-close-bridge:25, tools/session-close-payload-path:23, tools/session-close-supabase:32, tools/session-close-validation:12, tools/variants-dev-only:24 | +| `src/services/tier.ts` | 19 | hooks/quick-retrieve:14, services/metrics-session-fk:19, services/relevance-metadata:28, services/thread-sync-result:31, services/thread-triples-write:27, services/tracked-writers-surface-failures:17, services/write-health-probe:15, tools/absorb-observations:26, tools/archive-learning:29, tools/create-learning-errors:19, tools/create-learning-field-persistence:34, tools/prepare-context:27, tools/provenance-citation:130, tools/recall-surfacing:24, tools/recall-threshold:16, tools/session-close-bridge:37, tools/session-close-payload-path:35, tools/session-close-supabase:44, tools/session-close-validation:24 | +| `src/services/metrics.ts` | 12 | tools/absorb-observations:32, tools/archive-learning:47, tools/confirm-scars:45, tools/create-learning-errors:65, tools/create-learning-field-persistence:84, tools/prepare-context:39, tools/provenance-citation:146, tools/resolve-thread:46, tools/session-close-bridge:115, tools/session-close-payload-path:98, tools/session-close-supabase:120, tools/session-close-validation:87 | +| `src/services/session-state.ts` | 11 | services/thread-scope:18, tools/absorb-observations:46, tools/confirm-scars:14, tools/create-learning-errors:61, tools/create-learning-field-persistence:80, tools/provenance-citation:154, tools/resolve-thread:13, tools/session-close-bridge:72, tools/session-close-payload-path:59, tools/session-close-supabase:78, tools/session-close-validation:48 | +| `src/services/gitmem-dir.ts` | 10 | commands/deactivate:18, services/license:19, services/tier-license:18, services/timezone:8, tools/confirm-scars:52, tools/provenance-citation:178, tools/session-close-bridge:158, tools/session-close-payload-path:130, tools/session-close-supabase:152, tools/session-close-validation:119 | +| `src/services/storage.ts` | 10 | hooks/quick-retrieve:25, tools/archive-learning:35, tools/create-learning-errors:55, tools/create-learning-field-persistence:74, tools/prepare-context:33, tools/provenance-citation:139, tools/session-close-bridge:64, tools/session-close-payload-path:52, tools/session-close-supabase:71, tools/session-close-validation:41 | +| `src/services/effect-tracker.ts` | 7 | tools/create-learning-errors:49, tools/create-learning-field-persistence:68, tools/resolve-thread:36, tools/session-close-bridge:133, tools/session-close-payload-path:114, tools/session-close-supabase:136, tools/session-close-validation:103 | +| `src/services/thread-supabase.ts` | 5 | tools/resolve-thread:19, tools/session-close-bridge:97, tools/session-close-payload-path:80, tools/session-close-supabase:102, tools/session-close-validation:69 | +| `src/services/active-sessions.ts` | 4 | tools/session-close-bridge:165, tools/session-close-payload-path:136, tools/session-close-supabase:158, tools/session-close-validation:125 (all three-key factories `{unregisterSession, findSessionByHostPid, findSessionById}` — the GIT-86 fix is in place; 7 other exports still omitted, see §3) | + +Other mocked src modules (count): agent-detection 9, embedding 8, local-vector-search 5, thread-manager 5, startup 4, display-protocol 4, triple-writer 4, analytics 4, thread-dedup 4, compliance-validator 4, tools/record-scar-usage-batch 4, tools/save-transcript 4, transcript-chunker 4, thread-suggestions 4, store-columns 3, variant-generation 2, variant-assignment 2, cache 1, license 1, behavioral-decay 1, timezone 1. Non-src: `uuid` 5, `fs` 3. + +**Fetch stubs (network level, real `supabase-client.ts`):** + +| file | form | what it does | +|---|---|---| +| `tests/unit/services/supabase-client-rest.test.ts:28` | `vi.stubGlobal("fetch", fetchMock)` returning `okJson([])` | asserts URL/headers/filters of `listRecords`, `getRecord`; REST 404 → `Supabase REST error: 404` | +| `tests/unit/services/scar-search-rpc-name.test.ts:30` | `vi.stubGlobal("fetch", ...)` | asserts RPC path/body of `scarSearch` / `semanticSearch` under a table prefix | +| `tests/unit/tools/write-result-contract.test.ts:91` | `vi.stubGlobal("fetch", ...)` that **throws `TypeError`** on every call | pro tier (`hasSupabase()===true`, line 114); 10 write tools incl. `sessionClose` must return `success:false, durable:false` | +| `tests/e2e/blank-supabase-netlog.mjs:` | `globalThis.fetch =` wrapper | net-log preload for the venue driver (records method/path/status/size); not a stub | + +No `vi.spyOn(global, "fetch")` anywhere. No HTTP server (msw/nock/undici MockAgent) anywhere in `tests/`. + + +### 3. The findSessionById shape — mock factories that omit exports (completed by the lead auditor) + +From the static diff of all 178 `vi.mock` factories against the real modules' exports (`analyze.mjs` → `analysis.json`): **168 factories replace the module outright** (10 spread `importOriginal`), **147 omit at least one export**, and **27 omit an export that code reachable from the test's subject imports**. A factory omission only throws when the export is *called*, so "reachable import" is an upper bound; the rows below are narrowed by hand. + +**Observed live, right now (the findSessionById case recurring):** running `session-close-payload-path.test.ts` (and the other session-close suites) prints `[session_close] Failed to clean up session directory: Error: [vitest] No "getSessionDir" export is defined on the "../../../src/services/gitmem-dir.js" mock`. The omitted export **is** reached, throws, and is caught by `cleanupSessionFiles`' own try/catch — so the tests pass while the cleanup path they execute fails. Silent failure (class A) inside the test suite itself. + +| test file:line | mocked module | omitted exports reachable from the code under test | reached today? | +|---|---|---|---| +| tools/session-close-payload-path.test.ts:130 | gitmem-dir.ts | `getSessionDir` | **yes — throws, swallowed** (observed) | +| tools/session-close-supabase.test.ts:152 | gitmem-dir.ts | `getSessionDir` | **yes — same path** | +| tools/session-close-validation.test.ts:119 | gitmem-dir.ts | `getSessionDir` | **yes — same path** | +| tools/session-close-validation.test.ts:48 | session-state.ts | `resolveCurrentSession`, `getCurrentSession`, `getProject`, `getConfirmations`, `getReflections` | on paths not taken by these cases (pro close, recovery) — silent-but-live | +| tools/session-close-payload-path.test.ts:59 | session-state.ts | same five | silent-but-live | +| tools/session-close-supabase.test.ts:78 | session-state.ts | `resolveCurrentSession`, `getCurrentSession`, `getProject`, `getReflections` | silent-but-live | +| tools/session-close-bridge.test.ts:72 | session-state.ts | `resolveCurrentSession`, `getCurrentSession`, `getProject` | silent-but-live | +| tools/session-close-validation.test.ts:24 | tier.ts | `hasFullAliases` | only via definitions import; not called | +| tools/confirm-scars.test.ts:14 | session-state.ts | `getRecallFailure` | **gates the GIT-93 retrieval-failure branch** — that branch is untestable from this file | +| tools/session-start-cross-project.test.ts:17 | agent-detection.ts | `getAgentIdentity` | reachable via recall/metrics imports; not called on the free path tested | +| 17 files | supabase-client.ts | up to 25 exports each (`directQuery`, `directPatch`, `listRecords`, `getRecord`, `scarSearch`, `loadLearnings*`, `getLearningsFingerprint`…) | per file: mostly unused by the tested path; `directPatch`/`directQuery` omitted in 12/14 files means any new patch/query in the code under test fails inside a try/catch rather than the test | + +**Pattern:** every session-close test builds its own partial world by hand (≈20 `vi.mock` factories each). Adding one import to `session-close.ts` silently turns part of its behaviour off in all four suites. + +### 4. Files with zero unit coverage (sorted by lines) + +`src/commands/activate.ts` 663 · `src/tools/graph-traverse.ts` 557 · `src/server.ts` 531 · `src/services/doc-index.ts` 456 · `src/commands/migrate-local.ts` 360 · `src/lib/telemetry.ts` 345 · `src/tools/cleanup-threads.ts` 312 · `src/tools/log.ts` 296 · `src/tools/reflect-scars.ts` 266 · `src/services/doc-chunker.ts` 250 · `src/commands/telemetry.ts` 229 · `src/tools/index-docs.ts` 227 · `src/tools/analyze.ts` 220 · `src/tools/search-transcripts.ts` 213 · `src/tools/list-threads.ts` 192 · `src/tools/contribute-feedback.ts` 155 · `src/tools/search-docs.ts` 150 · `src/tools/dismiss-suggestion.ts` 76 · `src/tools/get-transcript.ts` 74 · `src/services/feedback-remote.ts` 44 · `src/schemas/index.ts` 25 · `src/index.ts` 20. **22 files, 5,706 lines.** Plus `bin/gitmem.js` (939) and `bin/init-wizard.js` (1,072): their e2e tests (`cli-fresh-install`, `user-journey`, `cursor-cross-tool`) exist but **are not run in CI**. + +Class b (direct tests, risky layer mocked): `thread-supabase.ts` 843, `startup.ts` 827, `analytics.ts` 823, `recall.ts` 785, `check.ts` 563, `triple-writer.ts` 500, `metrics.ts` 487, `prepare-context.ts` 390, `search.ts` 366, `variant-generation.ts` 347, `license.ts` 232, `write-health.ts` 226, `quick-retrieve.ts` 176, `timezone.ts` 153, `deactivate.ts` 116, `absorb-observations.ts` 116, `store-columns.ts` 74. + +### 5. Probes + +| probe | answer | +|---|---| +| (a) supabase-client.ts against a real HTTP server or fetch stub? | fetch stub only (`supabase-client-rest.test.ts`, `scar-search-rpc-name.test.ts`, `security-high.test.ts`, `write-result-contract.test.ts`). **No test ever calls a real PostgREST** except the venue driver (tags / dispatch only). The stubbed `scar-search-rpc-name` test asserts the RPC *name* the code sends — it encodes `match_gitmem_learnings_weighted`, which does not exist on any customer store (C-1; verified 404 PGRST202 on the venue). | +| (b) pro session_start → session_close end to end against anything real? | **Unit: no.** `session-start-cross-project` runs free tier; session-close suites mock supabase-client. Real coverage is the venue driver's `flow` mode, which runs only in `venue-gate` (v* tags, manual dispatch) — not on PRs. | +| (c) hooks covered only by `hooks/tests/test-hooks.sh`? in CI? | yes and yes (since 1.11.0: `hooks-without-jq`, Linux container). Linux has `timeout`, so the macOS-only `auto-retrieve-hook.sh` failure (D 7.1) is invisible to it; `auto-retrieve-hook.sh` has no test case. | +| (d) bin/gitmem.js, bin/init-wizard.js | e2e tests exist, not in CI. `init` writing `/.gitmem` (D 1.1) is untested against the server's root. | +| (e) src/commands/* | activate, migrate-local, telemetry: zero. check, deactivate: mocked layer. migrate-root: real fs (good). | +| (f) src/diagnostics/* | direct tests for anonymizer, channels, collector, check-command. | +| (g) src/lib/telemetry.ts | zero tests; F also found nothing calls `getTelemetry` (dead code). | + +### 6. Top 10 most dangerous blind spots (risk × coverage) + +1. **`thread-supabase.ts` sync loop (843 lines, class b):** the GIT-70 test fails only the *lookup* (`directQuery`), which propagates; it never fails the write helpers, which swallow. That is exactly the gap behind B-1/A-2 (threads reported synced and pruned when every write failed). +2. **Remote recall RPC name is pinned by a stubbed test** (`scar-search-rpc-name.test.ts`) to a function no customer has. The test protects the bug. +3. **`startup.ts` (827, class b):** `loadScarsFromSupabase` → `[]` on failure → `flushCache` empties the index and the hook cache, returns success (E-5). No test fails the network during a flush. +4. **Session-close suites (≈20 hand-built mocks each):** omitted exports are reached and swallowed today (`getSessionDir`). Any new import disables behaviour silently. +5. **`archive-learning.ts`:** tests mock the prefix query, so the `like.` on a UUID column (42883 on every customer store, verified) never reached a real PostgREST. +6. **`init-wizard.js` (1,072, no CI):** writes the store to the wrong root (D 1.1). +7. **`auto-retrieve-hook.sh`:** no test; dead on stock macOS (D 7.1). +8. **`migrate-local.ts` (360, zero):** the path that lost 20 of 23 learnings in feedback c6e4cdfc has no regression test. +9. **`embedding.ts` on Pro:** every test touching it mocks it; feedback 54cb480a (embeddings not generated) has no regression test. +10. **`server.ts` (531, zero unit):** the display-only response rule (fields never reach the agent) and tool registration at the optimistic tier are exercised only by spawned-server smoke tests on the free tier. + +--- + +## 10. Community feedback map + +Source: `community_feedback` on the production project, read via the service-role path (scar `cc430e6c`), 2026-09-22. **69 rows**, 2026-02-22 → 2026-09-20: 21 bug reports, 8 friction, 23 suggestions, 17 feature requests; severity 23 high / 28 medium / 18 low. **`triaged_at`, `resolved_at` and `resolution_note` are null on all 69 rows**; only 8 rows carry `resolved_in_version` (all 1.6.1 or 1.6.5). The table is not being used as a triage record. + +Classes: A silent failure · B false success · C schema · D environment · E concurrency · F contract/UX · — not a defect. "Regression test" means a test that would fail if the reported behaviour came back. + +### Defects (bug_report + defect-shaped friction) + +| # | id | date · ver | tool | report (short) | class | ticket · fixed in | regression test | +|---|---|---|---|---|---|---|---| +| 1 | 87a121ad | 08-21 · 1.8.0 | session_start | `project` ignored on resume; other project's session resumed | B, D | GIT-86 · 1.11.0 | `session-start-cross-project.test.ts`; venue `projects` mode | +| 2 | 5229af45 | 08-12 · 1.8.0 | session_start | same as #1 (first report) | B, D | GIT-86 · 1.11.0 | same | +| 3 | eecd6e65 | 09-20 · 1.8.0 | session_start | every process re-downloads the whole embedding corpus (~9.3 MB) | D | GIT-98 · 1.9.0 disk cache, 1.11.0 per-row delta | `vector-disk-cache.test.ts`, `vector-delta-sync.test.ts`; venue `egress` gate. gzip suggestion: in effect via Node fetch defaults (no explicit header) | +| 4 | ac284a64 | 09-20 · 1.8.0 | recall | sub-agent fan-out cost (companion of #3) | D | GIT-98 · 1.11.0 | as #3 | +| 5 | 07cc544b | 09-18 · 1.8.0 | session_close | payload missing from the store gitmem reads → errors describe content, not absence | B, D | GIT-99 · 1.11.0 | `session-close-payload-path.test.ts`; venue flow check | +| 6 | 7046853a | 09-18 · 1.8.0 | session_close | opaque "WARN 2 write failures" | A, B | GIT-102 · 1.11.0 | `write-health-timeout.test.ts` (WARN lines); venue flow check | +| 7 | 2b8540ab | 09-13 · 1.8.0 | resolve_thread | success when only the local cache changed | B | GIT-101 · 1.11.0 | `write-result-contract.test.ts` | +| 8 | f2b853de | 09-13 · 1.8.0 | health | `checkWritePath()` itself went silent | A | GIT-102 · 1.11.0 | `write-health-timeout.test.ts`, `write-health-probe.test.ts` | +| 9 | 293d96a6 | 09-13 · 1.8.0 | session_start | thread panel and list_threads report different stores | B | GIT-97 · 1.9.0 (Supabase authoritative on start) | venue flow `thread_counts_match`; GIT-97 unit tests (6 refs) | +| 10 | 36b14b88 | 08-21 · 1.8.0 | session_start | Pro reads from two sources that disagree (display vs recall) | B | GIT-97 · 1.9.0 (threads); learnings side: GIT-98 cache — **not fully addressed** (display still built from local files where Supabase is down) | partial: venue flow | +| 11 | 99a7d7da | 08-28 · 1.8.0 | session_close | repo-local vs user store split breaks close/hook loop (4 ways) | D | GIT-91 · 1.8.0 (server), GIT-99 · 1.11.0 (hooks) | `gitmem-root-resolution.test.ts`; `hooks/tests/test-hooks.sh` (CI, no-jq job) | +| 12 | ba16dc5b | 08-21 · 1.8.0 | migrate-root | skips every memory file, then says delete the source | B | GIT-100 · 1.11.0 | `migrate-root.test.ts` (GIT-100 block) | +| 13 | f9fa0461 | 08-05 · 1.6.6 | create_thread | plausible ID returned, thread never stored (no active session) | B | GIT-63/GIT-67 · 1.7.0 | tests referencing GIT-63 (2) / GIT-67 (6); `write-result-contract.test.ts` | +| 14 | 8dc4f152 | 07-06 · 1.6.6 | session_close (hook) | Stop hook blocks on every turn | F | no ticket · partially: meaningful-session criteria + loop guard in `session-close-check.sh` | `test-hooks.sh` (guard, <5 calls) | +| 15 | ad5ca35a | 06-21 · 1.6.4 | cache tools | no thread-store reconcile; cache-health covers scars only | B, E | GIT-69/GIT-70 · 1.7.0 (thread drift report) — **cache-health still scar-only** | `thread-drift.test.ts` | +| 16 | 0e294c35 | 06-21 · 1.6.4 | list/resolve_thread | store divergence (recurrence) | B | GIT-46 · 1.6.5 (row says RESOLVED 1.6.5) | `resolve-thread.test.ts`, GIT-46 refs (4) | +| 17 | cc7d17f7 | 06-18 · 1.6.4 | list/resolve/close | list_threads (remote) vs resolve_thread (local) | B | GIT-46 · 1.6.5 | same | +| 18 | 39d8aa94 | 06-17 · 1.6.4 | resolve_thread | thread shown at start not resolvable in session | B | GIT-46 · 1.6.5; scope: GIT-69 · 1.7.0 | `thread-scope.test.ts` | +| 19 | 11c313d0 | 06-13 · 1.6.4 | session_close | partial write completes without error | A | GIT-69 item 3 / GIT-70 · 1.7.0 (partial persist reported, files retained) | `thread-sync-result.test.ts` | +| 20 | 5fa2e07f | 06-04 · 1.6.1 | resolve_thread | only own-session threads resolvable; misleading "not found" | B | GIT-46 · 1.6.5 | `resolve-thread.test.ts` | +| 21 | 85dcf230 | 06-03 · 1.6.1 | resolve_thread | same as #20 (first report) | B | GIT-46 · 1.6.5 | same | +| 22 | 54cb480a | 05-25 · 1.6.0 | recall | Pro: embeddings not generated → recall/search non-functional | A | 1.6.1 ("Embeddings not generated on Pro tier") — no ticket id in CHANGELOG | **none dedicated**: `embedding.ts` has no direct test; the 8 files touching it mock it (see G) | +| 23 | c6e4cdfc | 05-25 · 1.6.0 | session_start | Pro migration lossy (23 learnings → 3) | B, E | 1.6.1 `migrate-local` | **none**: `src/commands/migrate-local.ts` has zero tests (see G) | +| 24 | 870c918b, b80b3b94 | 02-22 · 1.3.x | confirm_scars | "No recall-surfaced scars to confirm" with valid IDs | B | 1.3.5 ("free tier recall→confirm_scars flow broken") | e2e regression added then (CHANGELOG 1.3.5); `confirm-scars.test.ts` | +| 25 | 5ad23659, 6f0f3e73 | 02-22 · 1.3.x | log | pattern learning missing from `log` | A? | no ticket; `log` queries all types by default today (`src/tools/log.ts:106-130`) — cause unverified | none | +| 26 | 074bfb57 | 02-22 · 1.3.5 | list/resolve_thread | `#N` false affordance | F | 1.3.5 (positional `#N` resolve + ID column) | `resolve-thread.test.ts`, `golden-regressions.test.ts` | +| 27 | f43b42f7 | 02-22 · 1.3.4 | recall (enforcement) | nag fires on benign create_learning/create_decision | F | **open by design**: `src/services/enforcement.ts:53-57` still lists create_learning, create_decision, session_close as consequential | `enforcement.test.ts` encodes current behaviour | +| 28 | 64b791d3 | 02-22 · 1.3.5 | hooks | hooks bypassed when simulating agent spawning | — | not a defect (hooks fire on real tool events) | n/a | + +### Friction / suggestions / features (not defects) + +| ids | theme | class | state | +|---|---|---|---| +| 196cddda, d9f875ea, cacc0b13, 51f6c518, 900607c4, 7e4d05d9, fd9a44ba, 134acd02, 78fe7efc | starter scars generic / dominate recall; provenance filter | F | 1.3.5: starter penalty 0.4x, `[starter]` tag, first-recall welcome; no `source` filter param (open) | +| 7c7c4d03, 19dde0ff, 3e581588, f48feb9d, c3839d33 | proposed starter scars | — | 1.3.5 pack expanded 7→12 | +| c1d4e34b, 0d5ae2c1, ce99a7a8, 249b7f29 | close ceremony heavy; N_A evidence friction; inline reflection | F | adaptive ceremony (micro/standard/full) + inline `closing_reflection` accepted; quick-close 30-min gate; N_A evidence length unchanged | +| 4fe374a9 | close should propose threads for untracked work | — | thread suggestions (Phase 5, `thread-suggestions.ts`) | +| c1b65b66 | parent resolves child sessions' threads | — | project-wide resolve (GIT-46/R9c) | +| 96c05eaf | amend / mark-refuted for scars | F | open; only archive_learning exists | +| 57babd55 | init should write AGENTS.md | — | done 1.3.5 | +| e49d4b48 | CLI output noise / DISPLAY PROTOCOL footer | F | footer trimmed 1.3.5; protocol line still on every tool | +| 61f8ea9d, 71ef2e39, 52d4dadb, 33/52453f9f | auto-recall, pre-commit diff check, file-linked scars, prepare_context formats | — | open features | +| d117200b, 7add2921, 6b1737f9, 22cd3c30, 5651e249, 5cc9ed4b, 9c70f8c7, c263328a, 4f08b5ce, 6aef6e64, 014c8aca, ca978e25 | frameworks, strategy, testimonials, install test | — | n/a | + +### What the feedback says about the classes + +- 13 of the 21 bug reports are class **B (false success)**; 7 are class **A**; 5 touch **D**. Every 1.8.0-era report (rows 1–12) is B, A or D, and every one of those was fixed in 1.9.0–1.11.0. No feedback row maps to class C or E: schema and concurrency failures are not being reported by users, which is consistent with them being silent (A) rather than absent. +- Two fixed defects have **no regression test**: Pro embedding generation (#22) and `migrate-local` (#23). +- Two reports are **open by design or partially addressed**: enforcement nag on benign tools (#27), cache-health scar-only (#15), and the learnings side of #10. +- The triage columns are unused, so a report's status is not recoverable from the table itself (GIT-47 asked for an admin read/triage path). + +--- + +## 11. Verification log and corrections + +**Read-only probes against the venue** (`vujndhcumgroztfkkdus`, a setup.sql store; service-role key read from the local env file and never printed): + +| Probe | Request | Result | Confirms | +|---|---|---|---| +| Remote recall RPC as the code calls it | `POST /rest/v1/rpc/match_gitmem_learnings_weighted` `{query_embedding, match_count, match_threshold}` | **404 PGRST202** "Searched for the function … no matches" | #1 | +| The function `setup.sql` provides | `POST /rest/v1/rpc/gitmem_scar_search` `{query_embedding, match_count, similarity_threshold}` | 400 "vector must have at least 1 dimension": the function exists (the test vector was empty) | #1's fix target | +| `archive_learning` prefix lookup | `GET /rest/v1/gitmem_learnings?select=id&id=like.abcd%25&limit=2` | **42883** "operator does not exist: uuid ~~ unknown" | #9 | +| Shape of a closed session's `open_threads` | `GET gitmem_sessions?select=open_threads…` | Elements are JSON-stringified `ThreadObject`s inside `TEXT[]` | #19 | + +**Code re-reads by hand:** #2 (`init-wizard.js:169`), #3 (`auto-retrieve-hook.sh:174`, plus `command -v timeout gtimeout` → none on the audit Mac), #4 (all three helpers catch and return `null`/`false`/`void`; the GIT-70 test rejects only `directQuery`), #5 (`startup.ts:225-227, 677-690`), #8 (`active-sessions.ts:465`), #16 (`bin/gitmem.js:659, 805-807`), and the live `getSessionDir` mock omission (observed in the session-close suites' stderr during Release C). + +**Corrections to sweep reports:** +- **F top-10 #10 is wrong:** `pg` **is** a dependency (`package.json:42`); `activate`'s `DATABASE_URL` path is not broken for that reason. Dropped from the ranking. +- **F #7 narrowed:** `install-hooks` is guarded when gitmem hooks are already present; it replaces *other* hooks only when no gitmem hook is found. `uninstall-hooks` deletes all hooks unconditionally. +- **C #2 re-rated:** from high to low-medium. After GIT-98's per-row delta the v1.8.0 refresh function costs a refetch of the scored rows (≈42 KB measured), not the whole index. +- **D (classes 7–9) and G (sections 3–6)** were completed by the lead auditor after the sweeps were cut off by a spend limit; they're marked as such inline. +- The class G mock-factory figures come from a static reachability analysis (upper bound); the table in G§3 narrows them by hand. + +**Not verified (reported as the sweeps found them):** E's race timings (#6, #7, #8, #18) are traced through code, not reproduced. The concurrency scenarios need the Phase 2 harness to demonstrate.