#!/usr/bin/env bash # Golden fixture harness for memory plugin hooks # Usage: ./harness.sh [--scenario-dir ] # Builds sandbox, runs hook, emits normalized stdout+sideeffects for diffing # # Normalization rules (applied in this order to avoid corruption): # 1. ISO 8601 timestamps (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z) → __TIMESTAMP__ # 2. Date-only (\d{4}-\d{2}-\d{2}) → __DATE__ # 3. Sandbox temp path ($SANDBOX) → __SANDBOX__ # 4. cwd= log values → cwd=__CWD__ (varies by machine/invocation dir) # 5. Real home /home/jared → __REAL_HOME__ # # SAFETY: git shim blocks any git call targeting /home/jared/.memsearch set -euo pipefail # Set HOOKS_DIR to override, e.g. for Python port replay: # HOOKS_DIR=/path/to/python-hooks ./harness.sh ... HOOKS_DIR="${HOOKS_DIR:-$(cd "$(dirname "$0")/../hooks" && pwd -P)}" usage() { echo "Usage: $0 [--scenario-dir ]" >&2 exit 1 } [[ $# -lt 2 ]] && usage HOOK_NAME="$1" INPUT_JSON_FILE="$2" SCENARIO_DIR="" shift 2 while [[ $# -gt 0 ]]; do case "$1" in --scenario-dir) SCENARIO_DIR="$2"; shift 2 ;; *) echo "Unknown arg: $1" >&2; usage ;; esac done # Validate hook name HOOK_SCRIPT="$HOOKS_DIR/${HOOK_NAME}.sh" if [[ ! -f "$HOOK_SCRIPT" ]]; then echo "Hook script not found: $HOOK_SCRIPT" >&2 exit 1 fi # --------------------------------------------------------------------------- # 1. Create sandbox # --------------------------------------------------------------------------- SANDBOX=$(mktemp -d) # Canonicalize to avoid symlink leaks (e.g. /tmp → /private/tmp on macOS) SANDBOX=$(cd "$SANDBOX" && pwd -P) cleanup() { rm -rf "$SANDBOX" } trap cleanup EXIT # --------------------------------------------------------------------------- # 2. Populate sandbox structure # --------------------------------------------------------------------------- mkdir -p "$SANDBOX/.claude/plugins/memory" cat > "$SANDBOX/.claude/plugins/memory/config.yaml" < "$SANDBOX/bin/graphify" <> "${SANDBOX}/graphify-shim.log" exit 0 BASH chmod +x "$SANDBOX/bin/graphify" # --------------------------------------------------------------------------- # 4. Git shim — SAFETY CRITICAL # Absolute log path baked in at write-time (not env-var-only) so it works # inside session-end's subshell even if env is stripped. # Emits NOTHING to stdout/stderr — only writes to log file. # Blocks any git call with /home/jared/.memsearch as an argument. # --------------------------------------------------------------------------- export SANDBOX_GIT_SHIM_LOG="$SANDBOX/git-shim.log" cat > "$SANDBOX/bin/git" <> "${SANDBOX}/git-shim.log" exit 0 fi done exec /usr/bin/git "\$@" BASH chmod +x "$SANDBOX/bin/git" # --------------------------------------------------------------------------- # 5. Expand __SANDBOX__ in input JSON, write to temp file # --------------------------------------------------------------------------- INPUT_EXPANDED="$SANDBOX/input-expanded.json" sed "s|__SANDBOX__|${SANDBOX}|g" "$INPUT_JSON_FILE" > "$INPUT_EXPANDED" # --------------------------------------------------------------------------- # 6. Export environment for hook run # --------------------------------------------------------------------------- export PATH="$SANDBOX/bin:$PATH" export HOME="$SANDBOX" # --------------------------------------------------------------------------- # 7. Normalization function # --------------------------------------------------------------------------- normalize() { local sandbox_path="$1" sed \ -e "s|[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}T[0-9]\{2\}:[0-9]\{2\}:[0-9]\{2\}Z|__TIMESTAMP__|g" \ -e "s|[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}|__DATE__|g" \ -e "s|${sandbox_path}|__SANDBOX__|g" \ -e "s|cwd=[^ ]*|cwd=__CWD__|g" \ -e "s|/home/jared|__REAL_HOME__|g" } # --------------------------------------------------------------------------- # 8. Record log file offset before run (for delta capture) # --------------------------------------------------------------------------- LOG_OFFSET=0 if [[ -f /tmp/memory-hook.log ]]; then LOG_OFFSET=$(wc -c < /tmp/memory-hook.log) fi # --------------------------------------------------------------------------- # 9. Run hook # --------------------------------------------------------------------------- HOOK_STDOUT="" HOOK_EXIT=0 HOOK_STDOUT=$(bash "$HOOK_SCRIPT" < "$INPUT_EXPANDED" 2>/dev/null) || HOOK_EXIT=$? # For session-start-stale: poll for graphify-shim.log up to 5 seconds if [[ "$HOOK_NAME" == "session-start" ]]; then for i in $(seq 1 10); do if [[ -f "$SANDBOX/graphify-shim.log" ]]; then break fi sleep 0.5 done fi # --------------------------------------------------------------------------- # 10. Output normalized stdout and exit code # --------------------------------------------------------------------------- echo "$HOOK_STDOUT" | normalize "$SANDBOX" echo "EXIT_CODE: $HOOK_EXIT" # --------------------------------------------------------------------------- # 11. If scenario-dir given, write artifacts there # --------------------------------------------------------------------------- if [[ -n "$SCENARIO_DIR" ]]; then mkdir -p "$SCENARIO_DIR/sideeffects" echo "$HOOK_STDOUT" | normalize "$SANDBOX" > "$SCENARIO_DIR/stdout.txt" echo "$HOOK_EXIT" > "$SCENARIO_DIR/exit_code.txt" cp "$INPUT_JSON_FILE" "$SCENARIO_DIR/input.json" # Memory hook log delta if [[ -f /tmp/memory-hook.log ]]; then dd if=/tmp/memory-hook.log bs=1 skip="$LOG_OFFSET" 2>/dev/null \ | normalize "$SANDBOX" \ > "$SCENARIO_DIR/sideeffects/memory-hook-log-delta.txt" else echo "(no log entries)" > "$SCENARIO_DIR/sideeffects/memory-hook-log-delta.txt" fi # Graphify shim log if [[ -f "$SANDBOX/graphify-shim.log" ]]; then normalize "$SANDBOX" < "$SANDBOX/graphify-shim.log" \ > "$SCENARIO_DIR/sideeffects/graphify-shim-args.txt" fi # Git shim log if [[ -f "$SANDBOX/git-shim.log" ]]; then normalize "$SANDBOX" < "$SANDBOX/git-shim.log" \ > "$SCENARIO_DIR/sideeffects/git-shim-log.txt" fi # --------------------------------------------------------------------------- # Hook-specific side effects # --------------------------------------------------------------------------- SESSION_ID=$(python3 -c "import json; d=json.load(open('$INPUT_EXPANDED')); print(d.get('session_id','unknown'))" 2>/dev/null || echo "unknown") if [[ "$HOOK_NAME" == "session-end" ]]; then # Journal file (find by glob) local journal_file journal_file=$(ls "$SANDBOX/vault/journal/"*.md 2>/dev/null | head -1) if [[ -n "$journal_file" && -f "$journal_file" ]]; then normalize "$SANDBOX" < "$journal_file" > "$SCENARIO_DIR/sideeffects/journal.txt" else echo "(journal file not created)" > "$SCENARIO_DIR/sideeffects/journal.txt" fi # Touch file deleted? if [[ ! -f "/tmp/claude-vault-touched-${SESSION_ID}" ]]; then echo "deleted" > "$SCENARIO_DIR/sideeffects/touch-file-deleted.txt" else echo "NOT deleted (unexpected)" > "$SCENARIO_DIR/sideeffects/touch-file-deleted.txt" fi fi if [[ "$HOOK_NAME" == "post-tool-use-write" ]]; then # Touch file content if [[ -f "/tmp/claude-vault-touched-${SESSION_ID}" ]]; then normalize "$SANDBOX" < "/tmp/claude-vault-touched-${SESSION_ID}" \ > "$SCENARIO_DIR/sideeffects/touch-file.txt" else echo "(not created)" > "$SCENARIO_DIR/sideeffects/touch-file.txt" fi # Post-tool-use log if [[ -f "$SANDBOX/.cache/graphify/post-tool-use.log" ]]; then normalize "$SANDBOX" < "$SANDBOX/.cache/graphify/post-tool-use.log" \ > "$SCENARIO_DIR/sideeffects/post-tool-use-log.txt" else echo "(not created)" > "$SCENARIO_DIR/sideeffects/post-tool-use-log.txt" fi # Stamp deleted? if [[ ! -f "$SANDBOX/.cache/graphify/vault-rebuild.stamp" ]]; then echo "yes" > "$SCENARIO_DIR/sideeffects/stamp-deleted.txt" else echo "no" > "$SCENARIO_DIR/sideeffects/stamp-deleted.txt" fi fi if [[ "$HOOK_NAME" == "session-context" ]]; then # Flag file created? if [[ -f "/tmp/memory-context-injected-${SESSION_ID}" ]]; then echo "yes" > "$SCENARIO_DIR/sideeffects/flag-file-created.txt" else echo "no" > "$SCENARIO_DIR/sideeffects/flag-file-created.txt" fi fi fi