Author: Mike Swanson Machine: Mikes-MacBook-Air.local Timestamp: 2026-06-02 14:49:12
334 lines
12 KiB
Bash
Executable File
334 lines
12 KiB
Bash
Executable File
#!/bin/sh
|
|
# sync-memory.sh -- additive union between the REPO memory store and the
|
|
# machine-local HARNESS PROFILE memory store.
|
|
#
|
|
# WHY: There are TWO memory stores on every machine:
|
|
# REPO store : <root>/.claude/memory/ (git-tracked, synced via Gitea -- SOURCE OF TRUTH)
|
|
# PROFILE store : $HOME/.claude/projects/<slug>/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 step with the
|
|
# synced REPO store, while preserving any memories authored only on this machine.
|
|
#
|
|
# ADDITIVE UNION -- NO DELETES, NO DESTRUCTIVE OVERWRITES:
|
|
# * file in REPO but not in PROFILE -> copy REPO -> PROFILE
|
|
# * file in PROFILE but not in REPO -> copy PROFILE -> REPO (capture local-only for git sync)
|
|
# * file in BOTH, identical -> nothing
|
|
# * file in BOTH, content differs -> DO NOT overwrite; log "CONFLICT (manual review)"
|
|
# * never deletes from either side.
|
|
#
|
|
# 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; copy nothing, create nothing.
|
|
#
|
|
# Exit: 0 normally (including when conflicts are present -- they are reported,
|
|
# not fatal). Non-zero only on a hard setup error (no repo, no memory dir).
|
|
|
|
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 " Additive union of the repo and harness-profile memory stores."
|
|
echo " --dry-run print planned copies; 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 <root>/.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
|
|
# "<drive>-" into "<drive>--".
|
|
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 or created)"
|
|
else
|
|
echo "[INFO] MODE: APPLY (additive union)"
|
|
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] additive-only: no file was deleted or overwritten on either side."
|
|
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"
|
|
|
|
copied_r2p=0
|
|
copied_p2r=0
|
|
conflicts=0
|
|
identical=0
|
|
|
|
# Copy helper honoring dry-run.
|
|
do_copy() {
|
|
src="$1"; dst="$2"; label="$3"
|
|
if [ "$DRY_RUN" -eq 1 ]; then
|
|
echo "[DRY-RUN] would copy $label : $(basename -- "$src")"
|
|
else
|
|
# Belt-and-suspenders no-clobber: even though the caller only invokes
|
|
# do_copy when the destination is absent, re-check here to close any
|
|
# TOCTOU window and protect against future refactors. ADDITIVE-ONLY:
|
|
# never overwrite an existing destination.
|
|
if [ -e "$dst" ]; 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
|
|
}
|
|
|
|
while IFS= read -r name; do
|
|
[ -n "$name" ] || continue
|
|
r="$REPO_MEM/$name"
|
|
p="$PROFILE_MEM/$name"
|
|
if [ -f "$r" ] && [ ! -f "$p" ]; then
|
|
do_copy "$r" "$p" "REPO->PROFILE" && copied_r2p=$((copied_r2p + 1))
|
|
elif [ ! -f "$r" ] && [ -f "$p" ]; then
|
|
do_copy "$p" "$r" "PROFILE->REPO" && copied_p2r=$((copied_p2r + 1))
|
|
elif [ -f "$r" ] && [ -f "$p" ]; then
|
|
if cmp -s "$r" "$p"; then
|
|
identical=$((identical + 1))
|
|
else
|
|
echo "[CONFLICT] $name : repo and profile differ -- left untouched (manual review)"
|
|
conflicts=$((conflicts + 1))
|
|
fi
|
|
fi
|
|
done < "$NAMES_TMP"
|
|
|
|
echo "[INFO] ----- summary -----"
|
|
echo "[INFO] copied REPO -> PROFILE : $copied_r2p"
|
|
echo "[INFO] copied PROFILE -> REPO : $copied_p2r"
|
|
echo "[INFO] identical (no action) : $identical"
|
|
echo "[INFO] conflicts (manual review): $conflicts"
|
|
if [ "$DRY_RUN" -eq 1 ]; then
|
|
echo "[INFO] DRY-RUN: nothing was changed."
|
|
fi
|
|
echo "[INFO] additive-only: no file was deleted or overwritten on either side."
|
|
|
|
exit 0
|