From ae5198855708cdde0b5a02bbace2ac2a4f2e2f6c Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Tue, 2 Jun 2026 15:15:10 -0700 Subject: [PATCH] =?UTF-8?q?feat(sync-memory):=20switch=20to=20mirror=20mod?= =?UTF-8?q?e=20=E2=80=94=20repo=20is=20authoritative?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude/scripts/sync-memory.sh | 116 ++++++++++++++++++++++++--------- 1 file changed, 84 insertions(+), 32 deletions(-) diff --git a/.claude/scripts/sync-memory.sh b/.claude/scripts/sync-memory.sh index 45c4608..cac85a4 100755 --- a/.claude/scripts/sync-memory.sh +++ b/.claude/scripts/sync-memory.sh @@ -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 : /.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 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