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
27 changes: 26 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,31 @@ jobs:
- name: Hook tests
run: bash hooks/tests/test-hooks.sh

# GIT-116: the same suite on stock macOS. The runner has Homebrew (and may
# have coreutils' timeout), so the suite runs with the system directories
# plus a directory holding only node — what a Mac without Homebrew has.
hooks-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

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

- name: Hook tests on a stock macOS PATH (no Homebrew, no timeout)
run: |
mkdir -p "$RUNNER_TEMP/nodeonly"
ln -s "$(command -v node)" "$RUNNER_TEMP/nodeonly/node"
STOCK="/usr/bin:/bin:/usr/sbin:/sbin:$RUNNER_TEMP/nodeonly"
for t in timeout gtimeout; do
if PATH="$STOCK" command -v "$t" >/dev/null; then echo "::error::$t is on the stock PATH"; exit 1; fi
done
# macOS 15+ ships /usr/bin/jq; older macOS does not. The jq-less path
# is covered by hooks-without-jq (Linux); report which this runner is.
PATH="$STOCK" command -v jq >/dev/null && echo "stock jq present: $(PATH="$STOCK" jq --version)" || echo "stock jq absent"
env -i HOME="$HOME" PATH="$STOCK" /bin/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
Expand Down Expand Up @@ -164,7 +189,7 @@ jobs:
retention-days: 14

publish:
needs: [build, hooks-without-jq, venue-gate]
needs: [build, hooks-without-jq, hooks-macos, venue-gate]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
Expand Down
43 changes: 28 additions & 15 deletions hooks/scripts/auto-retrieve-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,20 @@ HOOK_INPUT=$(cat -)
# Extract prompt from hook input
# ============================================================================

# Use node if available, fallback to basic extraction
# GIT-116: a missing tool is a visible, non-blocking hook error (exit 1 with a
# reason on stderr; exit 2 would block the prompt), never a silent "nothing
# relevant". Retrieval needs node, which the MCP server needs anyway — but the
# hook runs with the launcher's PATH, where an nvm/volta node may be absent.
gitmem_unavailable() {
echo "gitmem auto-retrieve is not running: $1" >&2
exit 1
}

if ! command -v node &>/dev/null; then
gitmem_unavailable "node is not on the hook's PATH (PATH=$PATH)"
fi

