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:
@@ -1,29 +1,35 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# sync-memory.sh -- additive union between the REPO memory store and the
|
# sync-memory.sh -- MIRROR the REPO memory store into the machine-local
|
||||||
# machine-local HARNESS PROFILE memory store.
|
# HARNESS PROFILE memory store. Repo is the source of truth.
|
||||||
#
|
#
|
||||||
# WHY: There are TWO memory stores on every machine:
|
# WHY: There are TWO memory stores on every machine:
|
||||||
# REPO store : <root>/.claude/memory/ (git-tracked, synced via Gitea -- SOURCE OF TRUTH)
|
# 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;
|
# PROFILE store : $HOME/.claude/projects/<slug>/memory/ (machine-local, NOT in git;
|
||||||
# the harness auto-injects THIS into the prompt)
|
# the harness auto-injects THIS into the prompt)
|
||||||
# They drift. This script keeps the auto-injected PROFILE store in step with the
|
# They drift. This script keeps the auto-injected PROFILE store in lock step
|
||||||
# synced REPO store, while preserving any memories authored only on this machine.
|
# 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 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, identical -> nothing
|
||||||
# * file in BOTH, content differs -> DO NOT overwrite; log "CONFLICT (manual review)"
|
# * file in BOTH, content differs -> overwrite PROFILE with REPO (repo wins)
|
||||||
# * never deletes from either side.
|
# * 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,
|
# Idempotent and safe to run repeatedly on every machine (Windows Git Bash,
|
||||||
# macOS, Linux). ASCII output only. No hardcoded drive paths.
|
# macOS, Linux). ASCII output only. No hardcoded drive paths.
|
||||||
#
|
#
|
||||||
# Flags:
|
# 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,
|
# Exit: 0 normally. Non-zero only on a hard setup error (no repo, no memory
|
||||||
# not fatal). Non-zero only on a hard setup error (no repo, no memory dir).
|
# dir, or the safety threshold trips).
|
||||||
|
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
@@ -33,8 +39,10 @@ for arg in "$@"; do
|
|||||||
--dry-run) DRY_RUN=1 ;;
|
--dry-run) DRY_RUN=1 ;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: sync-memory.sh [--dry-run]"
|
echo "Usage: sync-memory.sh [--dry-run]"
|
||||||
echo " Additive union of the repo and harness-profile memory stores."
|
echo " Mirror the repo memory store into the harness-profile memory store."
|
||||||
echo " --dry-run print planned copies; change nothing."
|
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
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
@@ -217,9 +225,9 @@ fi
|
|||||||
|
|
||||||
echo "[INFO] sync-memory.sh"
|
echo "[INFO] sync-memory.sh"
|
||||||
if [ "$DRY_RUN" -eq 1 ]; then
|
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
|
else
|
||||||
echo "[INFO] MODE: APPLY (additive union)"
|
echo "[INFO] MODE: APPLY (mirror -- repo is source of truth)"
|
||||||
fi
|
fi
|
||||||
echo "[INFO] repo store : $REPO_MEM"
|
echo "[INFO] repo store : $REPO_MEM"
|
||||||
if [ "$SKIP_PROFILE" -eq 1 ]; then
|
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] profile slug : (skipped -- ambiguous match)"
|
||||||
echo "[INFO] ----- summary -----"
|
echo "[INFO] ----- summary -----"
|
||||||
echo "[INFO] profile sync SKIPPED: multiple candidate profile dirs matched; refusing to guess."
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "[INFO] profile store : $PROFILE_MEM"
|
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"
|
[ -d "$PROFILE_MEM" ] && list_md "$PROFILE_MEM"
|
||||||
} | sort -u > "$NAMES_TMP"
|
} | 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_r2p=0
|
||||||
copied_p2r=0
|
deleted_profile=0
|
||||||
conflicts=0
|
overwrote_profile=0
|
||||||
identical=0
|
identical=0
|
||||||
|
|
||||||
# Copy helper honoring dry-run.
|
# 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() {
|
do_copy() {
|
||||||
src="$1"; dst="$2"; label="$3"
|
src="$1"; dst="$2"; label="$3"; overwrite="${4:-0}"
|
||||||
if [ "$DRY_RUN" -eq 1 ]; then
|
if [ "$DRY_RUN" -eq 1 ]; then
|
||||||
echo "[DRY-RUN] would copy $label : $(basename -- "$src")"
|
echo "[DRY-RUN] would copy $label : $(basename -- "$src")"
|
||||||
else
|
else
|
||||||
# Belt-and-suspenders no-clobber: even though the caller only invokes
|
# Belt-and-suspenders no-clobber: re-check destination existence to
|
||||||
# do_copy when the destination is absent, re-check here to close any
|
# close any TOCTOU window. In MIRROR MODE the conflict path
|
||||||
# TOCTOU window and protect against future refactors. ADDITIVE-ONLY:
|
# legitimately overwrites, so callers pass overwrite=1 for that case.
|
||||||
# never overwrite an existing destination.
|
if [ -e "$dst" ] && [ "$overwrite" -ne 1 ]; then
|
||||||
if [ -e "$dst" ]; then
|
|
||||||
echo "[SKIP] dst appeared, not overwriting: $(basename -- "$dst")"
|
echo "[SKIP] dst appeared, not overwriting: $(basename -- "$dst")"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -302,32 +329,57 @@ do_copy() {
|
|||||||
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
|
while IFS= read -r name; do
|
||||||
[ -n "$name" ] || continue
|
[ -n "$name" ] || continue
|
||||||
r="$REPO_MEM/$name"
|
r="$REPO_MEM/$name"
|
||||||
p="$PROFILE_MEM/$name"
|
p="$PROFILE_MEM/$name"
|
||||||
if [ -f "$r" ] && [ ! -f "$p" ]; then
|
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))
|
do_copy "$r" "$p" "REPO->PROFILE" && copied_r2p=$((copied_r2p + 1))
|
||||||
elif [ ! -f "$r" ] && [ -f "$p" ]; then
|
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
|
elif [ -f "$r" ] && [ -f "$p" ]; then
|
||||||
if cmp -s "$r" "$p"; then
|
if cmp -s "$r" "$p"; then
|
||||||
identical=$((identical + 1))
|
identical=$((identical + 1))
|
||||||
else
|
else
|
||||||
echo "[CONFLICT] $name : repo and profile differ -- left untouched (manual review)"
|
# Mirror mode: repo wins, overwrite profile.
|
||||||
conflicts=$((conflicts + 1))
|
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
|
||||||
fi
|
fi
|
||||||
done < "$NAMES_TMP"
|
done < "$NAMES_TMP"
|
||||||
|
|
||||||
echo "[INFO] ----- summary -----"
|
echo "[INFO] ----- summary -----"
|
||||||
echo "[INFO] copied REPO -> PROFILE : $copied_r2p"
|
echo "[INFO] copied REPO -> PROFILE : $copied_r2p"
|
||||||
echo "[INFO] copied PROFILE -> REPO : $copied_p2r"
|
echo "[INFO] deleted from PROFILE : $deleted_profile"
|
||||||
echo "[INFO] identical (no action) : $identical"
|
echo "[INFO] overwrote PROFILE (repo-newer) : $overwrote_profile"
|
||||||
echo "[INFO] conflicts (manual review): $conflicts"
|
echo "[INFO] identical (no action) : $identical"
|
||||||
if [ "$DRY_RUN" -eq 1 ]; then
|
if [ "$DRY_RUN" -eq 1 ]; then
|
||||||
echo "[INFO] DRY-RUN: nothing was changed."
|
echo "[INFO] DRY-RUN: nothing was changed."
|
||||||
fi
|
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
|
exit 0
|
||||||
|
|||||||
Reference in New Issue
Block a user