From 2ad5bf75d20345d19d9fb49c737b5d6867553301 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Thu, 3 Sep 2026 12:43:13 -0400 Subject: [PATCH] feat(agents-vault): sync the vault from the claude and agy wrappers Both wrappers stay behind the C1 guard, so disabling __fish_config_op_aliases still passes straight through to the real binary. Launch commits but never pushes, keeping the network and any credential prompt off the critical path; pushing is left to the Claude Code SessionEnd hook. agy has no such hook, so its memory lands one launch later. --- functions/agents-vault.fish | 13 +++++--- functions/agy.fish | 14 +++++--- functions/claude.fish | 9 +++++- tests/functional.fish | 28 ++++++++++++++++ tests/run-tests.fish | 14 ++++++++ tests/test-agents-vault.fish | 62 ++++++++++++++++++++++++++++++++++++ 6 files changed, 131 insertions(+), 9 deletions(-) diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish index 066d01d..5b41da0 100644 --- a/functions/agents-vault.fish +++ b/functions/agents-vault.fish @@ -475,8 +475,12 @@ function agents-vault --description 'track curated agent memory in a host-scoped # named: git add refuses a pathspec that matches nothing -- # which one of the two always is, once it has been moved # back -- and then stages none of them, not even the one - # that did match. - git -C "$vault" add -A -- projects 2>/dev/null + # that did match. Its stderr is deliberately not swallowed: + # projects/ provably exists here, so the only thing a + # redirect could hide is a real failure -- a held + # index.lock, say -- after which the index stays + # half-applied while nothing says so. + git -C "$vault" add -A -- projects return 1 end end @@ -498,8 +502,9 @@ function agents-vault --description 'track curated agent memory in a host-scoped # because even the git-native rollback leaves it out of line: # the forward `git mv` overwrote the stashed entry's index # entries, and moving the files back does not bring them back. - # See the same call above for why projects/ is named whole. - git -C "$vault" add -A -- projects 2>/dev/null + # See the same call above for why projects/ is named whole, + # and why its stderr is left visible. + git -C "$vault" add -A -- projects echo "$c_err""agents-vault: could not relink $claude_root/$mangled/memory; $cur was left as it was$c_reset" >&2 return 1 end diff --git a/functions/agy.fish b/functions/agy.fish index cfc751d..2cd47c1 100644 --- a/functions/agy.fish +++ b/functions/agy.fish @@ -8,7 +8,7 @@ # aliases/dev-tools # # DEPENDENCIES -# agents-init +# agents-init, agents-vault # # SYNOPSIS # agy [ARGS...] @@ -18,9 +18,14 @@ # sub-repository is initialized and any agent-made changes are committed # before launch. Delegates all scaffold and commit logic to agents-init # --quiet (full setup), which ensures AGENTS/ is scaffolded and CLAUDE.md -# is symlinked to AGENTS/AGENTS.md in the current project. Arguments are -# forwarded verbatim to the real agy binary, except for -r/--resume which -# are translated to -c/--continue. +# is symlinked to AGENTS/AGENTS.md in the current project. +# +# Also syncs the host-scoped agent memory vault (agents-vault). agy has +# no session-end hook, so its memory is captured on the next launch +# rather than at session end. +# +# Arguments are forwarded verbatim to the real agy binary, except for +# -r/--resume which are translated to -c/--continue. # # Opinionated component (C1): when disabled via __fish_config_op_aliases # (or the __fish_config_opinionated master), the command is passed through @@ -44,6 +49,7 @@ function agy --wraps=agy --description 'agy wrapper: auto-initializes AGENTS/ su end agents-init --quiet + agents-vault --quiet for i in (seq (count $argv)) if test "$argv[$i]" = "-r" diff --git a/functions/claude.fish b/functions/claude.fish index ff3d278..37285b5 100644 --- a/functions/claude.fish +++ b/functions/claude.fish @@ -8,7 +8,7 @@ # aliases/dev-tools # # DEPENDENCIES -# agents-init +# agents-init, agents-vault # # SYNOPSIS # claude [ARGS...] @@ -19,6 +19,12 @@ # Delegates all scaffold and commit logic to agents-init --quiet (full # setup), which ensures AGENTS/ is scaffolded and CLAUDE.md is symlinked # to AGENTS/AGENTS.md in the current project. +# +# Also syncs the host-scoped agent memory vault (agents-vault), which +# tracks curated memory living outside the project tree. The vault +# commits on launch but does not push; pushing happens from the Claude +# Code SessionEnd hook or an explicit agents-vault --push. +# # All arguments are forwarded verbatim to the real claude binary. # # Opinionated component (C1): when disabled via __fish_config_op_aliases @@ -42,6 +48,7 @@ function claude --wraps=claude --description 'claude wrapper: auto-links AGENTS. end agents-init --quiet + agents-vault --quiet command claude $argv end diff --git a/tests/functional.fish b/tests/functional.fish index a6f326f..8dc6649 100644 --- a/tests/functional.fish +++ b/tests/functional.fish @@ -58,6 +58,34 @@ function test_greeting_function_defined functions -q fish_greeting end +function test_agents_vault_defined + for f in agents-vault _agents_vault_dir _agents_repo_slug \ + _agents_repo_ensure_symlink _agents_repo_sync \ + _agents_repo_install_tools + if not functions -q $f + echo " missing function: $f" + return 1 + end + end +end + +function test_wrappers_call_agents_vault + functions -q claude; or return 1 + functions claude | string match -q '*agents-vault*'; or return 1 + functions -q agy; or return 1 + functions agy | string match -q '*agents-vault*' +end + +function test_vault_dir_honors_override + set -l saved + set -q __fish_agent_vault_dir; and set saved $__fish_agent_vault_dir + set -g __fish_agent_vault_dir /tmp/vault-override-check + set -l got (_agents_vault_dir) + set -e __fish_agent_vault_dir + test (count $saved) -gt 0; and set -g __fish_agent_vault_dir $saved + test "$got" = /tmp/vault-override-check +end + function functional_test_main set -l names (functions -a | string match 'test_*' | sort) set -l failed 0 diff --git a/tests/run-tests.fish b/tests/run-tests.fish index a0af7cc..59bc260 100755 --- a/tests/run-tests.fish +++ b/tests/run-tests.fish @@ -11,6 +11,8 @@ # and loads it as an isolated interactive session. # 3. Runs the functional checks in tests/functional.fish inside that # loaded session. +# 4. Runs tests/test-agents-vault.fish as its own process; that suite +# builds its own throwaway repos and needs no loaded config. # # Usage: fish tests/run-tests.fish @@ -86,4 +88,16 @@ if test $functional_status -ne 0 set overall_failed 1 end +# ---- Phase 3: hermetic vault helper tests -------------------------------- +# Run as its own fish process rather than inside the sandboxed session: +# the suite builds its own throwaway git repos and binds the vault, claude +# and agy roots to them, so it needs no loaded config and must never see +# the real ~/.claude. +echo "" +echo "== Vault helper tests ==" +fish $repo_root/tests/test-agents-vault.fish +if test $status -ne 0 + set overall_failed 1 +end + exit $overall_failed diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index ea208be..51b6efe 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -958,6 +958,68 @@ popd >/dev/null check "stash adopt rollback: ordinary run fabricated no entry" "$sf_entries" (command ls -A $vroot9/agent-vault/projects | sort | string join ',') check "stash adopt rollback: ordinary run kept the memory reachable" stashfail-precious (cat $croot9/$sfmang/memory/keep.md 2>/dev/null) +# The other rollback in --adopt: the *forward* move failing outright, +# before the relink is ever reached. A plain file sitting where the target +# entry would go makes `git mv` refuse ("destination already exists") and +# the coreutils fallback refuse too ("cannot overwrite non-directory"), so +# the worktree needs no repair -- but the index does, and that is the half +# of the rollback nothing else pins. With a real git there is no way for a +# test to leave a genuinely half-applied rename here, so the divergence +# stands in for one: the index is desynced by hand first, and the check +# that matters is that the way out re-read projects/ and healed it. +# Without that setup the repair is an invisible no-op, and a refactor can +# drop it with the suite still green. +set -l ff (new_repo https://git.rootiest.dev/rootiest/fwd-fail.git) +set -l ffslug git.rootiest.dev-rootiest-fwd-fail +set -l ffmang (string replace -a '/' '-' -- $ff | string replace -a '.' '-') +mkdir -p $croot9/$ffmang/memory +echo fwdfail-precious >$croot9/$ffmang/memory/keep.md +pushd $ff >/dev/null +agents-vault --silent +popd >/dev/null + +# The blocker must be tracked and committed, so that the only thing dirty +# at adopt time is the divergence staged just below. +echo occupied >$vroot9/agent-vault/projects/fwdfail-target +pushd $ff >/dev/null +agents-vault --silent +popd >/dev/null +check "fwd-fail adopt: the blocking file is tracked" true (git -C $vroot9/agent-vault ls-files --error-unmatch projects/fwdfail-target >/dev/null 2>&1; and echo true; or echo false) +check "fwd-fail adopt: vault clean before the divergence" "" (git -C $vroot9/agent-vault status --porcelain | string join ',') + +git -C $vroot9/agent-vault rm -q --cached projects/$ffslug/origin >/dev/null +set -l ff_dirty (git -C $vroot9/agent-vault status --porcelain | string join ',') +check "fwd-fail adopt: index diverges before the adopt" true (string match -q "*D projects/$ffslug/origin*" -- "$ff_dirty"; and echo true; or echo false) + +set -l ff_entries (command ls -A $vroot9/agent-vault/projects | sort | string join ',') +set -l ff_head (git -C $vroot9/agent-vault rev-list --count HEAD) +set -l ff_link (path resolve $croot9/$ffmang/memory) + +pushd $ff >/dev/null +set -l ffrc (agents-vault --adopt=fwdfail-target --silent 2>/dev/null; echo $status) +popd >/dev/null + +check "fwd-fail adopt: returns 1" 1 "$ffrc" +check "fwd-fail adopt: entries unchanged" "$ff_entries" (command ls -A $vroot9/agent-vault/projects | sort | string join ',') +check "fwd-fail adopt: source entry intact" fwdfail-precious (cat $vroot9/agent-vault/projects/$ffslug/claude/memory/keep.md 2>/dev/null) +check "fwd-fail adopt: blocking file untouched" occupied (cat $vroot9/agent-vault/projects/fwdfail-target 2>/dev/null) +check "fwd-fail adopt: no origin note appended" false (string match -q '*adopted:*' -- (cat $vroot9/agent-vault/projects/$ffslug/origin); and echo true; or echo false) +check "fwd-fail adopt: nothing committed" "$ff_head" (git -C $vroot9/agent-vault rev-list --count HEAD) +check "fwd-fail adopt: live link never removed" "$ff_link" (path resolve $croot9/$ffmang/memory) +check "fwd-fail adopt: memory still reachable live" fwdfail-precious (cat $croot9/$ffmang/memory/keep.md 2>/dev/null) +check "fwd-fail adopt: no stash left behind" false (test -e $vroot9/agent-vault/.git/agents-vault-adopt-stash -o -e $vroot9/agent-vault/.adopt-stash; and echo true; or echo false) +# The pin: projects/ was re-read on the way out, so git describes the +# files as they actually are rather than as the abandoned rename left them. +check "fwd-fail adopt: index re-read to match the worktree" "" (git -C $vroot9/agent-vault status --porcelain | string join ',') +check "fwd-fail adopt: nothing left staged against HEAD" "" (git -C $vroot9/agent-vault diff HEAD --name-only | string join ',') + +# And the ordinary run afterwards still finds the original entry. +pushd $ff >/dev/null +agents-vault --silent +popd >/dev/null +check "fwd-fail adopt: ordinary run fabricated no entry" "$ff_entries" (command ls -A $vroot9/agent-vault/projects | sort | string join ',') +check "fwd-fail adopt: ordinary run kept the memory reachable" fwdfail-precious (cat $croot9/$ffmang/memory/keep.md 2>/dev/null) + set -e __fish_agent_vault_dir set -e __fish_agent_vault_claude_root set -g __fish_agent_vault_claude_home $HERMETIC_HOME/claude