#!/bin/sh # sync-memory.sh -- MIRROR the REPO memory store into the machine-local # HARNESS PROFILE memory store. Repo is the source of truth. # # WHY: There are TWO memory stores on every machine: # REPO store : /.claude/memory/ (git-tracked, synced via Gitea -- SOURCE OF TRUTH) # PROFILE store : $HOME/.claude/projects//memory/ (machine-local, NOT in git; # the harness auto-injects THIS into the prompt) # They drift. This script keeps the auto-injected PROFILE store in lock step # with the synced REPO store. Memories must be authored into the REPO store # (`.claude/memory/`) to persist; PROFILE-only files are treated as stale and # removed. # # MIRROR MODE -- REPO IS AUTHORITATIVE: # * file in REPO but not in PROFILE -> copy REPO -> PROFILE # * file in PROFILE but not in REPO -> DELETE from PROFILE (repo absence = intentional removal) # * file in BOTH, identical -> nothing # * file in BOTH, content differs -> overwrite PROFILE with REPO (repo wins) # * never modifies the REPO store. MEMORY.md is excluded from sync. # # SAFETY: if the REPO store contains fewer than 5 *.md files (excluding # MEMORY.md), the script ABORTS before deleting anything from PROFILE. This # guards against a corrupted or empty repo wiping the profile store. # # Idempotent and safe to run repeatedly on every machine (Windows Git Bash, # macOS, Linux). ASCII output only. No hardcoded drive paths. # # Flags: # --dry-run show what WOULD be copied/deleted/overwritten; change nothing. # # Exit: 0 normally. Non-zero only on a hard setup error (no repo, no memory # dir, or the safety threshold trips). set -u DRY_RUN=0 for arg in "$@"; do case "$arg" in --dry-run) DRY_RUN=1 ;; -h|--help) echo "Usage: sync-memory.sh [--dry-run]" echo " Mirror the repo memory store into the harness-profile memory store." echo " Repo is the source of truth; profile-only files are removed and" echo " divergent files are overwritten with the repo version." echo " --dry-run print planned ops; change nothing." exit 0 ;; *) echo "[ERROR] unknown argument: $arg" >&2 echo "Usage: sync-memory.sh [--dry-run]" >&2 exit 2 ;; esac done # --- Resolve CLAUDETOOLS_ROOT (env -> identity.json -> git toplevel -> script dir) --- # Absolute dir of this script, POSIX-portable (no readlink -f on macOS). script_path="$0" case "$script_path" in /*) : ;; *) script_path="$(pwd)/$script_path" ;; esac SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$script_path")" && pwd)" ROOT="" if [ -n "${CLAUDETOOLS_ROOT:-}" ] && [ -d "${CLAUDETOOLS_ROOT}" ]; then ROOT="$CLAUDETOOLS_ROOT" fi # Walk up from the script dir to find a dir containing .claude/ (covers the # normal layout /.claude/scripts/sync-memory.sh). if [ -z "$ROOT" ]; then d="$SCRIPT_DIR" while [ "$d" != "/" ] && [ -n "$d" ]; do if [ -d "$d/.claude" ]; then ROOT="$d" break fi parent="$(dirname -- "$d")" [ "$parent" = "$d" ] && break d="$parent" done fi # identity.json may override with claudetools_root (authoritative per machine). if [ -n "$ROOT" ] && [ -f "$ROOT/.claude/identity.json" ]; then ID_ROOT="$( PYBIN="" for c in py python3 python; do if command -v "$c" >/dev/null 2>&1; then PYBIN="$c"; break; fi done if [ -n "$PYBIN" ]; then "$PYBIN" - "$ROOT/.claude/identity.json" <<'PYEOF' 2>/dev/null import json, os, sys try: d = json.load(open(sys.argv[1])) r = d.get("claudetools_root") if r and os.path.isdir(r): print(r) except Exception: pass PYEOF fi )" if [ -n "$ID_ROOT" ] && [ -d "$ID_ROOT" ]; then ROOT="$ID_ROOT" fi fi # Last resort: git toplevel. if [ -z "$ROOT" ]; then ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)" fi if [ -z "$ROOT" ] || [ ! -d "$ROOT/.claude" ]; then echo "[ERROR] could not resolve CLAUDETOOLS_ROOT (no .claude dir found)." >&2 exit 1 fi REPO_MEM="$ROOT/.claude/memory" if [ ! -d "$REPO_MEM" ]; then echo "[ERROR] repo memory dir not found: $REPO_MEM" >&2 exit 1 fi # --- Derive the harness profile memory dir --------------------------------- # Slug = absolute project path with every run of non-alphanumeric chars -> '-'. # Must match memory_dream.py's derivation. Prefer CLAUDE_PROJECT_DIR if set. HOME_DIR="${HOME:-$(cd ~ 2>/dev/null && pwd)}" PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$ROOT}" # Absolutize PROJECT_DIR. case "$PROJECT_DIR" in /*) : ;; *) PROJECT_DIR="$(CDPATH= cd -- "$PROJECT_DIR" 2>/dev/null && pwd || echo "$PROJECT_DIR")" ;; esac # Compute the slug. Use Python when available (exact, matches the analyzer); # otherwise fall back to sed/tr. # Single-dash collapse: replace any non-alphanumeric run with a single '-'. # Prefer Python (exact, matches the analyzer) when available, else sed. SLUG_SINGLE="" PYBIN="" for c in py python3 python; do if command -v "$c" >/dev/null 2>&1; then PYBIN="$c"; break; fi done if [ -n "$PYBIN" ]; then SLUG_SINGLE="$( "$PYBIN" - "$PROJECT_DIR" <<'PYEOF' 2>/dev/null import os, re, sys print(re.sub(r"[^A-Za-z0-9]+", "-", os.path.abspath(sys.argv[1]))) PYEOF )" fi if [ -z "$SLUG_SINGLE" ]; then SLUG_SINGLE="$(printf '%s' "$PROJECT_DIR" | sed 's/[^A-Za-z0-9][^A-Za-z0-9]*/-/g')" fi # The Claude Code harness maps a Windows drive colon to '--' (so # "D:\claudetools" -> "D--claudetools"), while the single-dash collapse above # produces "D-claudetools". Reproduce the harness rule by doubling a leading # "-" into "--". SLUG_DOUBLE="$(printf '%s' "$SLUG_SINGLE" | sed 's/^\([A-Za-z]\)-/\1--/')" # Resolve the profile dir. Try the EXACT candidate slugs in priority order # (harness double-dash first, then single-dash collapse); use the first whose # profile memory dir actually exists. PROJ_ROOT="$HOME_DIR/.claude/projects" SLUG="" PROFILE_MEM="" SKIP_PROFILE=0 for cand_slug in "$SLUG_DOUBLE" "$SLUG_SINGLE"; do [ -n "$cand_slug" ] || continue if [ -d "$PROJ_ROOT/$cand_slug/memory" ]; then SLUG="$cand_slug" PROFILE_MEM="$PROJ_ROOT/$cand_slug/memory" break elif [ -d "$PROJ_ROOT/$cand_slug" ]; then SLUG="$cand_slug" PROFILE_MEM="$PROJ_ROOT/$cand_slug/memory" break fi done # ONLY if no exact candidate exists, fall back to a case-insensitive tail-scan # of $HOME/.claude/projects/*/memory for a dir whose slug ends with the repo's # basename. If MORE THAN ONE dir matches, do NOT guess -- skip the profile side # entirely to avoid cross-project contamination. if [ -z "$PROFILE_MEM" ]; then # Default to the harness (double-dash) slug for messaging / dir creation. SLUG="$SLUG_DOUBLE" PROFILE_MEM="$PROJ_ROOT/$SLUG/memory" if [ -d "$PROJ_ROOT" ]; then BASE="$(basename -- "$ROOT")" BASE_SLUG="$(printf '%s' "$BASE" | sed 's/[^A-Za-z0-9][^A-Za-z0-9]*/-/g' | tr 'A-Z' 'a-z')" MATCH_COUNT=0 MATCH_LIST="" MATCH_DIR="" for cand in "$PROJ_ROOT"/*/memory; do [ -d "$cand" ] || continue cand_parent="$(basename -- "$(dirname -- "$cand")")" cand_lc="$(printf '%s' "$cand_parent" | tr 'A-Z' 'a-z')" case "$cand_lc" in *"$BASE_SLUG") MATCH_COUNT=$((MATCH_COUNT + 1)) MATCH_DIR="$cand" if [ -z "$MATCH_LIST" ]; then MATCH_LIST="$cand_parent" else MATCH_LIST="$MATCH_LIST, $cand_parent" fi ;; esac done if [ "$MATCH_COUNT" -gt 1 ]; then echo "[WARNING] multiple profile dirs matched ($MATCH_LIST); skipping profile sync to avoid cross-project contamination" SKIP_PROFILE=1 elif [ "$MATCH_COUNT" -eq 1 ]; then PROFILE_MEM="$MATCH_DIR" SLUG="$(basename -- "$(dirname -- "$MATCH_DIR")")" fi fi fi echo "[INFO] sync-memory.sh" if [ "$DRY_RUN" -eq 1 ]; then echo "[INFO] MODE: DRY-RUN (no files will be copied, deleted, or created)" else echo "[INFO] MODE: APPLY (mirror -- repo is source of truth)" fi echo "[INFO] repo store : $REPO_MEM" if [ "$SKIP_PROFILE" -eq 1 ]; then echo "[INFO] profile store : (skipped -- ambiguous match)" echo "[INFO] profile slug : (skipped -- ambiguous match)" echo "[INFO] ----- summary -----" echo "[INFO] profile sync SKIPPED: multiple candidate profile dirs matched; refusing to guess." echo "[INFO] mirror mode: repo is source of truth; profile is synced to match." exit 0 fi echo "[INFO] profile store : $PROFILE_MEM" echo "[INFO] profile slug : $SLUG" # Create the profile dir (apply mode only). if [ ! -d "$PROFILE_MEM" ]; then if [ "$DRY_RUN" -eq 1 ]; then echo "[INFO] would create profile dir: $PROFILE_MEM" else mkdir -p "$PROFILE_MEM" || { echo "[ERROR] could not create profile dir: $PROFILE_MEM" >&2 exit 1 } echo "[OK] created profile dir: $PROFILE_MEM" fi fi # --- Build the union of *.md basenames (excluding MEMORY.md) ---------------- # MEMORY.md (the human index) is intentionally NOT synced -- the repo index is # authoritative and the profile index is regenerated by the harness; copying it # either way would create a guaranteed perpetual "conflict". list_md() { # $1 = dir ; prints basenames of *.md except MEMORY.md, one per line dir="$1" [ -d "$dir" ] || return 0 for f in "$dir"/*.md; do [ -e "$f" ] || continue b="$(basename -- "$f")" case "$b" in MEMORY.md|memory.md) continue ;; esac printf '%s\n' "$b" done } # Collect names into a deduped, sorted list using a temp file (portable). NAMES_TMP="$(mktemp 2>/dev/null || echo "${TMPDIR:-/tmp}/sync-memory-names.$$")" trap 'rm -f "$NAMES_TMP"' EXIT INT TERM { list_md "$REPO_MEM" [ -d "$PROFILE_MEM" ] && list_md "$PROFILE_MEM" } | sort -u > "$NAMES_TMP" # --- Safety check: refuse to mirror from a suspiciously-empty repo store ---- # Count *.md files in REPO_MEM (excluding MEMORY.md). If too few, abort BEFORE # any destructive op so a corrupted/empty repo can't wipe the profile store. REPO_COUNT=0 for f in "$REPO_MEM"/*.md; do [ -e "$f" ] || continue b="$(basename -- "$f")" case "$b" in MEMORY.md|memory.md) continue ;; esac REPO_COUNT=$((REPO_COUNT + 1)) done if [ "$REPO_COUNT" -lt 5 ]; then echo "[ERROR] repo memory store has only $REPO_COUNT file(s); refusing to mirror -- would wipe profile store. Verify repo state and re-run." >&2 exit 1 fi copied_r2p=0 deleted_profile=0 overwrote_profile=0 identical=0 # Copy helper honoring dry-run. # $1 src, $2 dst, $3 label, $4 (optional) overwrite=1 to permit overwriting # an existing destination. Default behavior is no-clobber (STRICT_NO_CLOBBER). do_copy() { src="$1"; dst="$2"; label="$3"; overwrite="${4:-0}" if [ "$DRY_RUN" -eq 1 ]; then echo "[DRY-RUN] would copy $label : $(basename -- "$src")" else # Belt-and-suspenders no-clobber: re-check destination existence to # close any TOCTOU window. In MIRROR MODE the conflict path # legitimately overwrites, so callers pass overwrite=1 for that case. if [ -e "$dst" ] && [ "$overwrite" -ne 1 ]; then echo "[SKIP] dst appeared, not overwriting: $(basename -- "$dst")" return 0 fi # cp -p preserves mtime; portable across BSD/GNU. if cp -p "$src" "$dst" 2>/dev/null || cp "$src" "$dst"; then echo "[OK] copied $label : $(basename -- "$src")" else echo "[ERROR] failed to copy $label : $(basename -- "$src")" >&2 return 1 fi fi } # Delete helper honoring dry-run. Only ever called on PROFILE side. do_delete_profile() { target="$1" if [ "$DRY_RUN" -eq 1 ]; then echo "[DRY-RUN] would DELETE from PROFILE : $(basename -- "$target")" else if rm -f -- "$target"; then echo "[OK] deleted from PROFILE : $(basename -- "$target")" else echo "[ERROR] failed to delete from PROFILE : $(basename -- "$target")" >&2 return 1 fi fi } while IFS= read -r name; do [ -n "$name" ] || continue r="$REPO_MEM/$name" p="$PROFILE_MEM/$name" if [ -f "$r" ] && [ ! -f "$p" ]; then # In repo, not in profile -> copy repo -> profile. do_copy "$r" "$p" "REPO->PROFILE" && copied_r2p=$((copied_r2p + 1)) elif [ ! -f "$r" ] && [ -f "$p" ]; then # In profile, not in repo -> repo absence is a deliberate removal; # DELETE from profile. do_delete_profile "$p" && deleted_profile=$((deleted_profile + 1)) elif [ -f "$r" ] && [ -f "$p" ]; then if cmp -s "$r" "$p"; then identical=$((identical + 1)) else # Mirror mode: repo wins, overwrite profile. if [ "$DRY_RUN" -eq 1 ]; then echo "[DRY-RUN] would OVERWRITE PROFILE (repo newer) : $name" overwrote_profile=$((overwrote_profile + 1)) else if do_copy "$r" "$p" "REPO->PROFILE (overwrite)" 1; then overwrote_profile=$((overwrote_profile + 1)) fi fi fi fi done < "$NAMES_TMP" echo "[INFO] ----- summary -----" echo "[INFO] copied REPO -> PROFILE : $copied_r2p" echo "[INFO] deleted from PROFILE : $deleted_profile" echo "[INFO] overwrote PROFILE (repo-newer) : $overwrote_profile" echo "[INFO] identical (no action) : $identical" if [ "$DRY_RUN" -eq 1 ]; then echo "[INFO] DRY-RUN: nothing was changed." fi echo "[INFO] mirror mode: repo is source of truth; profile is synced to match." exit 0