diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish index dd3253d..6cdd3ea 100644 --- a/functions/agents-vault.fish +++ b/functions/agents-vault.fish @@ -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 diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index 5de4b94..1f5a400 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -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"