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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user