# Extract the prompt (node is required; checked above)
PROMPT=""
if command -v node &>/dev/null; then
PROMPT=$(echo "$HOOK_INPUT" | node -e "
Expand Down Expand Up @@ -134,10 +147,6 @@ fi
# Invoke quick-retrieve (Node module)
# ============================================================================

# Require node for retrieval
if ! command -v node &>/dev/null; then
exit 0
fi

# Locate quick-retrieve.js relative to plugin root
# Hook scripts are at: ${CLAUDE_PLUGIN_ROOT}/scripts/
Expand All @@ -150,13 +159,17 @@ if [ ! -f "$QUICK_RETRIEVE" ]; then
QUICK_RETRIEVE="${CLAUDE_PLUGIN_ROOT}/../dist/hooks/quick-retrieve.js"
fi

# If still not found, try the gitmem package location
# Hooks copied into <repo>/.gitmem/hooks (init, install-hooks) have no dist/
# beside them, so look where the package itself is installed. GIT-116: this
# searched for a package called "gitmem"; it is "gitmem-mcp". Most installs run
# it through npx, so npx's cache is searched too (newest first).
if [ ! -f "$QUICK_RETRIEVE" ]; then
# Check common install locations
REL="gitmem-mcp/dist/hooks/quick-retrieve.js"
BIN="$(command -v gitmem-mcp 2>/dev/null || true)"
for CANDIDATE in \
"/workspace/gitmem/dist/hooks/quick-retrieve.js" \
"$(npm root -g 2>/dev/null)/gitmem/dist/hooks/quick-retrieve.js" \
"$(dirname "$(which gitmem 2>/dev/null)")/../lib/node_modules/gitmem/dist/hooks/quick-retrieve.js"; do
${BIN:+"$(dirname "$BIN")/../lib/node_modules/$REL"} \
$(ls -1t "$HOME"/.npm/_npx/*/node_modules/$REL 2>/dev/null) \
"$(npm root -g 2>/dev/null)/$REL"; do
if [ -f "$CANDIDATE" ]; then
QUICK_RETRIEVE="$CANDIDATE"
break
Expand All @@ -165,13 +178,13 @@ if [ ! -f "$QUICK_RETRIEVE" ]; then
fi

if [ ! -f "$QUICK_RETRIEVE" ]; then
# Can't find quick-retrieve module — fail open
exit 0
gitmem_unavailable "quick-retrieve.js not found next to the hook, in npx's cache, or in the global node_modules"
fi

# Call quick-retrieve with prompt and level
# Timeout: 2.5s (leave 500ms buffer within 3s hook timeout)
RESULT=$(timeout 2.5 node "$QUICK_RETRIEVE" "$PROMPT" "$RETRIEVAL_LEVEL" 2>/dev/null) || true
# Call quick-retrieve with prompt and level. It bounds its own run (2.5 s,
# inside the 3 s hook budget): GIT-116 dropped the `timeout` wrapper, which
# macOS does not ship.
RESULT=$(node "$QUICK_RETRIEVE" "$PROMPT" "$RETRIEVAL_LEVEL" 2>/dev/null) || true

# Empty result → nothing relevant found
if [ -z "$RESULT" ]; then
Expand Down
4 changes: 4 additions & 0 deletions hooks/scripts/credential-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ if [ "$TOOL_NAME" = "Read" ]; then

# Block reading known credential files
if echo "$BASENAME" | grep -qEi "$CREDENTIAL_FILES_PATTERN"; then
# GIT-116: escape for the JSON below. A quote or backslash in the path
# made the output invalid JSON, which Claude Code treats as "allow".
FILE_PATH=$(printf '%s' "$FILE_PATH" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')
BASENAME=$(printf '%s' "$BASENAME" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')
cat <<HOOKJSON
{
"decision": "block",
Expand Down
20 changes: 15 additions & 5 deletions hooks/scripts/resolve-root.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,21 @@ 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.
# Is this pid a running process? `kill -0` (a builtin) answers for our own
# processes. When it fails the process is either gone or another user's (EPERM):
# /proc tells them apart on Linux, `ps` (always present on macOS) elsewhere.
# GIT-116: this used to match kill's English error text, which a non-English
# locale changes.
gitmem_pid_alive() {
kill -0 "$1" 2>/dev/null && return 0
if [ -d /proc/self ]; then
[ -d "/proc/$1" ] && return 0 || return 1
fi
if command -v ps >/dev/null 2>&1; then
ps -p "$1" >/dev/null 2>&1 && return 0 || return 1
fi
local out
out=$(kill -0 "$1" 2>&1) && return 0
out=$(LC_ALL=C kill -0 "$1" 2>&1)
case "$out" in *ermitted*) return 0 ;; esac
return 1
}
Expand All @@ -59,7 +68,8 @@ gitmem_pid_alive() {
gitmem_live_session_ids() {
[ -f "$GITMEM_ACTIVE_SESSIONS" ] || return 0
local host rows
host=$(hostname 2>/dev/null || echo "")
# bash's own HOSTNAME is gethostname(), what the server's os.hostname() is.
host="${HOSTNAME:-$(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("|")' \
Expand Down
41 changes: 25 additions & 16 deletions hooks/scripts/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@

set -e

# Debug logging — verify hook actually fires
PLUGIN_LOG="/tmp/gitmem-hooks-plugin-debug.log"
echo "[$(date)] PLUGIN SessionStart hook invoked" >> "$PLUGIN_LOG"
# Debug logging — verify hook actually fires. GIT-116: per user and in
# $TMPDIR (macOS gives each user its own), and never fatal: under `set -e` an
# unwritable log — e.g. /tmp/…debug.log created by another user — used to kill
# the whole SessionStart hook.
PLUGIN_LOG="${TMPDIR:-/tmp}/gitmem-hooks-plugin-debug-$(id -u).log"
echo "[$(date)] PLUGIN SessionStart hook invoked" >> "$PLUGIN_LOG" 2>/dev/null || true

# Read hook input from stdin
HOOK_INPUT=$(cat -)
echo "[$(date)] Input: $HOOK_INPUT" >> "$PLUGIN_LOG"
echo "[$(date)] Input: $HOOK_INPUT" >> "$PLUGIN_LOG" 2>/dev/null || true

# ============================================================================
# Detect gitmem MCP server
Expand Down Expand Up @@ -98,8 +101,7 @@ fi
if [ "$GITMEM_DETECTED" = "false" ]; then
for SERVER_PATH in \
"${GITMEM_SERVER_PATH:-}" \
"$(which gitmem-mcp 2>/dev/null || echo '')" \
"$(which gitmem 2>/dev/null || echo '')"; do
"$(command -v gitmem-mcp 2>/dev/null || echo '')"; do
if [ -n "$SERVER_PATH" ] && [ -f "$SERVER_PATH" ]; then
GITMEM_DETECTED=true
DETECT_SOURCE="binary $SERVER_PATH"
Expand All @@ -122,13 +124,13 @@ fi
# MCP handshake completion. Total budget: ~8s (within 10s hook timeout).

if [ "$GITMEM_DETECTED" = "true" ]; then
GATE_START=$(date +%s%N 2>/dev/null || date +%s)
GATE_START=$(date +%s)
MAX_WAIT_SECS=7
POLL_INTERVAL=0.3
HANDSHAKE_BUFFER=0.5
SERVER_FOUND=false

echo "[$(date)] MCP readiness gate: waiting up to ${MAX_WAIT_SECS}s for gitmem server process..." >> "$PLUGIN_LOG"
echo "[$(date)] MCP readiness gate: waiting up to ${MAX_WAIT_SECS}s for gitmem server process..." >> "$PLUGIN_LOG" 2>/dev/null || true

# Build list of patterns to match the gitmem server process
# Covers: direct node invocation, npx, and symlinked binaries
Expand All @@ -140,12 +142,19 @@ if [ "$GITMEM_DETECTED" = "true" ]; then
# Poll every 0.3s. Max iterations = MAX_WAIT_SECS / 0.3 ≈ 23
MAX_POLLS=$(( MAX_WAIT_SECS * 10 / 3 ))
POLL=0
# GIT-116: pgrep ships with macOS and procps, but not with every slim
# image. Without it every poll failed and the gate stalled for its full
# 7 s before continuing; skip the wait instead, and say so.
if ! command -v pgrep >/dev/null 2>&1; then
echo "[$(date)] MCP readiness gate: pgrep not available — not waiting for the server process" >> "$PLUGIN_LOG" 2>/dev/null || true
MAX_POLLS=0
fi
while [ "$POLL" -lt "$MAX_POLLS" ]; do
for PATTERN in "${GITMEM_PATTERNS[@]}"; do
if pgrep -f "$PATTERN" > /dev/null 2>&1; then
SERVER_FOUND=true
WAIT_SECS=$(( POLL * 3 / 10 ))
echo "[$(date)] MCP readiness gate: gitmem process found (pattern: $PATTERN) after ~${WAIT_SECS}.$(( POLL * 3 % 10 ))s" >> "$PLUGIN_LOG"
echo "[$(date)] MCP readiness gate: gitmem process found (pattern: $PATTERN) after ~${WAIT_SECS}.$(( POLL * 3 % 10 ))s" >> "$PLUGIN_LOG" 2>/dev/null || true
# Buffer for MCP protocol handshake to complete
sleep "$HANDSHAKE_BUFFER"
break 2
Expand All @@ -156,11 +165,11 @@ if [ "$GITMEM_DETECTED" = "true" ]; then
done

if [ "$SERVER_FOUND" = "false" ]; then
echo "[$(date)] MCP readiness gate: gitmem process NOT found after ${MAX_WAIT_SECS}s — proceeding anyway (config detected, server may use different process name)" >> "$PLUGIN_LOG"
echo "[$(date)] MCP readiness gate: gitmem process NOT found after ${MAX_WAIT_SECS}s — proceeding anyway (config detected, server may use different process name)" >> "$PLUGIN_LOG" 2>/dev/null || true
fi

GATE_END=$(date +%s%N 2>/dev/null || date +%s)
echo "[$(date)] MCP readiness gate completed" >> "$PLUGIN_LOG"
GATE_END=$(date +%s)
echo "[$(date)] MCP readiness gate completed" >> "$PLUGIN_LOG" 2>/dev/null || true
fi

# ============================================================================
Expand All @@ -179,7 +188,7 @@ echo "0" > "$STATE_DIR/last_nag_time"
# ============================================================================

if [ "$GITMEM_DETECTED" = "true" ]; then
echo "[$(date)] Gitmem DETECTED via ${DETECT_SOURCE} — instructing agent to call session_start via MCP" >> "$PLUGIN_LOG"
echo "[$(date)] Gitmem DETECTED via ${DETECT_SOURCE} — instructing agent to call session_start via MCP" >> "$PLUGIN_LOG" 2>/dev/null || true

# Instruct agent to call session_start via MCP.
# The MCP server creates sessions with its own PID, enabling multi-session
Expand All @@ -197,7 +206,7 @@ if [ "$GITMEM_DETECTED" = "true" ]; then
fi

if [ -n "$GITMEM_PROJECT" ]; then
echo "[$(date)] Project from config: ${GITMEM_PROJECT}" >> "$PLUGIN_LOG"
echo "[$(date)] Project from config: ${GITMEM_PROJECT}" >> "$PLUGIN_LOG" 2>/dev/null || true
cat <<HOOK_MSG
SESSION START — ACTIVE

Expand All @@ -224,10 +233,10 @@ every session. Use recall before tasks to see what past sessions discovered.
The more you use it, the better it gets.
HOOK_MSG2
else
echo "[$(date)] Gitmem NOT detected (checked: project .mcp.json, mcp-config, ~/.claude.json, disk)" >> "$PLUGIN_LOG"
echo "[$(date)] Gitmem NOT detected (checked: project .mcp.json, mcp-config, ~/.claude.json, disk)" >> "$PLUGIN_LOG" 2>/dev/null || true
echo "GITMEM PLUGIN: GitMem MCP server not detected. Session lifecycle hooks are inactive. To enable, ensure gitmem is configured via --mcp-config, project .mcp.json, or set GITMEM_ENABLED=true."
fi

echo "[$(date)] Hook completed, exit 0" >> "$PLUGIN_LOG"
echo "[$(date)] Hook completed, exit 0" >> "$PLUGIN_LOG" 2>/dev/null || true

exit 0
86 changes: 86 additions & 0 deletions hooks/tests/test-hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,92 @@ GOT=$(bash -c '. "$0/scripts/resolve-root.sh"; gitmem_pid_alive 1 && echo alive'

remove_session_registry

# ============================================================================
# TEST GROUP: portability (GIT-116)
# ============================================================================

echo ""
echo -e "${YELLOW}=== Portability (GIT-116) ===${NC}"

# A PATH holding every command on the current PATH except the ones named.
path_without() {
local out="$1"; shift
mkdir -p "$out"
local d f n old_ifs="$IFS"
IFS=:
for d in $PATH; do
[ -d "$d" ] || continue
for f in "$d"/*; do
n=$(basename "$f")
[ -x "$f" ] && [ ! -e "$out/$n" ] && ln -s "$f" "$out/$n" 2>/dev/null || true
done
done
IFS="$old_ifs"
for n in "$@"; do rm -f "$out/$n"; done
echo "$out"
}

# A fake installed package: hooks/scripts next to dist/hooks/quick-retrieve.js.
PKG="$TMPDIR/pkg"
mkdir -p "$PKG/hooks/scripts" "$PKG/dist/hooks"
cp "$SCRIPT_DIR"/scripts/*.sh "$PKG/hooks/scripts/"
cat > "$PKG/dist/hooks/quick-retrieve.js" <<'STUB'
process.stdout.write("STUB-SCAR for: " + process.argv[2]);
STUB
PROMPT_JSON='{"prompt":"add a retry to the deploy script before the migration"}'

NO_TIMEOUT_PATH=$(path_without "$TMPDIR/bin-no-timeout" timeout gtimeout)
OUT=$(echo "$PROMPT_JSON" | PATH="$NO_TIMEOUT_PATH" bash "$PKG/hooks/scripts/auto-retrieve-hook.sh" 2>/dev/null) || true
echo "$OUT" | grep -q "STUB-SCAR" && pass "auto-retrieve works with no timeout/gtimeout on PATH (stock macOS)" \
|| fail "auto-retrieve without timeout" "STUB-SCAR in additionalContext" "$OUT"

NO_NODE_PATH=$(path_without "$TMPDIR/bin-no-node" node)
set +e
ERR=$(echo "$PROMPT_JSON" | PATH="$NO_NODE_PATH" bash "$PKG/hooks/scripts/auto-retrieve-hook.sh" 2>&1 >/dev/null); CODE=$?
set -e
[ "$CODE" = "1" ] && echo "$ERR" | grep -q "node is not on the hook's PATH" \
&& pass "no node: visible non-blocking error (exit 1, reason on stderr)" \
|| fail "no node visible" "exit 1 + reason" "exit=$CODE err=$ERR"

# Hooks copied into a repo (init / install-hooks): no dist beside them.
COPIED="$TMPDIR/repo/.gitmem/hooks"; mkdir -p "$COPIED"; cp "$SCRIPT_DIR"/scripts/*.sh "$COPIED/"
EMPTY_HOME="$TMPDIR/empty-home"; mkdir -p "$EMPTY_HOME"
NO_PKG_PATH=$(path_without "$TMPDIR/bin-no-pkg" gitmem-mcp npm)
set +e
ERR=$(echo "$PROMPT_JSON" | HOME="$EMPTY_HOME" PATH="$NO_PKG_PATH" bash "$COPIED/auto-retrieve-hook.sh" 2>&1 >/dev/null); CODE=$?
set -e
[ "$CODE" = "1" ] && echo "$ERR" | grep -q "quick-retrieve.js not found" \
&& pass "module not found: visible non-blocking error, not a silent 'nothing relevant'" \
|| fail "module not found visible" "exit 1 + reason" "exit=$CODE err=$ERR"

NPX_HOME="$TMPDIR/npx-home"
mkdir -p "$NPX_HOME/.npm/_npx/abc123/node_modules/gitmem-mcp/dist/hooks"
cp "$PKG/dist/hooks/quick-retrieve.js" "$NPX_HOME/.npm/_npx/abc123/node_modules/gitmem-mcp/dist/hooks/"
OUT=$(echo "$PROMPT_JSON" | HOME="$NPX_HOME" PATH="$NO_PKG_PATH" bash "$COPIED/auto-retrieve-hook.sh" 2>/dev/null) || true
echo "$OUT" | grep -q "STUB-SCAR" && pass "copied hooks find gitmem-mcp in npx's cache" \
|| fail "npx cache lookup" "STUB-SCAR" "$OUT"

# session-start: no pgrep must not stall the 10 s hook for the 7 s gate.
NO_PGREP_PATH=$(path_without "$TMPDIR/bin-no-pgrep" pgrep)
T0=$(date +%s)
OUT=$(echo '{}' | GITMEM_ENABLED=true PATH="$NO_PGREP_PATH" bash "$SCRIPT_DIR/scripts/session-start.sh" 2>/dev/null) || true
ELAPSED=$(( $(date +%s) - T0 ))
[ "$ELAPSED" -lt 4 ] && echo "$OUT" | grep -q "SESSION START" \
&& pass "session-start without pgrep does not stall (${ELAPSED}s)" \
|| fail "session-start without pgrep" "<4s and SESSION START" "${ELAPSED}s: $OUT"

# session-start: an unwritable debug log must not kill the hook under set -e.
BAD_TMP="$TMPDIR/bad-tmp"; mkdir -p "$BAD_TMP/gitmem-hooks-plugin-debug-$(id -u).log"
OUT=$(echo '{}' | GITMEM_ENABLED=true TMPDIR="$BAD_TMP" PATH="$NO_PGREP_PATH" bash "$SCRIPT_DIR/scripts/session-start.sh" 2>/dev/null) || true
echo "$OUT" | grep -q "SESSION START" && pass "session-start survives an unwritable debug log" \
|| fail "unwritable debug log" "SESSION START" "$OUT"

# credential-guard: a quote or backslash in the path must still produce JSON that blocks.
OUT=$(printf '{"tool_name":"Read","tool_input":{"file_path":"/x/we\\"ird\\\\d/.env"}}' | bash "$SCRIPT_DIR/scripts/credential-guard.sh" 2>/dev/null) || true
echo "$OUT" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{process.exit(JSON.parse(d).decision==="block"?0:1)})' 2>/dev/null \
&& pass "credential-guard block is valid JSON for a path with quotes" \
|| fail "credential-guard JSON" "valid JSON, decision block" "$OUT"

# ============================================================================
# Summary
# ============================================================================
Expand Down
Loading
Loading