feat(agents-vault): back up agy and global Claude state

agy partitions by conversation UUID rather than by workspace, so it has no
per-project slice and is tracked globally. Its knowledge store is copied
rather than symlinked because it sits beside SQLite databases with WAL
sidecars. Claude's global memory directory is symlinked into the vault the
same way per-project memory is, including the emergent-restore direction.

Paths are allowlisted so credentials, transcripts, and session state cannot
be swept in.

Two variables keep the tests off the real home: the new
__fish_agent_vault_claude_home overrides ~/.claude (whose memory/
subdirectory is the global one), distinct from the existing
__fish_agent_vault_claude_root, which overrides ~/.claude/projects.
Without it a test run on a machine that has a real global memory directory
would move it into a mktemp vault and leave a dangling symlink behind. The
suite now points every run at a throwaway home by default and asserts the
real paths are untouched.

cp cannot report whether anything actually differed, so the copy is only
counted as a change when it leaves the vault's global/agy/ subtree dirty.
Marking it changed unconditionally would print a --quiet summary line on
every agent launch and make the flag meaningless.
This commit is contained in:
2026-09-03 18:56:45 -04:00
parent a018d7997c
commit 1c9cedb8f3
2 changed files with 273 additions and 0 deletions
+109
View File
@@ -36,6 +36,17 @@
# of megabytes per project, growing per session). Paths are allowlisted,
# never denylisted, so nothing new upstream adds can leak in.
#
# Global state that belongs to no project is tracked as well. Claude's
# global memory directory (~/.claude/memory) is symlinked into the vault
# exactly like per-project memory, and is only linked when one side or
# the other already holds something, since that path does not exist by
# default. agy's knowledge store and settings.json are copied rather
# than symlinked: agy partitions by conversation UUID rather than by
# workspace, so it has no per-project slice, and its store sits beside
# SQLite databases whose WAL sidecars must never be live-tracked inside
# a git worktree. A failed copy is reported but is not fatal, because an
# incomplete backup still leaves the agent working.
#
# Because the slug is derived from the remote, gaining, losing, or
# rewriting a project's origin changes it. Each run detects this by
# reading the previous slug straight off the live memory symlink's
@@ -72,6 +83,22 @@
# __fish_agent_vault_autopush to 1 to also push on wrapper launch;
# it defaults to off so a backgrounded push can never hang or prompt
# invisibly underneath a starting agent.
#
# Three further variables exist only so the test suite can run against
# throwaway directories instead of the real home, and are not meant for
# everyday use. __fish_agent_vault_claude_root overrides Claude's
# per-project directory (~/.claude/projects), which is where the
# per-project memory directories live. __fish_agent_vault_claude_home
# overrides Claude's home directory (~/.claude), whose memory
# subdirectory holds the global memory. Those two name different paths
# and setting one has no effect on the other.
# __fish_agent_vault_agy_root overrides agy's state directory
# (~/.gemini/antigravity-cli), which is only ever read from.
#
# The last two are not optional niceties. Without them, a test run on a
# machine that has a real global memory directory would move it into a
# throwaway directory and leave a dangling symlink behind, which is
# strictly worse than having had no backup at all.
function agents-vault --description 'track curated agent memory in a host-scoped vault repo'
set -l c_head (set_color --bold cyan)
set -l c_cmd (set_color --bold)
@@ -196,6 +223,88 @@ function agents-vault --description 'track curated agent memory in a host-scoped
set changed 1
end
# ────────────────────────── global state ───────────────────────────
# Allowlist, never a denylist. The agy root and ~/.claude also hold
# .credentials.json, history.jsonl, sessions/, session-env/,
# shell-snapshots/, and the conversation databases, so only the paths
# named here are ever copied or linked; a "back up all but known junk"
# rule would leak secrets the first time upstream adds a file.
set -l agy_root $__fish_agent_vault_agy_root
test -n "$agy_root"; or set agy_root "$HOME/.gemini/antigravity-cli"
# agy state is copied, never symlinked: agy partitions by conversation
# UUID rather than by workspace, so there is no per-project slice to
# link, and its store sits beside SQLite databases whose WAL sidecars
# must never be live-tracked inside a git worktree.
#
# A failed copy is reported but not fatal. An incomplete backup still
# leaves the agent fully working, unlike a broken memory symlink, and
# this runs on every agent launch.
set -l agy_copied 0
if test -d "$agy_root/knowledge"
if not mkdir -p "$vault/global/agy/knowledge"
echo "$c_err""agents-vault: could not create $vault/global/agy/knowledge$c_reset" >&2
else if not command cp -r "$agy_root/knowledge/." "$vault/global/agy/knowledge/"
echo "$c_warn""agents-vault: could not copy the agy knowledge store$c_reset" >&2
else
set agy_copied 1
end
end
if test -f "$agy_root/settings.json"
if not mkdir -p "$vault/global/agy"
echo "$c_err""agents-vault: could not create $vault/global/agy$c_reset" >&2
else if not command cp "$agy_root/settings.json" "$vault/global/agy/settings.json"
echo "$c_warn""agents-vault: could not copy the agy settings file$c_reset" >&2
else
set agy_copied 1
end
end
# cp cannot report whether anything actually differed, so treating the
# copy itself as a change would set $changed on every single run --
# and agents-vault runs on every claude/agy launch, so --quiet would
# print a summary line every time and stop meaning anything. Ask git
# instead: the copy counts only when it left global/agy/ dirty.
if test $agy_copied -eq 1
set -l agy_dirty (git -C "$vault" status --porcelain -- global/agy 2>/dev/null)
if test -n "$agy_dirty"
set changed 1
test $verbose -eq 1; and echo "$c_ok→ Copied agy global state into the vault$c_reset"
end
end
# Claude's global memory directory is symlinked into the vault exactly
# like per-project memory, so backup and restore stay one operation.
set -l claude_home $__fish_agent_vault_claude_home
test -n "$claude_home"; or set claude_home "$HOME/.claude"
set -l glive "$claude_home/memory"
set -l gvault "$vault/global/claude/memory"
set -l gvault_content
test -d "$gvault"; and set gvault_content (command ls -A "$gvault" 2>/dev/null)
# Link when either side already has something: the live directory
# exists (back it up) or a cloned vault carries global memory (restore
# it). Never out of thin air -- ~/.claude/memory does not exist by
# default, and fabricating it would invent state Claude never asked
# for and permanently claim the path.
if test -d "$glive"; or test -L "$glive"; or test (count $gvault_content) -gt 0
if not mkdir -p "$gvault"
echo "$c_err""agents-vault: could not create $gvault$c_reset" >&2
return 1
end
set -l gmsg (_agents_repo_ensure_symlink "$glive" "$gvault")
set -l grc $status
if test $grc -ne 0
echo "$c_err""agents-vault: could not link $glive$c_reset" >&2
return 1
end
if test -n "$gmsg"
set changed 1
test $verbose -eq 1; and echo "$c_ok$gmsg$c_reset"
end
end
# ─────────────────────── unimplemented modes ───────────────────────
for f in _flag_push _flag_restore _flag_status _flag_adopt _flag_remote
if set -q $f
+164
View File
@@ -47,6 +47,41 @@ function cleanup
end
end
# Describe a path well enough to prove it was not disturbed: a symlink is
# recorded by its literal target (path resolve would hide a link that was
# repointed into a since-deleted temp directory), everything else by kind.
function snapshot_path --argument-names p
if test -L "$p"
printf 'link:%s\n' (readlink "$p")
else if test -d "$p"
printf 'dir\n'
else if test -e "$p"
printf 'file\n'
else
printf 'absent\n'
end
end
# ────────────────────────── hermeticity floor ──────────────────────────
# agents-vault reads and *writes* global agent state under ~/.claude and
# ~/.gemini when it is not told otherwise, so every run in this file is
# pointed at a throwaway home first. Without this, a test run would copy
# the real agy knowledge store into a temp vault and -- far worse -- move a
# real ~/.claude/memory into a temp directory that cleanup then deletes,
# leaving a dangling symlink behind. Individual sections override these
# with their own fixtures and must restore them here, not erase them.
set -g HERMETIC_HOME (mktemp -d)
set -ga TMPDIRS $HERMETIC_HOME
mkdir -p $HERMETIC_HOME/claude $HERMETIC_HOME/agy
set -g __fish_agent_vault_claude_home $HERMETIC_HOME/claude
set -g __fish_agent_vault_agy_root $HERMETIC_HOME/agy
# Recorded before anything runs, asserted at the very end.
set -g REAL_CLAUDE_MEMORY "$HOME/.claude/memory"
set -g REAL_AGY_ROOT "$HOME/.gemini/antigravity-cli"
set -g REAL_CLAUDE_MEMORY_BEFORE (snapshot_path "$REAL_CLAUDE_MEMORY")
set -g REAL_AGY_ROOT_BEFORE (snapshot_path "$REAL_AGY_ROOT")
# ─────────────────────────── slug derivation ───────────────────────────
echo "== _agents_repo_slug =="
@@ -470,6 +505,135 @@ check "fallback old entry removed" false (test -d $vroot2/agent-vault/projects/$
set -e __fish_agent_vault_dir
set -e __fish_agent_vault_claude_root
# ──────────────────────────── global state ─────────────────────────────
# State that belongs to no project: agy's knowledge store and settings.json
# (copied, because agy keys by conversation UUID and its store sits beside
# SQLite databases with WAL sidecars) and Claude's *global* memory
# directory (symlinked, exactly like per-project memory).
echo ""
echo "== agents-vault (global state) =="
set -l vroot5 (mktemp -d); set -ga TMPDIRS $vroot5
set -l croot5 (mktemp -d); set -ga TMPDIRS $croot5
set -l chome5 (mktemp -d); set -ga TMPDIRS $chome5
set -l agy5 (mktemp -d); set -ga TMPDIRS $agy5
set -g __fish_agent_vault_dir $vroot5/agent-vault
set -g __fish_agent_vault_claude_root $croot5
set -g __fish_agent_vault_claude_home $chome5
set -g __fish_agent_vault_agy_root $agy5
mkdir -p $agy5/knowledge $agy5/conversations
echo learned >$agy5/knowledge/fact.md
echo '{"model":"x"}' >$agy5/settings.json
# Decoys that must never be copied: the allowlist names knowledge/ and
# settings.json and nothing else.
echo secret >$agy5/history.jsonl
: >$agy5/conversations/c.db-wal
# A global (non-per-project) Claude memory directory with a sentinel file.
# __fish_agent_vault_claude_home is what keeps this off the real ~/.claude:
# if agents-vault ignored the override, these checks would fail here *and*
# the real global memory would be moved into $chome5.
mkdir -p $chome5/memory
echo global-memory >$chome5/memory/g.md
set -l gp (new_repo https://git.rootiest.dev/rootiest/globals.git)
pushd $gp >/dev/null
agents-vault --silent
popd >/dev/null
check "agy knowledge copied" learned (cat $vroot5/agent-vault/global/agy/knowledge/fact.md)
check "agy settings copied" '{"model":"x"}' (cat $vroot5/agent-vault/global/agy/settings.json)
check "agy knowledge is a copy not a link" false (test -L $vroot5/agent-vault/global/agy/knowledge; and echo true; or echo false)
check "history.jsonl not copied" false (test -e $vroot5/agent-vault/global/agy/history.jsonl; and echo true; or echo false)
check "conversations not copied" false (test -e $vroot5/agent-vault/global/agy/conversations; and echo true; or echo false)
check "global claude memory in the vault" global-memory (cat $vroot5/agent-vault/global/claude/memory/g.md)
check "global claude memory is now a link" true (test -L $chome5/memory; and echo true; or echo false)
check "global link points into the vault" (path resolve $vroot5/agent-vault/global/claude/memory) (path resolve $chome5/memory)
check "global memory readable through the link" global-memory (cat $chome5/memory/g.md)
# --quiet must stay silent when nothing upstream changed. agents-vault runs
# on every claude/agy launch, so a copy step that reported "changed" on
# every run (cp cannot tell whether anything differed) would print a
# summary line at every launch and defeat the flag entirely.
pushd $gp >/dev/null
set -l q1 (agents-vault --quiet)
set -l q2 (agents-vault --quiet)
popd >/dev/null
check "first --quiet rerun prints nothing" "" "$q1"
check "second --quiet rerun prints nothing" "" "$q2"
# ... but a genuine upstream change must still re-sync and still report.
echo "learned more" >$agy5/knowledge/fact.md
pushd $gp >/dev/null
set -l q3 (agents-vault --quiet)
popd >/dev/null
check "agy knowledge re-synced" "learned more" (cat $vroot5/agent-vault/global/agy/knowledge/fact.md)
check "changed agy content reports in --quiet" true (string match -q '*Synced*' -- "$q3"; and echo true; or echo false)
set -e __fish_agent_vault_dir
set -e __fish_agent_vault_claude_root
# ────────────────── emergent restore of global memory ──────────────────
# The global counterpart of the per-project restore case: a cloned vault
# already carries global/claude/memory but the live ~/.claude/memory does
# not exist yet. The link must still be created, or a starting agent writes
# fresh, history-less global memory beside the restored copy.
echo ""
echo "== agents-vault (global emergent restore) =="
set -l vroot6 (mktemp -d); set -ga TMPDIRS $vroot6
set -l croot6 (mktemp -d); set -ga TMPDIRS $croot6
set -l chome6 (mktemp -d); set -ga TMPDIRS $chome6
set -l agy6 (mktemp -d); set -ga TMPDIRS $agy6
set -g __fish_agent_vault_dir $vroot6/agent-vault
set -g __fish_agent_vault_claude_root $croot6
set -g __fish_agent_vault_claude_home $chome6
set -g __fish_agent_vault_agy_root $agy6
mkdir -p $vroot6/agent-vault/global/claude/memory
echo restored-global >$vroot6/agent-vault/global/claude/memory/old.md
set -l gp6 (new_repo https://git.rootiest.dev/rootiest/globals-restore.git)
pushd $gp6 >/dev/null
agents-vault --silent
popd >/dev/null
check "global restore: link created with no prior live dir" true (test -L $chome6/memory; and echo true; or echo false)
check "global restore: vault content readable through the link" restored-global (cat $chome6/memory/old.md 2>/dev/null)
# A home with neither side populated must not have a memory/ invented for
# it: ~/.claude/memory does not exist by default.
set -l chome7 (mktemp -d); set -ga TMPDIRS $chome7
set -l vroot7 (mktemp -d); set -ga TMPDIRS $vroot7
set -g __fish_agent_vault_dir $vroot7/agent-vault
set -g __fish_agent_vault_claude_home $chome7
set -l gp7 (new_repo https://git.rootiest.dev/rootiest/globals-absent.git)
pushd $gp7 >/dev/null
agents-vault --silent
popd >/dev/null
check "absent global memory is not fabricated" false (test -e $chome7/memory; and echo true; or echo false)
set -e __fish_agent_vault_dir
set -e __fish_agent_vault_claude_root
set -g __fish_agent_vault_claude_home $HERMETIC_HOME/claude
set -g __fish_agent_vault_agy_root $HERMETIC_HOME/agy
# ──────────────────────── hermeticity assertion ────────────────────────
# The whole suite must never have touched the real global agent state. The
# failure this guards is specific: a global-memory sync with no test
# override would move ~/.claude/memory into a mktemp vault that cleanup
# then deletes, leaving the live path a dangling symlink.
echo ""
echo "== hermeticity =="
check "real ~/.claude/memory untouched" "$REAL_CLAUDE_MEMORY_BEFORE" (snapshot_path "$REAL_CLAUDE_MEMORY")
check "real agy root untouched" "$REAL_AGY_ROOT_BEFORE" (snapshot_path "$REAL_AGY_ROOT")
set -e __fish_agent_vault_claude_home
set -e __fish_agent_vault_agy_root
cleanup
echo ""
echo (math $TESTS_RUN - $TESTS_FAILED)"/$TESTS_RUN passed"