Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
104 changes: 104 additions & 0 deletions hooks/tests/test-hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ trap "rm -rf $TMPDIR" EXIT

cd "$TMPDIR"

# GIT-99: the hooks resolve .gitmem like the server does (GITMEM_DIR, then
# GITMEM_HOME/.gitmem, then ~/.gitmem), no longer from cwd. These tests used to
# rely on cwd = $TMPDIR finding $TMPDIR/.gitmem; point the hooks there
# explicitly instead — and, unpointed, they would read the developer's ~/.gitmem.
export GITMEM_DIR="$TMPDIR/.gitmem"
unset GITMEM_HOME

# ============================================================================
# Helpers: multi-session registry format
# ============================================================================
Expand Down Expand Up @@ -622,6 +629,103 @@ fi
# Clean up
rm -rf /tmp/gitmem-hooks-*

# ============================================================================
# TEST GROUP: .gitmem root resolution (GIT-99)
# ============================================================================

echo ""
echo -e "${YELLOW}=== Root Resolution (GIT-99) ===${NC}"

# Resolve in a clean subshell and print GITMEM_ROOT.
resolve_root() {
( cd "$1" && shift && env -u GITMEM_DIR -u GITMEM_HOME "$@" bash -c '. "$0/scripts/resolve-root.sh"; printf "%s" "$GITMEM_ROOT"' "$SCRIPT_DIR" )
}
R_HOME="$TMPDIR/r-home"; R_GH="$TMPDIR/r-gh"; R_DIR="$TMPDIR/r-dir/.gitmem"; R_CWD="$TMPDIR/r-cwd"
mkdir -p "$R_HOME" "$R_GH" "$R_DIR" "$R_CWD/.gitmem"

GOT=$(resolve_root "$R_CWD" HOME="$R_HOME" GITMEM_HOME="$R_GH" GITMEM_DIR="$R_DIR")
[ "$GOT" = "$R_DIR" ] && pass "GITMEM_DIR wins over GITMEM_HOME and HOME" \
|| fail "GITMEM_DIR precedence" "$R_DIR" "$GOT"

GOT=$(resolve_root "$R_CWD" HOME="$R_HOME" GITMEM_HOME="$R_GH")
[ "$GOT" = "$R_GH/.gitmem" ] && pass "GITMEM_HOME/.gitmem wins over HOME" \
|| fail "GITMEM_HOME precedence" "$R_GH/.gitmem" "$GOT"

GOT=$(resolve_root "$R_CWD" HOME="$R_HOME")
[ "$GOT" = "$R_HOME/.gitmem" ] && pass "falls back to ~/.gitmem — never cwd/.gitmem" \
|| fail "HOME fallback" "$R_HOME/.gitmem" "$GOT"

GOT=$(resolve_root "$R_CWD" HOME="$R_HOME" GITMEM_DIR="rel/.gitmem")
[ "$GOT" = "$R_CWD/rel/.gitmem" ] && pass "relative GITMEM_DIR is made absolute" \
|| fail "relative GITMEM_DIR" "$R_CWD/rel/.gitmem" "$GOT"

# Hooks find the store from any cwd, and ignore a decoy .gitmem in cwd.
setup_state 10 60
create_session_registry "test-session"
echo '{"sessions":[]}' > "$R_CWD/.gitmem/active-sessions.json"
OUTPUT=$( cd "$R_CWD" && echo '{}' | bash "$SCRIPT_DIR/scripts/session-close-check.sh" 2>/dev/null )
echo "$OUTPUT" | grep -q '"decision": "block"' && pass "Stop hook reads GITMEM_DIR from an unrelated cwd (decoy cwd/.gitmem ignored)" \
|| fail "Stop hook cwd independence" "block" "$OUTPUT"

# The Stop hook names the absolute payload path it resolved.
PAYLOAD_EXPECTED="$GITMEM_DIR/closing-payload.json"
echo "$OUTPUT" | grep -qF "WRITE structured payload to $PAYLOAD_EXPECTED (this exact absolute path" \
&& pass "Stop hook text prints the resolved absolute payload path" \
|| fail "Stop hook payload path" "$PAYLOAD_EXPECTED" "$OUTPUT"
echo "$OUTPUT" | (command -v node >/dev/null && node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{JSON.parse(d)})' 2>/dev/null) \
&& pass "Stop hook output with the path is valid JSON" \
|| fail "Stop hook JSON" "parseable JSON" "$OUTPUT"

# A path that needs escaping still yields valid JSON.
Q_DIR="$TMPDIR/q\"uote/.gitmem"; mkdir -p "$Q_DIR"
echo '{"sessions":[{"session_id":"q"}]}' > "$Q_DIR/active-sessions.json"
setup_state 10 60
OUTPUT=$( GITMEM_DIR="$Q_DIR" bash -c 'echo "{}" | bash "$0/scripts/session-close-check.sh"' "$SCRIPT_DIR" 2>/dev/null )
echo "$OUTPUT" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{const j=JSON.parse(d);if(!j.reason.includes(process.argv[1]))process.exit(1)})' "$Q_DIR/closing-payload.json" 2>/dev/null \
&& pass "payload path with a quote is JSON-escaped" \
|| fail "payload path escaping" "valid JSON containing $Q_DIR/closing-payload.json" "$OUTPUT"

