Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
7988be7
fix: never resume, displace or supersede a session of another project…
Sep 22, 2026
cf5e7bd
test: venue driver projects mode — one process serving two projects (…
Sep 22, 2026
e8268ba
fix: sync only the rows that changed when the learnings fingerprint m…
Sep 22, 2026
fb2893f
test: egress mode fails on any full index download after the cold sta…
Sep 22, 2026
936cc4f
fix: hooks resolve .gitmem like the server, skip dead-pid sessions, p…
Sep 22, 2026
709aa84
fix: session_close names the payload path when there is nothing to cl…
Sep 22, 2026
f1a1842
test: venue flow checks a payload-less close names the absolute paylo…
Sep 22, 2026
6fbe045
fix: every write tool reports whether the write is durable; resolve_t…
Sep 22, 2026
e11d850
test: venue flow resolves a thread and checks it landed in gitmem_thr…
Sep 22, 2026
90690f1
fix: bounded write-path check with an UNVERIFIED verdict in health; s…
Sep 22, 2026
eef2f8e
test: venue flow requires self-describing close WARNs and a write-pat…
Sep 22, 2026
a6f35ec
fix: metrics wait for their session row instead of racing it to a 409…
Sep 22, 2026
203a2e2
test: remove GIT-73 from the venue driver's expected failures
Sep 22, 2026
e3c77fe
fix: migrate-root merges memory files by record id instead of skippin…
Sep 22, 2026
71ba596
fix: thread triples reference the thread's row id, not its "t-" id (G…
Sep 22, 2026
b4abf5e
test: venue flow requires linked thread triples; remove GIT-105 from …
Sep 22, 2026
1b8f4a7
fix: compare .gitmem roots by real path, not string (GIT-107)
Sep 22, 2026
b80d11b
test: venue flow fails on a false "NOT being read" notice (GIT-107)
Sep 22, 2026
00d8860
test: static schema-drift gate against the v1.8.0 setup.sql (GIT-109 …
Sep 22, 2026
c18e82a
fix: hook pid check needs no ps (GIT-99)
Sep 22, 2026
28b3e9d
ci: release gate — venue driver on the v1.8.0 then current schema, ho…
Sep 22, 2026
f148557
Merge bugfix/GIT-99-hook-root-resolution into release/C
Sep 22, 2026
a103b60
Merge bugfix/GIT-86-cross-project-resume into release/C
Sep 22, 2026
abb772a
test: session-close mocks export findSessionById (release/C merge of …
Sep 22, 2026
80dd52a
Merge bugfix/GIT-98-per-row-delta-sync into release/C
Sep 22, 2026
bec4095
Merge bugfix/GIT-101-write-result into release/C
Sep 22, 2026
afae88b
Merge bugfix/GIT-102-diagnostics into release/C
Sep 22, 2026
bb77d0c
Merge bugfix/GIT-73-metrics-fk-race into release/C
Sep 22, 2026
3bc8d46
Merge bugfix/GIT-105-thread-triple-source-id into release/C
Sep 22, 2026
54377ae
Merge bugfix/GIT-100-migrate-root-merge into release/C
Sep 22, 2026
87e5aee
Merge bugfix/GIT-107-realpath-roots into release/C
Sep 22, 2026
4a68fce
Merge test/GIT-109-schema-drift into release/C
Sep 22, 2026
bba8427
Merge ci/GIT-83-release-venue-gate into release/C
Sep 22, 2026
f3fdae3
docs: CHANGELOG [Unreleased] for Release C
Sep 22, 2026
7a3ac5c
chore: bump version to v1.11.0
Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 113 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
tags: ['v*']
pull_request:
branches: [main]
# GIT-83: run the venue gate by hand (Actions → CI → Run workflow) before
# tagging. Publishing still happens only on a v* tag.
workflow_dispatch:

jobs:
build:
Expand Down Expand Up @@ -51,8 +54,117 @@ jobs:
- name: Dependency audit (no unused deps)
run: npx depcheck --ignores="@types/*" --json | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); const unused=d.dependencies||[]; if(unused.length){console.error('Unused dependencies:',unused);process.exit(1)}"

# GIT-83 / GIT-112: the hook suite on a runner WITHOUT jq (or ps). The hooks
# fall back to node and bash builtins there; on 1.10.0 that fallback left
# Stop enforcement inactive and nothing noticed, because every runner and
# dev machine the suite had run on had jq.
hooks-without-jq:
runs-on: ubuntu-latest
container: node:20-bookworm-slim
steps:
- uses: actions/checkout@v4

- name: Runner has no jq and no ps
run: |
if command -v jq >/dev/null; then echo "::error::jq is installed; this job must run without it"; exit 1; fi
if command -v ps >/dev/null; then echo "::error::ps is installed; this job must run without it"; exit 1; fi
bash --version | head -1; node --version

- name: Hook tests
run: bash hooks/tests/test-hooks.sh

# GIT-83: the release gate. Customers never run SQL after setup (standing
# rule, 2026-09-21), so a release must work on a store still on the v1.8.0
# setup.sql — the venue is put on exactly that schema first, then the
# current one. Runs on v* tags (publish depends on it) and on demand.
#
# Needs repository secrets, all for the DISPOSABLE venue (never production;
# scripts/venue-schema.sh and the driver both refuse it):
# VENUE_REF project ref
# VENUE_SUPABASE_URL https://<VENUE_REF>.supabase.co
# VENUE_SERVICE_ROLE_KEY service role key
# VENUE_DB_URL postgres:// URL (session pooler) for schema resets
venue-gate:
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
# One venue: two runs would wipe each other's data.
concurrency:
group: gitmem-venue
cancel-in-progress: false
env:
VENUE_REF: ${{ secrets.VENUE_REF }}
VENUE_DB_URL: ${{ secrets.VENUE_DB_URL }}
VENUE_SUPABASE_URL: ${{ secrets.VENUE_SUPABASE_URL }}
VENUE_SERVICE_ROLE_KEY: ${{ secrets.VENUE_SERVICE_ROLE_KEY }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # the v1.8.0 tag supplies the floor schema

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- run: npm ci --legacy-peer-deps
- run: npm run build

# Missing secrets fail the gate — a skipped gate would let publish through.
- name: Venue configured
run: |
missing=""
for v in VENUE_REF VENUE_DB_URL VENUE_SUPABASE_URL VENUE_SERVICE_ROLE_KEY; do
[ -n "${!v}" ] || missing="$missing $v"
done
if [ -n "$missing" ]; then echo "::error::venue gate cannot run, missing secrets:$missing"; exit 1; fi
umask 077
printf 'SUPABASE_URL=%s\nSUPABASE_SERVICE_ROLE_KEY=%s\nVENUE_REF=%s\n' \
"$VENUE_SUPABASE_URL" "$VENUE_SERVICE_ROLE_KEY" "$VENUE_REF" > "$RUNNER_TEMP/venue.env"
echo "VENUE_ENV=$RUNNER_TEMP/venue.env" >> "$GITHUB_ENV"
echo "RUNS=$RUNNER_TEMP/venue-runs" >> "$GITHUB_ENV"
mkdir -p "$RUNNER_TEMP/venue-runs"
git show v1.8.0:schema/setup.sql > "$RUNNER_TEMP/setup-v1.8.0.sql"

- name: Venue on the v1.8.0 setup.sql
run: scripts/venue-schema.sh reset-to "$RUNNER_TEMP/setup-v1.8.0.sql"

- name: Driver on v1.8.0 — flow
run: node tests/e2e/blank-supabase.mjs flow --label v180-flow --out "$RUNS"

- name: Driver on v1.8.0 — egress
run: node tests/e2e/blank-supabase.mjs egress --label v180-egress --out "$RUNS" --rows 250 --usage 5

- name: Driver on v1.8.0 — projects (when the driver has it)
run: |
# The usage line lists the modes; a bare "projects" string also appears in a path.
if grep -q 'flow|egress|projects' tests/e2e/blank-supabase.mjs; then
node tests/e2e/blank-supabase.mjs projects --label v180-projects --out "$RUNS"
else
echo "driver has no projects mode in this revision"
fi

# The current setup.sql must apply on top of v1.8.0, and apply again.
- name: Venue on the current setup.sql (applied twice)
run: |
scripts/venue-schema.sh apply schema/setup.sql
scripts/venue-schema.sh apply schema/setup.sql

- name: Driver on the current schema — flow
run: node tests/e2e/blank-supabase.mjs flow --label current-flow --out "$RUNS"

- name: Driver reports
if: always()
uses: actions/upload-artifact@v4
with:
name: venue-gate-${{ github.run_id }}
# netlogs record method, path, status and body size only — no bodies or headers.
path: ${{ runner.temp }}/venue-runs
if-no-files-found: ignore
retention-days: 14

publish:
needs: build
needs: [build, hooks-without-jq, venue-gate]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
Expand Down
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.11.0] - 2026-09-21

**Just upgrade the package. Nothing to change on your database.** Every fix in this release works on
a project still on the `setup.sql` you first ran, back to 1.8.0. Each one was checked against a
project on exactly that schema.

**A chat in one project no longer continues another project's session.** The desktop app runs one
gitmem process for every chat. Starting a session for project Y while a project X chat was open used
to resume X's session, swapping the project you asked for for X's and saying so only on stderr.
`session_start(project: Y)` now starts a Y session and leaves X open and resumable. Called with no
project, it resumes the most recent session and names that session's project in its output.
`force: true` carries activity forward only from a session of the same project. (GIT-86)

**Every write now says whether it was actually saved.** `create_learning`, `create_decision`,
`create_thread`, `resolve_thread`, `archive_learning`, `record_scar_usage` (single and batch),
`session_close`, `promote_suggestion` and `save_transcript` now report `durable` and `stored_in`
alongside `success`. `success` means the record reached your database. Before this,
`resolve_thread` reported success even when your database never recorded the resolve, so other
sessions still saw the thread as open. Now it says `RESOLVED LOCALLY ONLY — not durable`. (GIT-101)

**Stop enforcement was inactive on machines without jq; fixed.** (GIT-112)

### Fixed

- **Sessions start without re-downloading the whole index.** On Pro, nearly every `session_start`
changes a few scar scores, and the next start used to download every learning with its embedding
again. Now only the changed rows are fetched: in testing, 42 KB instead of 922 KB for 250
learnings. (GIT-98)
- **The hooks read the same store as the server.** They looked for `.gitmem` in the current directory
instead of `GITMEM_DIR`, then `GITMEM_HOME`, then `~/.gitmem`, so they could see no session or
someone else's. They now skip sessions whose server process has exited, and the Stop hook prints
the exact path of `closing-payload.json`. (GIT-99)
- **`session_close` says where it looked for your payload.** With no `closing-payload.json` and no
inline reflection, it now reports `closing-payload.json not found at <path>` instead of asking you
to answer the closing questions again. (GIT-99)
- **The startup write check can no longer hang silently.** It gives up after 10 seconds and reports
"timed out, durability UNVERIFIED", and a database that refuses the connection is reported as
unreachable rather than OK. `health` now shows the latest verdict. (GIT-102)
- **`session_close` warnings say what failed.** Each warning names the part that failed and why, and
says whether the session itself was saved. Before, a close showed only `WARN N write failures`.
(GIT-102)
- **No more `metrics` failure at `session_start`.** A metrics row now waits for its session row to
exist. (GIT-73, listed under *Known issues* in 1.10.0)
- **Knowledge-graph links for threads are saved.** They wrote a thread id where the database expects a
UUID, so every one was rejected. (GIT-105, listed under *Known issues* in 1.10.0)
- **`migrate-root` merges your memory instead of skipping it.** If your home store already had
learnings, threads, decisions or sessions, the project store's were skipped as "already exists". They
are now merged record by record: the newer version wins and the other is written to
`migrate-root-conflicts.json`, after a backup. Running it again changes nothing. (GIT-100)
- **No false "Memory store found but NOT being read" on macOS.** Paths under `/var` and
`/private/var` (and any symlink) were compared as text, so the store gitmem was reading could be
reported as unread. (GIT-107)

### Changed

- **Releases are gated on a database still on 1.8.0's `setup.sql`, and on the hooks passing
without jq.** Nothing is published unless both pass. (GIT-83, GIT-109)

## [1.10.0] - 2026-09-21

**Sessions that weren't being saved now save.** On your own Supabase project, a `session_close` was
Expand Down
5 changes: 3 additions & 2 deletions hooks/scripts/post-tool-use.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ HOOK_INPUT=$(cat -)
# ============================================================================

# Check active sessions registry
ACTIVE_SESSIONS=".gitmem/active-sessions.json"
if [ ! -f "$ACTIVE_SESSIONS" ]; then
# GIT-99: resolve .gitmem the way the server does, not from cwd.
. "$(dirname "${BASH_SOURCE[0]}")/resolve-root.sh"
if [ -z "$(gitmem_live_session_ids)" ]; then
exit 0
fi

Expand Down
17 changes: 7 additions & 10 deletions hooks/scripts/recall-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,17 @@ HOOK_INPUT=$(cat -)
# Resolve active session from registry
# ============================================================================

ACTIVE_SESSIONS=".gitmem/active-sessions.json"
if [ ! -f "$ACTIVE_SESSIONS" ]; then
# GIT-99: resolve .gitmem the way the server does, not from cwd.
. "$(dirname "${BASH_SOURCE[0]}")/resolve-root.sh"
if [ ! -f "$GITMEM_ACTIVE_SESSIONS" ]; then
exit 0
fi

# Get first session ID from registry to find per-session data file
# Newest live session in the registry -> its per-session data file.
# Entries whose server pid is dead are skipped (GIT-99).
SESSION_FILE=""
if command -v jq &>/dev/null; then
SID=$(jq -r '.sessions[0].session_id // empty' "$ACTIVE_SESSIONS" 2>/dev/null)
[ -n "$SID" ] && SESSION_FILE=".gitmem/sessions/${SID}/session.json"
elif command -v node &>/dev/null; then
SID=$(node -e "const fs=require('fs');try{const r=JSON.parse(fs.readFileSync('$ACTIVE_SESSIONS','utf8'));const s=(r.sessions||[])[0];process.stdout.write(s?.session_id||'')}catch(e){}" 2>/dev/null)
[ -n "$SID" ] && SESSION_FILE=".gitmem/sessions/${SID}/session.json"
fi
SID=$(gitmem_live_session_ids | head -n 1)
[ -n "$SID" ] && SESSION_FILE="$GITMEM_ROOT/sessions/${SID}/session.json"

if [ -z "$SESSION_FILE" ] || [ ! -f "$SESSION_FILE" ]; then
exit 0
Expand Down
91 changes: 91 additions & 0 deletions hooks/scripts/resolve-root.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/bin/bash
# ============================================================================
# gitmem-hooks — resolve the .gitmem root the way the MCP server does (GIT-99)
#
# Sourced, not executed: . "$(dirname "$0")/resolve-root.sh"
#
# The hooks used to read ".gitmem/..." relative to their cwd. Since GIT-91 the
# server resolves its root independently of cwd, so a hook running in a repo
# read a different store from the one the server writes: it saw no session, or
# someone else's, and pointed the agent at a closing-payload.json the server
# would never read.
#
# Precedence mirrors src/services/gitmem-dir.ts:
# 1. $GITMEM_DIR — names the .gitmem directory itself
# 2. $GITMEM_HOME/.gitmem — relocates the home the fallback uses
# 3. $HOME/.gitmem
#
# Sets (all absolute):
# GITMEM_ROOT the resolved .gitmem directory
# GITMEM_ACTIVE_SESSIONS $GITMEM_ROOT/active-sessions.json
# GITMEM_PAYLOAD_PATH $GITMEM_ROOT/closing-payload.json
# Provides:
# gitmem_live_session_ids registry session ids, newest first, skipping
# entries on this host whose pid is dead
# gitmem_pid_alive whether a pid is running (no `ps` needed)
# gitmem_json_escape escape a string for a JSON string literal
# ============================================================================

if [ -n "${GITMEM_DIR:-}" ]; then
_gitmem_root="$GITMEM_DIR"
elif [ -n "${GITMEM_HOME:-}" ]; then
_gitmem_root="$GITMEM_HOME/.gitmem"
else
_gitmem_root="$HOME/.gitmem"
fi
# Absolute, without requiring the directory to exist yet.
case "$_gitmem_root" in
/*) ;;
*) _gitmem_root="$PWD/$_gitmem_root" ;;
esac
GITMEM_ROOT="${_gitmem_root%/}"
unset _gitmem_root
GITMEM_ACTIVE_SESSIONS="$GITMEM_ROOT/active-sessions.json"
GITMEM_PAYLOAD_PATH="$GITMEM_ROOT/closing-payload.json"

# Is this pid a running process? `kill -0` needs no external tool (minimal
# images have no `ps`, and a missing `ps` would make every session look dead).
# EPERM ("Operation not permitted") means it exists but belongs to another user.
gitmem_pid_alive() {
local out
out=$(kill -0 "$1" 2>&1) && return 0
case "$out" in *ermitted*) return 0 ;; esac
return 1
}

# A registry entry whose server died is not a session anyone can be in. Only
# entries on THIS host can be checked; entries without a pid, or from another
# host, are kept.
gitmem_live_session_ids() {
[ -f "$GITMEM_ACTIVE_SESSIONS" ] || return 0
local host rows
host=$(hostname 2>/dev/null || echo "")
if command -v jq &>/dev/null; then
rows=$(jq -r '(.sessions // []) | sort_by(.started_at // "") | reverse | .[]
| [(.session_id // ""), ((.pid // "") | tostring), (.hostname // "")] | join("|")' \
"$GITMEM_ACTIVE_SESSIONS" 2>/dev/null) || return 0
elif command -v node &>/dev/null; then
rows=$(GITMEM_REG="$GITMEM_ACTIVE_SESSIONS" node -e '
try {
const r = JSON.parse(require("fs").readFileSync(process.env.GITMEM_REG, "utf8"));
const s = (r.sessions || []).slice().sort((a, b) => String(b.started_at || "").localeCompare(String(a.started_at || "")));
process.stdout.write(s.map((e) => [e.session_id || "", e.pid ?? "", e.hostname || ""].join("|")).join("\n"));
} catch (e) {}' 2>/dev/null) || return 0
else
return 0
fi
local sid pid h
# "|" rather than a tab: read collapses runs of whitespace separators, so an
# empty pid would shift the hostname into its place.
while IFS='|' read -r sid pid h; do
[ -n "$sid" ] || continue
if [ -n "$pid" ] && [ "$h" = "$host" ] && ! gitmem_pid_alive "$pid"; then
continue
fi
echo "$sid"
done <<< "$rows"
}

gitmem_json_escape() {
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
}
19 changes: 8 additions & 11 deletions hooks/scripts/session-close-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,12 @@ fi

IS_MEANINGFUL=false

ACTIVE_SESSIONS=".gitmem/active-sessions.json"
# GIT-99: resolve .gitmem the way the server does, not from cwd.
. "$(dirname "${BASH_SOURCE[0]}")/resolve-root.sh"
# A live registry entry means a session is open. Entries whose server pid is
# dead are skipped: nobody can close those from here (GIT-99).
SESSION_STARTED=false
if [ -f "$ACTIVE_SESSIONS" ]; then
# Check if registry has active session entries
if command -v jq &>/dev/null; then
[ "$(jq '.sessions | length' "$ACTIVE_SESSIONS" 2>/dev/null || echo 0)" -gt 0 ] 2>/dev/null && SESSION_STARTED=true
elif command -v node &>/dev/null; then
[ "$(node -e "const fs=require('fs');try{const r=JSON.parse(fs.readFileSync('$ACTIVE_SESSIONS','utf8'));process.stdout.write(String((r.sessions||[]).length))}catch(e){process.stdout.write('0')}" 2>/dev/null)" -gt 0 ] 2>/dev/null && SESSION_STARTED=true
fi
fi
[ -n "$(gitmem_live_session_ids)" ] && SESSION_STARTED=true

# Without state dir, we have no tracking data — can't determine meaningfulness.
# This happens when the SessionStart hook didn't fire (plugin hooks issue).
Expand Down Expand Up @@ -98,10 +94,11 @@ if [ "$SESSION_STARTED" = "true" ]; then
mkdir -p "$STATE_DIR"
touch "$STATE_DIR/stop_hook_active"

cat <<'HOOKJSON'
PAYLOAD_JSON=$(gitmem_json_escape "$GITMEM_PAYLOAD_PATH")
cat <<HOOKJSON
{
"decision": "block",
"reason": "GITMEM SESSION STILL OPEN — Run the standard closing ceremony:\n\n1. YOU (the agent) ANSWER these 7 reflection questions based on the session. Display your answers to the human:\n - what_broke: What broke that you didn't expect?\n - what_took_longer: What took longer than it should have?\n - do_differently: What would you do differently next time?\n - what_worked: What pattern or approach worked well?\n - wrong_assumption: What assumption was wrong?\n - scars_applied: Which scars or institutional knowledge did you apply?\n - institutional_memory: What from this session should be captured?\n\n2. ASK the human: 'Any corrections or additions to my answers?' WAIT for their response.\n\n3. WRITE structured payload to .gitmem/closing-payload.json with closing_reflection (7 fields above, incorporating human corrections), task_completion (timestamps), and human_corrections.\n\n4. CALL session_close with session_id and close_type: 'standard'.\n\nFor trivial sessions (< 30min, exploratory only), use close_type: 'quick' instead — no questions needed."
"reason": "GITMEM SESSION STILL OPEN — Run the standard closing ceremony:\n\n1. YOU (the agent) ANSWER these 7 reflection questions based on the session. Display your answers to the human:\n - what_broke: What broke that you didn't expect?\n - what_took_longer: What took longer than it should have?\n - do_differently: What would you do differently next time?\n - what_worked: What pattern or approach worked well?\n - wrong_assumption: What assumption was wrong?\n - scars_applied: Which scars or institutional knowledge did you apply?\n - institutional_memory: What from this session should be captured?\n\n2. ASK the human: 'Any corrections or additions to my answers?' WAIT for their response.\n\n3. WRITE structured payload to ${PAYLOAD_JSON} (this exact absolute path — it is where session_close reads) with closing_reflection (7 fields above, incorporating human corrections), task_completion (timestamps), and human_corrections.\n\n4. CALL session_close with session_id and close_type: 'standard'.\n\nFor trivial sessions (< 30min, exploratory only), use close_type: 'quick' instead — no questions needed."
}
HOOKJSON
exit 0
Expand Down
Loading
Loading