diff --git a/README.md b/README.md index 7c96eb7..cfb2804 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,26 @@ write through a link and names the target, which is the safe behaviour. Claude Code may also rewrite `settings.json` itself when settings change from inside the app. After that, or whenever in doubt, run `./link-dotfiles --check`. +## Claude Code auto-memory (separate private repo) + +Auto-memory (`~/.claude/projects/*/memory`) is backed up to gitea +`vh/claude-memory`, not to this repo, because it changes constantly and holds +operational detail. `home_root/.local/bin/claude-memory-sync` does the work. Its +git dir is `~/claude-memory.git` and its work tree is `~/.claude/projects`, and +exclude rules admit only the `memory/` folders, so transcripts never leave the +machine and nothing extra appears inside `~/.claude`. + +```bash +claude-memory-sync # commit, rebase onto the remote, push +claude-memory-sync --status # last run + uncommitted count +claude-memory-sync --init # fresh machine: clone and wire up +systemctl --user enable --now claude-memory-sync.timer # hourly (unit files in home_config/systemd/user) +``` + +Project folders are named after absolute paths, so memory lines up across Linux +boxes; macOS (`/Users/...`) gets its own folders. `./link-dotfiles --check` flags a +failed or stale (>3h) sync. + ## Windows (PowerShell) `stow` doesn't run natively on Windows, so use the PowerShell equivalent. It diff --git a/home_config/systemd/user/claude-memory-sync.service b/home_config/systemd/user/claude-memory-sync.service new file mode 100644 index 0000000..5d2b68c --- /dev/null +++ b/home_config/systemd/user/claude-memory-sync.service @@ -0,0 +1,7 @@ +[Unit] +Description=Back up Claude Code auto-memory to gitea vh/claude-memory +Documentation=file://%h/.local/bin/claude-memory-sync + +[Service] +Type=oneshot +ExecStart=%h/.local/bin/claude-memory-sync diff --git a/home_config/systemd/user/claude-memory-sync.timer b/home_config/systemd/user/claude-memory-sync.timer new file mode 100644 index 0000000..5b7c65a --- /dev/null +++ b/home_config/systemd/user/claude-memory-sync.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Hourly Claude Code auto-memory backup + +[Timer] +OnCalendar=hourly +RandomizedDelaySec=5min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/home_root/.claude/CLAUDE.md b/home_root/.claude/CLAUDE.md index aa66595..0be53df 100644 --- a/home_root/.claude/CLAUDE.md +++ b/home_root/.claude/CLAUDE.md @@ -16,7 +16,16 @@ applies to every other linked file: in `~/.claude` that is `settings.json`, `statusline-command.sh`, `CONTEXT.md`, `keybindings.json`, `bin/ratecheck` and `skills/australis-design`, plus `~/.zshrc`, `~/.zshenv` and `~/.gitconfig`. `~/dotfiles/link-dotfiles ---check` reports any file that has come loose, and any dangling link. +--check` reports any file that has come loose, any dangling link, any +uncommitted change, and a stale auto-memory backup. A long-running +session can write its stale copy of `settings.json` back through the link, +so re-check after editing user settings. + +**Auto-memory** (`~/.claude/projects/*/memory`) is backed up hourly to +the private gitea repo `vh/claude-memory` by `claude-memory-sync` (git +dir `~/claude-memory.git`; `--status` shows the last run). Transcripts +stay local. **Never write a credential into a memory file:** memory is +copied off-box. ## Operator identity diff --git a/home_root/.local/bin/claude-memory-sync b/home_root/.local/bin/claude-memory-sync new file mode 100755 index 0000000..4cb43d6 --- /dev/null +++ b/home_root/.local/bin/claude-memory-sync @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Back up Claude Code auto-memory (~/.claude/projects/*/memory) to its own private +# repo, gitea vh/claude-memory. The git dir lives at ~/claude-memory.git with +# ~/.claude/projects as its work tree, so nothing is added inside ~/.claude and +# Claude Code keeps writing memory exactly as before. The exclude rules below +# admit only the memory/ folders; transcripts never leave the machine. +# +# claude-memory-sync commit local changes, rebase onto the remote, push +# claude-memory-sync --status last result, and what is waiting to be committed +# claude-memory-sync --init set up this machine from the remote (fresh box) +# +# Run hourly by the claude-memory-sync.timer systemd user unit. The result of every +# run goes to $state; `link-dotfiles --check` reports it when it is stale or failed. +# +# Project folders are named after absolute paths (-home-lkraven-development-X), so +# memory lines up across Linux boxes; macOS (/Users/...) gets its own folders. +set -euo pipefail + +repo="$HOME/claude-memory.git" +worktree="$HOME/.claude/projects" +remote="git@gitea.phasefinal.com:vh/claude-memory.git" +state="$HOME/.claude/state/claude-memory-sync.last" +g() { git --git-dir="$repo" "$@"; } + +write_exclude() { + cat > "$repo/info/exclude" <<'EOF' +# Work tree is ~/.claude/projects. Track ONLY each project's memory/ folder; +# transcripts (*.jsonl), tool results and everything else stay local. +# Written by claude-memory-sync; edit the script, not this file. +/* +!/*/ +/*/* +!/*/memory/ +.pytest_cache/ +*.tmp +.DS_Store +EOF +} + +record() { # $1 = ok|fail, $2 = detail + mkdir -p "$(dirname "$state")" + printf '%s %s %s\n' "$(date +%s)" "$1" "$2" > "$state.$$" && mv -f "$state.$$" "$state" +} + +case "${1:-}" in + --status) + if [ -f "$state" ]; then + read -r ts result detail < "$state" + when="$(date -d "@$ts" '+%Y-%m-%d %H%M' 2>/dev/null || date -r "$ts" '+%Y-%m-%d %H%M')" + echo "last run: $when — $result${detail:+ ($detail)}" + else + echo "last run: never" + fi + [ -d "$repo" ] && echo "uncommitted memory changes: $(g status --porcelain | wc -l | tr -d ' ')" + exit 0 + ;; + --init) + [ -e "$repo" ] && { echo "already set up: $repo" >&2; exit 1; } + git clone -q --bare "$remote" "$repo" + g config core.bare false + g config core.worktree "$worktree" + g config status.showUntrackedFiles all + g config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' + g fetch -q origin + write_exclude + mkdir -p "$worktree" + # Index = remote HEAD, work tree untouched: memory already on this box wins on + # the first sync, and files only the remote has are restored below. + g reset -q + g ls-files --deleted -z | xargs -0 -r git --git-dir="$repo" checkout -- + echo "set up. Local differences (if any) will be committed on the first sync:" + g status --short | head -20 + exit 0 + ;; + "") ;; + *) echo "usage: claude-memory-sync [--status|--init]" >&2; exit 2 ;; +esac + +[ -d "$repo" ] || { echo "no memory repo at $repo (run --init)" >&2; exit 2; } +write_exclude + +# One run at a time: a slow push must not overlap the next timer tick. +exec 9>"$repo/sync.lock" +flock -n 9 || { echo "another sync is running" >&2; exit 0; } + +g add -A +if ! g diff --cached --quiet; then + n="$(g diff --cached --name-only | wc -l | tr -d ' ')" + g commit -q -m "memory: sync from $(hostname -s) ($n file(s))" +fi + +if ! g fetch -q origin 2>/dev/null; then + record fail "fetch failed — remote unreachable or repo missing" + exit 1 +fi + +if g rev-parse -q --verify origin/main >/dev/null; then + if ! g rebase -q origin/main 2>/dev/null; then + g rebase --abort 2>/dev/null || true + record fail "rebase conflict with origin/main — resolve by hand" + exit 1 + fi +fi + +if ! g push -q origin main 2>/dev/null; then + record fail "push failed" + exit 1 +fi +record ok "$(g rev-parse --short HEAD)" diff --git a/link-dotfiles b/link-dotfiles index 31aed82..4c00945 100755 --- a/link-dotfiles +++ b/link-dotfiles @@ -41,7 +41,29 @@ if [ "${1:-}" = "--check" ]; then echo "✗ links into dotfiles whose target is gone:" printf '%s\n' "$dangling" | sed 's/^/ /' fi - [ "$drift" -eq 0 ] && echo "✓ no drift: every tracked file is linked, and no dotfiles link dangles." + # A link can stay intact while its content moves: an app that holds a stale copy + # in memory (a long-running Claude Code session with settings.json) writes it + # straight back through the link. That shows up only as an uncommitted change. + dirty="$(git -C "$repo" status --porcelain -- home_root home_config 2>/dev/null || true)" + if [ -n "$dirty" ]; then + drift=1 + echo "✗ live config differs from the last commit (not backed up yet):" + printf '%s\n' "$dirty" | sed 's/^/ /' + fi + # Auto-memory backup (claude-memory-sync), checked only on machines that have it. + if [ -d "$HOME/claude-memory.git" ]; then + last="$HOME/.claude/state/claude-memory-sync.last" + if [ ! -f "$last" ]; then + drift=1; echo "✗ claude-memory-sync has never completed a run on this machine" + else + read -r ts result detail < "$last" + age=$(( ($(date +%s) - ts) / 60 )) + if [ "$result" != ok ] || [ "$age" -gt 180 ]; then + drift=1; echo "✗ claude-memory-sync: last run ${age} min ago — $result${detail:+ ($detail)}" + fi + fi + fi + [ "$drift" -eq 0 ] && echo "✓ no drift: every tracked file is linked and committed, no dotfiles link dangles, auto-memory backup is current." exit "$drift" fi