feat(sync-memory): switch to mirror mode — repo is authoritative

Drops the additive-union semantics that resurrected deliberate deletions
across the fleet (see feedback_memory_sync_destructive_ok.md and the
2026-06-01 consolidation that came back the next morning).

New behavior:
  * file in REPO, not in PROFILE   -> copy REPO -> PROFILE  (unchanged)
  * file in PROFILE, not in REPO   -> DELETE from PROFILE   (was: copy back)
  * file in BOTH, identical        -> no-op
  * file in BOTH, differ           -> overwrite PROFILE     (was: log conflict)

Safety: aborts if the repo has <5 .md files (guards against a broken
repo wiping the profile store).

Test plan verified on GURU-BEAST-ROG:
  * dry-run + apply matched (2 copies + 10 overwrites + 0 deletes)
  * idempotent re-run = 79 identical, 0 ops
  * self-check memory category PASS
  * git status .claude/memory/ clean (script touched profile only)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 15:15:10 -07:00
parent 7955e5e8b9
commit ae51988557

View File

@@ -1,29 +1,35 @@
#!/bin/sh
# sync-memory.sh -- additive union between the REPO memory store and the
# machine-local HARNESS PROFILE memory store.
# 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 : <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.
# 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.
#
# ADDITIVE UNION -- NO DELETES, NO DESTRUCTIVE OVERWRITES:
# MIRROR MODE -- REPO IS AUTHORITATIVE:
# * 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 PROFILE but not in REPO -> DELETE from PROFILE (repo absence = intentional removal)
# * file in BOTH, identical -> nothing
# * file in BOTH, content differs -> DO NOT overwrite; log "CONFLICT (manual review)"
# * never deletes from either side.
# * 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; copy nothing, create nothing.
# --dry-run show what WOULD be copied/deleted/overwritten; change 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).
# Exit: 0 normally. Non-zero only on a hard setup error (no repo, no memory
# dir, or the safety threshold trips).
set -u
@@ -33,8 +39,10 @@ for arg in "$@"; do
--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."
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
;;
*)
@@ -217,9 +225,9 @@ fi
echo "[INFO] sync-memory.sh"
if [ "$DRY_RUN" -eq 1 ]; then
echo "[INFO] MODE: DRY-RUN (no files will be copied or created)"
echo "[INFO] MODE: DRY-RUN (no files will be copied, deleted, or created)"
else
echo "[INFO] MODE: APPLY (additive union)"
echo "[INFO] MODE: APPLY (mirror -- repo is source of truth)"
fi
echo "[INFO] repo store : $REPO_MEM"
if [ "$SKIP_PROFILE" -eq 1 ]; then
@@ -227,7 +235,7 @@ if [ "$SKIP_PROFILE" -eq 1 ]; then
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."
echo "[INFO] mirror mode: repo is source of truth; profile is synced to match."
exit 0
fi
echo "[INFO] profile store : $PROFILE_MEM"
@@ -273,22 +281,41 @@ trap 'rm -f "$NAMES_TMP"' EXIT INT TERM
[ -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
copied_p2r=0
conflicts=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"
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: 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
# 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
@@ -302,32 +329,57 @@ do_copy() {
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
do_copy "$p" "$r" "PROFILE->REPO" && copied_p2r=$((copied_p2r + 1))
# 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
echo "[CONFLICT] $name : repo and profile differ -- left untouched (manual review)"
conflicts=$((conflicts + 1))
# 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] copied PROFILE -> REPO : $copied_p2r"
echo "[INFO] identical (no action) : $identical"
echo "[INFO] conflicts (manual review): $conflicts"
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] additive-only: no file was deleted or overwritten on either side."
echo "[INFO] mirror mode: repo is source of truth; profile is synced to match."
exit 0