# Registry entries whose pid is dead on this host are skipped.
DEAD_PID=99999999
while kill -0 "$DEAD_PID" 2>/dev/null; do DEAD_PID=$((DEAD_PID - 1)); done
HOST=$(hostname)
setup_state 10 60
echo "{\"sessions\":[{\"session_id\":\"dead-one\",\"pid\":$DEAD_PID,\"hostname\":\"$HOST\",\"started_at\":\"2026-09-21T00:00:00Z\"}]}" > "$GITMEM_DIR/active-sessions.json"
OUTPUT=$(echo '{}' | bash "$SCRIPT_DIR/scripts/session-close-check.sh" 2>/dev/null)
! echo "$OUTPUT" | grep -q "block" && pass "Stop hook ignores a registry entry whose server pid is dead" \
|| fail "dead pid skipped (Stop)" "no block" "$OUTPUT"

setup_state 10 60
echo "{\"sessions\":[{\"session_id\":\"dead-one\",\"pid\":$DEAD_PID,\"hostname\":\"other-host-$HOST\",\"started_at\":\"2026-09-21T00:00:00Z\"}]}" > "$GITMEM_DIR/active-sessions.json"
OUTPUT=$(echo '{}' | bash "$SCRIPT_DIR/scripts/session-close-check.sh" 2>/dev/null)
echo "$OUTPUT" | grep -q "block" && pass "an entry from another host is kept (its pid cannot be checked here)" \
|| fail "other-host entry kept" "block" "$OUTPUT"

# recall-check uses the newest LIVE session, not sessions[0].
remove_session_registry
echo "{\"sessions\":[
{\"session_id\":\"live-old\",\"pid\":$$,\"hostname\":\"$HOST\",\"started_at\":\"2026-09-21T00:00:00Z\"},
{\"session_id\":\"dead-new\",\"pid\":$DEAD_PID,\"hostname\":\"$HOST\",\"started_at\":\"2026-09-21T09:00:00Z\"}]}" > "$GITMEM_DIR/active-sessions.json"
create_session_data "live-old" '[{"scar_id":"s1","scar_title":"Unconfirmed","source":"recall"}]' '[]'
create_session_data "dead-new" '[]' '[]'
GOT=$(bash -c '. "$0/scripts/resolve-root.sh"; gitmem_live_session_ids | tr "\n" " "' "$SCRIPT_DIR")
[ "$GOT" = "live-old " ] && pass "gitmem_live_session_ids: newest first, dead pid skipped" \
|| fail "live session ids" "live-old" "$GOT"
setup_state 0 0
OUTPUT=$(echo '{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}' | bash "$SCRIPT_DIR/scripts/recall-check.sh" 2>/dev/null)
echo "$OUTPUT" | grep -q "block" && pass "recall-check reads the live session's scars (dead newer entry skipped)" \
|| fail "recall-check live session" "block on unconfirmed scar of live-old" "$OUTPUT"

# The pid check needs no `ps` (minimal images have none).
GOT=$(PATH="$TMPDIR/no-ps-bin" "$BASH" -c '. "$0/scripts/resolve-root.sh"; gitmem_pid_alive $$ && echo alive; gitmem_pid_alive '"$DEAD_PID"' || echo dead' "$SCRIPT_DIR" 2>/dev/null | tr "\n" " ")
[ "$GOT" = "alive dead " ] && pass "gitmem_pid_alive works with no ps on PATH" \
|| fail "pid check without ps" "alive dead" "$GOT"
GOT=$(bash -c '. "$0/scripts/resolve-root.sh"; gitmem_pid_alive 1 && echo alive' "$SCRIPT_DIR")
[ "$GOT" = "alive" ] && pass "a live pid owned by another user (EPERM) counts as alive" \
|| fail "EPERM pid alive" "alive" "$GOT"

remove_session_registry

# ============================================================================
# Summary
# ============================================================================
Expand Down
31 changes: 31 additions & 0 deletions src/tools/session-close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,7 @@ export async function sessionClose(
// This keeps the visible MCP tool call small: just session_id + close_type.
const payloadPath = getGitmemPath("closing-payload.json");
let payloadConsumed = false;
let payloadReadError: string | null = null;
try {
if (fs.existsSync(payloadPath)) {
const filePayload = JSON.parse(fs.readFileSync(payloadPath, "utf-8")) as Partial<SessionCloseParams>;
Expand All @@ -944,6 +945,36 @@ export async function sessionClose(
}
} catch (error) {
console.warn("[session_close] Failed to read closing-payload.json:", error);
payloadReadError =
`closing-payload.json at ${path.resolve(payloadPath)} could not be read: ` +
`${error instanceof Error ? error.message : String(error)}. Fix or rewrite it, then call session_close again.`;
}

// GIT-99: a standard close with no reflection at all. The agent almost always
// wrote the payload — to a root this server does not read (the hook and the
// server resolved .gitmem differently). Validation would then report
// "requires task_completion" / "requires closing_reflection with N answers",
// which sends the agent to rewrite answers it already wrote. Name the path.
if (params.close_type === "standard" && !payloadConsumed && !params.closing_reflection) {
return {
success: false,
session_id: params.session_id || "",
close_compliance: {
close_type: "standard",
agent: detectAgent().agent,
checklist_displayed: false,
questions_answered_by_agent: false,
human_asked_for_corrections: false,
learnings_stored: 0,
scars_applied: 0,
},
validation_errors: [
payloadReadError ??
`closing-payload.json not found at ${path.resolve(payloadPath)}. ` +
`Write the closing payload to exactly that path (or pass closing_reflection inline), then call session_close again.`,
],
performance: buildPerformanceData("session_close", timer.stop(), 0),
};
}

// Sanitize scars_to_record: agents frequently write create_learning shape
Expand Down
Loading
Loading