diff --git a/completions/agents-vault.fish b/completions/agents-vault.fish
new file mode 100644
index 0000000..e0b6cbc
--- /dev/null
+++ b/completions/agents-vault.fish
@@ -0,0 +1,21 @@
+# Copyright (C) 2026 Rootiest
+# SPDX-License-Identifier: AGPL-3.0-or-later
+#
+# Completions for agents-vault.
+
+complete -c agents-vault -f
+complete -c agents-vault -s h -l help -d 'Show help message'
+complete -c agents-vault -l link -d "Ensure this project's memory link only"
+complete -c agents-vault -l push -d 'Commit and push to the vault remote'
+complete -c agents-vault -l restore -d 'Relink everything possible, report the rest'
+complete -c agents-vault -l status -d 'Show entries, link health, remote, orphans'
+# --adopt takes an existing vault slug, so offer the entries that are
+# actually there; the vault may not exist yet, in which case this is empty.
+# -A because a dot-led slug is legitimate (a relative-path remote keys as
+# ..-mirror), and an entry that cannot be completed reads as one that is
+# not there.
+complete -c agents-vault -l adopt -r -a '(command ls -1A (_agents_vault_dir)/projects 2>/dev/null)' -d 'Bind this project to an existing vault entry'
+complete -c agents-vault -l remote -r -d 'Set the vault remote URL'
+complete -c agents-vault -s v -l verbose -d 'Print all per-step output (default)'
+complete -c agents-vault -s q -l quiet -d 'Print one summary line only if changed'
+complete -c agents-vault -s s -l silent -d 'Suppress all output; errors only'
diff --git a/docs/fish-config.index b/docs/fish-config.index
index 5f9adb8..678a663 100644
--- a/docs/fish-config.index
+++ b/docs/fish-config.index
@@ -276,6 +276,9 @@ minimal=## Opinionated Components (Minimal Mode)
minimal-mode=## Opinionated Components (Minimal Mode)
opt-out=## Opinionated Components (Minimal Mode)
toggles=## Opinionated Components (Minimal Mode)
+agent-vault=## Agent Memory Vault
+__fish_agent_vault_dir=## Agent Memory Vault
+__fish_agent_vault_autopush=## Agent Memory Vault
component-reference=# 8. COMPONENTS REFERENCE
components=# 8. COMPONENTS REFERENCE
c0=## Per-function overrides: `C0`/`always`
diff --git a/docs/manual/07-customization.md b/docs/manual/07-customization.md
index abfd609..02a9559 100644
--- a/docs/manual/07-customization.md
+++ b/docs/manual/07-customization.md
@@ -159,6 +159,27 @@ interactively. See [Components Reference](/08-components-reference/) for the
full sub-category breakdown of every category.
+## Agent Memory Vault
+
+ __fish_agent_vault_dir
+
+ Overrides the agent memory vault location. Defaults to
+ $XDG_DATA_HOME/agent-vault (or ~/.local/share/agent-vault).
+
+ __fish_agent_vault_autopush
+
+ When set to 1, agents-vault also pushes on wrapper launch. Defaults to
+ off: the vault commits locally on every launch and pushes from the
+ Claude Code SessionEnd hook or an explicit agents-vault --push. That
+ push is synchronous, so with autopush on the pull and the push are
+ each capped at 20 seconds; an explicit --push is left uncapped.
+
+NOTE:
+With autopush off and no SessionEnd hook installed, backups accumulate
+locally and never reach the remote. Run agents-vault --status to check
+how far ahead the vault is.
+
+
## Prompt and Theme
### Starship
diff --git a/functions/_agents_repo_ensure_symlink.fish b/functions/_agents_repo_ensure_symlink.fish
new file mode 100644
index 0000000..82d6ac3
--- /dev/null
+++ b/functions/_agents_repo_ensure_symlink.fish
@@ -0,0 +1,71 @@
+# Copyright (C) 2026 Rootiest
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+# SYNOPSIS
+# _agents_repo_ensure_symlink
+#
+# DESCRIPTION
+# Idempotently makes a symlink pointing at the directory .
+#
+# Only directories are ever linked. The agent file-editing tools resolve a
+# symlinked directory transparently but refuse to write through a
+# symlinked file, so linking a file would silently break every later edit;
+# a non-directory target is refused outright.
+#
+# A missing target is refused rather than linked, because a dangling
+# memory/ symlink makes agent memory writes fail -- strictly worse than
+# having no backup at all.
+#
+# When is an existing real directory, its contents are copied into
+# without clobbering (cp -n) before the directory is replaced by
+# the link, so adopting a populated live directory never overwrites the
+# copy already in the vault.
+#
+# ARGUMENTS
+# link Path that should become the symlink
+# target Existing directory the link should point at
+#
+# EXIT STATUS
+# 0 Link is correct (created, repinned, or already right)
+# 1 Refused (non-directory target, missing target, non-directory link) or
+# a copy, remove, or link operation failed
+#
+# RETURNS
+# A single "→ ..." progress line on stdout when something changed;
+# nothing at all when the link was already correct.
+#
+# EXAMPLE
+# _agents_repo_ensure_symlink ~/.claude/projects/-home-u-proj/memory \
+# ~/.local/share/agent-vault/projects/host-user-proj/claude/memory
+function _agents_repo_ensure_symlink --argument-names link target
+ test -n "$link" -a -n "$target"; or return 1
+
+ if test -e "$target"; and not test -d "$target"
+ echo "_agents_repo_ensure_symlink: refusing non-directory target: $target" >&2
+ return 1
+ end
+ if not test -d "$target"
+ echo "_agents_repo_ensure_symlink: target does not exist: $target" >&2
+ return 1
+ end
+
+ if test -L "$link"
+ set -l cur (path resolve "$link")
+ set -l want (path resolve "$target")
+ test "$cur" = "$want"; and return 0
+ rm -f "$link"; or return 1
+ else if test -d "$link"
+ set -l contents (command ls -A "$link" 2>/dev/null)
+ if test (count $contents) -gt 0
+ command cp -rn "$link/." "$target/"; or return 1
+ end
+ rm -rf "$link"; or return 1
+ else if test -e "$link"
+ echo "_agents_repo_ensure_symlink: refusing to replace non-directory: $link" >&2
+ return 1
+ end
+
+ mkdir -p (path dirname "$link"); or return 1
+ ln -s "$target" "$link"; or return 1
+ echo "→ Linked "(path basename "$link")" → $target"
+end
diff --git a/functions/_agents_init_install_tools.fish b/functions/_agents_repo_install_tools.fish
similarity index 64%
rename from functions/_agents_init_install_tools.fish
rename to functions/_agents_repo_install_tools.fish
index 5491971..95aeb83 100644
--- a/functions/_agents_init_install_tools.fish
+++ b/functions/_agents_repo_install_tools.fish
@@ -2,31 +2,34 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# SYNOPSIS
-# _agents_init_install_tools
+# _agents_repo_install_tools
#
# DESCRIPTION
# Copies the canonical version-bump script and git hook shims from
-# fish-config's scripts/agents-tools/ into /.agents-tools/,
+# fish-config's scripts/agents-tools/ into /.agents-tools/,
# refreshing them when the shipped agents-tools-version: marker is newer
# than the installed copy. Files are made executable. Idempotent: prints
# nothing when the installed tooling is already current, or a short summary
-# line when it installed or updated the tooling.
+# line when it installed or updated the tooling, naming 's own
+# basename rather than a hardcoded caller (e.g. "AGENTS/.agents-tools/" for
+# agents-init, "agent-vault/.agents-tools/" for agents-vault). Shared by
+# agents-init and agents-vault.
#
# ARGUMENTS
-# agents_dir Absolute path to the AGENTS/ sub-repo root
+# repo_dir Absolute path to the git repo root to install tooling into
#
# EXIT STATUS
# 0 Tooling is current or was installed/updated successfully
# 1 Canonical source missing or a copy failed
#
# EXAMPLE
-# set -l msg (_agents_init_install_tools /path/to/AGENTS)
+# set -l msg (_agents_repo_install_tools /path/to/AGENTS)
# test -n "$msg"; and echo $msg
-function _agents_init_install_tools --argument-names agents_dir
- test -n "$agents_dir"; or return 1
+function _agents_repo_install_tools --argument-names repo_dir
+ test -n "$repo_dir"; or return 1
set -l src (path resolve (status dirname)/../scripts/agents-tools)
test -f "$src/version-bump"; or return 1
- set -l dest "$agents_dir/.agents-tools"
+ set -l dest "$repo_dir/.agents-tools"
set -l want (command grep -m1 -oE 'agents-tools-version: *[0-9]+' "$src/version-bump" 2>/dev/null | command grep -oE '[0-9]+$')
set -l have ""
@@ -40,9 +43,10 @@ function _agents_init_install_tools --argument-names agents_dir
command cp "$src/hooks/prepare-commit-msg" "$dest/hooks/prepare-commit-msg"; or return 1
chmod +x "$dest/version-bump" "$dest/hooks/pre-commit" "$dest/hooks/prepare-commit-msg"; or return 1
+ set -l label (path basename -- "$repo_dir")
if test -z "$have"
- echo "→ Installed AGENTS/.agents-tools/ (version-bump v$want)"
+ echo "→ Installed $label/.agents-tools/ (version-bump v$want)"
else
- echo "→ Updated AGENTS/.agents-tools/ (v$have → v$want)"
+ echo "→ Updated $label/.agents-tools/ (v$have → v$want)"
end
end
diff --git a/functions/_agents_repo_local_slug.fish b/functions/_agents_repo_local_slug.fish
new file mode 100644
index 0000000..71f5a21
--- /dev/null
+++ b/functions/_agents_repo_local_slug.fish
@@ -0,0 +1,40 @@
+# Copyright (C) 2026 Rootiest
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+# SYNOPSIS
+# _agents_repo_local_slug
+#
+# DESCRIPTION
+# Builds the path-derived fallback slug used when a project has no git
+# remote: local--<8 hex of sha256(realpath)>. The
+# basename is lowercased and every character outside [a-z0-9._-] is
+# mapped to a dash, matching the sanitization the remote-URL branch of
+# _agents_repo_slug applies to hostnames and paths.
+#
+# This is the single source of truth for that formula. It exists so the
+# rule is written once: _agents_repo_slug's no-remote branch calls it to
+# produce the slug, and agents-vault's slug-migration fallback (used when
+# there is no live symlink yet to read the previous slug from) calls it
+# to recompute the same candidate. Duplicating the formula in both places
+# let them drift once before; this closes that gap for good.
+#
+# ARGUMENTS
+# dir Absolute or relative path to the project directory
+#
+# EXIT STATUS
+# 0 Slug printed
+# 1 No directory argument given
+#
+# RETURNS
+# The local-* slug, one line on stdout.
+#
+# EXAMPLE
+# set -l slug (_agents_repo_local_slug /home/user/myproject)
+function _agents_repo_local_slug --argument-names dir
+ test -n "$dir"; or return 1
+
+ set -l rp (path resolve "$dir")
+ set -l base (string lower -- (path basename "$rp") | string replace -ra '[^a-z0-9._-]' '-')
+ set -l digest (printf '%s' "$rp" | sha256sum | string split -f1 ' ')
+ printf 'local-%s-%s\n' "$base" (string sub -l 8 -- "$digest")
+end
diff --git a/functions/_agents_repo_slug.fish b/functions/_agents_repo_slug.fish
new file mode 100644
index 0000000..d5672f8
--- /dev/null
+++ b/functions/_agents_repo_slug.fish
@@ -0,0 +1,70 @@
+# Copyright (C) 2026 Rootiest
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+# DEPENDENCIES
+# _agents_repo_local_slug
+#
+# SYNOPSIS
+# _agents_repo_slug
+#
+# DESCRIPTION
+# Derives the vault slug for a project directory. Prefers the normalized
+# git remote URL so the same project keys identically from any clone on
+# any machine; falls back to a path-derived key when no remote exists.
+#
+# Normalization strips the scheme, userinfo, and a numeric port, rewrites
+# scp-form host:path to host/path, drops a trailing .git, lowercases, and
+# maps every character outside [a-z0-9._-] to a dash. These all yield
+# git.rootiest.dev-rootiest-fish-config:
+#
+# https://git.rootiest.dev/rootiest/fish-config.git
+# git@git.rootiest.dev:rootiest/fish-config.git
+# ssh://git@git.rootiest.dev:22/rootiest/fish-config.git
+#
+# With no remote the slug is local--<8 hex of sha256(realpath)>,
+# where the basename is lowercased and mapped the same way as the remote form.
+# That key is machine-dependent by construction and is best-effort only;
+# agents-vault --adopt rebinds such an entry by hand.
+#
+# ARGUMENTS
+# dir Absolute path to the project directory
+#
+# EXIT STATUS
+# 0 Slug printed
+# 1 No directory argument given
+#
+# RETURNS
+# The slug, one line on stdout.
+#
+# EXAMPLE
+# set -l slug (_agents_repo_slug /home/user/myproject)
+function _agents_repo_slug --argument-names dir
+ test -n "$dir"; or return 1
+
+ set -l url (git -C "$dir" remote get-url origin 2>/dev/null)
+ if test -z "$url"
+ set -l remotes (git -C "$dir" remote 2>/dev/null)
+ if test (count $remotes) -gt 0
+ set url (git -C "$dir" remote get-url $remotes[1] 2>/dev/null)
+ end
+ end
+
+ if test -n "$url"
+ set -l s $url
+ # Order matters: the port must go before the scp-form rewrite, or
+ # ssh://host:22/a/b becomes host/22/a/b and diverges from the
+ # https slug for the same repository.
+ set s (string replace -r '^[A-Za-z][A-Za-z0-9+.-]*://' '' -- $s)
+ set s (string replace -r '^[^@/]+@' '' -- $s)
+ set s (string replace -r '^([^/:]+):[0-9]+/' '$1/' -- $s)
+ set s (string replace -r '^([^/:]+):' '$1/' -- $s)
+ set s (string replace -r '\.git$' '' -- $s)
+ set s (string replace -r '/+$' '' -- $s)
+ set s (string lower -- $s)
+ set s (string replace -ra '[^a-z0-9._-]' '-' -- $s)
+ printf '%s\n' $s
+ return 0
+ end
+
+ _agents_repo_local_slug "$dir"
+end
diff --git a/functions/_agents_repo_sync.fish b/functions/_agents_repo_sync.fish
new file mode 100644
index 0000000..eb2772b
--- /dev/null
+++ b/functions/_agents_repo_sync.fish
@@ -0,0 +1,73 @@
+# Copyright (C) 2026 Rootiest
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+# SYNOPSIS
+# _agents_repo_sync
+#
+# DESCRIPTION
+# Stages everything in and commits it with . Shared by
+# agents-init and agents-vault.
+#
+# It never touches the network, and that is the point rather than an
+# omission. Both callers run on every agent launch, synchronously, ahead
+# of the agent itself, and a fetch there blocks the launch for as long as
+# an unreachable remote takes to time out and can prompt for credentials
+# invisibly underneath a starting agent. Committing needs no remote at
+# all -- only pushing does -- so the pull lives on agents-vault's push
+# path, which is already opt-in for exactly this reason. An offline
+# laptop therefore still gets a complete local backup, which is the whole
+# point of keeping one.
+#
+# A rebase already in progress is refused rather than committed: the
+# worktree then holds conflict markers, and recording those under a
+# routine-looking message buries the conflict in the history instead of
+# reporting it. The rebase is left exactly as it stands -- this function
+# did not start it, so it is not this function's to abort -- and the
+# caller says so.
+#
+# Commits are made with commit.gpgsign=false so a pinentry prompt can
+# never block a shell or an agent launch. If a pre-commit or commit-msg
+# hook rejects the commit (e.g. a secret scanner), that failure is
+# surfaced too: nothing is committed and a diagnostic goes to stderr.
+#
+# ARGUMENTS
+# dir Absolute path to the git repository
+# message Commit subject used when there is something to commit
+#
+# EXIT STATUS
+# 0 Committed, or nothing needed committing
+# 1 is not a git repository, arguments were missing, or the commit
+# itself failed (e.g. a pre-commit/commit-msg hook rejected it)
+# 2 A rebase is in progress; nothing committed, nothing touched
+#
+# RETURNS
+# A single "→ Committed () " line on stdout when it
+# commits; nothing when there was nothing to do.
+#
+# EXAMPLE
+# _agents_repo_sync /path/to/AGENTS "chore: sync AGENTS repository"
+function _agents_repo_sync --argument-names dir msg
+ test -n "$dir" -a -n "$msg"; or return 1
+ test -d "$dir/.git"; or return 1
+
+ # The guard above proved .git is a directory, so these are the same two
+ # paths `agents-vault --status` reports an unresolved rebase from.
+ if test -d "$dir/.git/rebase-merge"; or test -d "$dir/.git/rebase-apply"
+ echo "_agents_repo_sync: unresolved rebase in $dir; nothing committed" >&2
+ return 2
+ end
+
+ git -C "$dir" add -A 2>/dev/null
+ set -l status_out (git -C "$dir" status --porcelain 2>/dev/null)
+ test -n "$status_out"; or return 0
+
+ if git -C "$dir" -c commit.gpgsign=false commit -q -m "$msg" 2>/dev/null
+ set -l sha (git -C "$dir" rev-parse --short HEAD 2>/dev/null)
+ set -l subject (git -C "$dir" log -1 --pretty=%s 2>/dev/null)
+ echo "→ Committed ($sha) $subject"
+ return 0
+ else
+ echo "_agents_repo_sync: commit failed in $dir (hook rejected it?); nothing committed" >&2
+ return 1
+ end
+end
diff --git a/functions/_agents_vault_dir.fish b/functions/_agents_vault_dir.fish
new file mode 100644
index 0000000..e2de353
--- /dev/null
+++ b/functions/_agents_vault_dir.fish
@@ -0,0 +1,32 @@
+# Copyright (C) 2026 Rootiest
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+# SYNOPSIS
+# _agents_vault_dir
+#
+# DESCRIPTION
+# Prints the agent memory vault root. Honors the universal variable
+# __fish_agent_vault_dir when set, otherwise
+# ${XDG_DATA_HOME:-$HOME/.local/share}/agent-vault.
+#
+# The vault holds agy state as well as Claude state, so it is not nested
+# under either tool's directory; it is backed-up state rather than
+# configuration, hence XDG_DATA_HOME rather than XDG_CONFIG_HOME.
+#
+# EXIT STATUS
+# 0 Always
+#
+# RETURNS
+# The vault root path, one line on stdout.
+#
+# EXAMPLE
+# set -l vault (_agents_vault_dir)
+function _agents_vault_dir
+ if set -q __fish_agent_vault_dir; and test -n "$__fish_agent_vault_dir"
+ printf '%s\n' "$__fish_agent_vault_dir"
+ return 0
+ end
+ set -l base $XDG_DATA_HOME
+ test -n "$base"; or set base "$HOME/.local/share"
+ printf '%s\n' "$base/agent-vault"
+end
diff --git a/functions/agents-init.fish b/functions/agents-init.fish
index 5814379..8c5ef93 100644
--- a/functions/agents-init.fish
+++ b/functions/agents-init.fish
@@ -4,6 +4,9 @@
# CATEGORY
# 12-ai-and-developer-tools
#
+# DEPENDENCIES
+# _agents_repo_install_tools, _agents_repo_sync, _agents_init_ensure_gitignore
+#
# SYNOPSIS
# agents-init [-a | --agents] [-p | --plugins] [-v | --verbose]
# [-q | --quiet] [-s | --silent] [-h | --help]
@@ -50,11 +53,16 @@
#
# With no flags, runs both --agents and --plugins setup; --agents re-runs
# only the AGENTS.md / symlink step and --plugins only the plans/specs/
-# devlogs wiring step. Managed paths are added to .gitignore. The sub-repo
-# is pulled first when it has an upstream, and at the end of every
-# invocation any uncommitted changes inside it are auto-committed so
-# agent-made edits are captured automatically. Fully idempotent: a second
-# run produces no output and no new commits.
+# devlogs wiring step. Managed paths are added to .gitignore. At the end
+# of every invocation any uncommitted changes inside the sub-repo are
+# auto-committed so agent-made edits are captured automatically. Fully
+# idempotent: a second run produces no output and no new commits.
+#
+# The commit is local only. Nothing here fetches or pushes: the wrappers
+# call this synchronously before starting an agent, and a network round
+# trip there blocks the launch until an unreachable remote times out and
+# can prompt for credentials with nobody watching. A sub-repo that has an
+# upstream is pulled by hand, on the user's own schedule.
#
# Called automatically by the claude and agy wrappers on every invocation.
#
@@ -68,7 +76,8 @@
#
# EXIT STATUS
# 0 Setup completed successfully
-# 1 Fatal error (git init failed, move failed, etc.)
+# 1 Fatal error (git init failed, move failed, the AGENTS/ commit was
+# rejected, or an unresolved rebase blocked it)
#
# EXAMPLE
# agents-init
@@ -165,7 +174,7 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi
test $verbose -eq 1; and echo "$c_ok→ Created AGENTS/.version (1.0.0)$c_reset"
end
- set -l _tools (_agents_init_install_tools "$agents_dir")
+ set -l _tools (_agents_repo_install_tools "$agents_dir")
if test -n "$_tools"
set changed 1
test $verbose -eq 1; and echo "$c_ok$_tools$c_reset"
@@ -259,38 +268,26 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi
test $verbose -eq 1; and echo "$c_ok→ Linked AGENTS/CLAUDE.md → AGENTS/AGENTS.md$c_reset"
end
- # ── Root symlink: AGENTS.md → AGENTS/AGENTS.md ───────────────────────
- set -l _need_link 0
- if not test -L "$root/AGENTS.md"
- set _need_link 1
- else if test (readlink "$root/AGENTS.md") != AGENTS/AGENTS.md
- rm -f "$root/AGENTS.md"
- set _need_link 1
- end
- if test $_need_link -eq 1
- if not ln -s AGENTS/AGENTS.md "$root/AGENTS.md"
- echo "$c_err""Error: could not create AGENTS.md symlink$c_reset" >&2
- return 1
+ # Root symlinks point at files, not directories, so they cannot use
+ # _agents_repo_ensure_symlink (which is directory-only by design).
+ for pair in "AGENTS.md:AGENTS/AGENTS.md" "CLAUDE.md:AGENTS/CLAUDE.md"
+ set -l name (string split -f1 ':' -- $pair)
+ set -l want (string split -f2 ':' -- $pair)
+ set -l need 0
+ if not test -L "$root/$name"
+ set need 1
+ else if test (readlink "$root/$name") != "$want"
+ rm -f "$root/$name"
+ set need 1
end
- set changed 1
- test $verbose -eq 1; and echo "$c_ok→ Linked AGENTS.md → AGENTS/AGENTS.md$c_reset"
- end
-
- # ── Root symlink: CLAUDE.md → AGENTS/CLAUDE.md ───────────────────────
- set -l _need_link 0
- if not test -L "$root/CLAUDE.md"
- set _need_link 1
- else if test (readlink "$root/CLAUDE.md") != AGENTS/CLAUDE.md
- rm -f "$root/CLAUDE.md"
- set _need_link 1
- end
- if test $_need_link -eq 1
- if not ln -s AGENTS/CLAUDE.md "$root/CLAUDE.md"
- echo "$c_err""Error: could not create CLAUDE.md symlink$c_reset" >&2
- return 1
+ if test $need -eq 1
+ if not ln -s "$want" "$root/$name"
+ echo "$c_err""Error: could not create $name symlink$c_reset" >&2
+ return 1
+ end
+ set changed 1
+ test $verbose -eq 1; and echo "$c_ok→ Linked $name → $want$c_reset"
end
- set changed 1
- test $verbose -eq 1; and echo "$c_ok→ Linked CLAUDE.md → AGENTS/CLAUDE.md$c_reset"
end
# ── .gitignore ────────────────────────────────────────────────────────
@@ -461,24 +458,30 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi
end
# ──────────────────────── Auto-commit AGENTS/ ────────────────────────────
- # Pull first when an upstream is configured so the local .version reflects
- # any remote bumps before we add to it (no-op for local-only repos).
- if git -C "$agents_dir" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1
- git -C "$agents_dir" pull --rebase --autostash -q 2>/dev/null
- end
- git -C "$agents_dir" add -A 2>/dev/null
- set -l status_out (git -C "$agents_dir" status --porcelain 2>/dev/null)
- if test -n "$status_out"
- set -l msg "chore: sync AGENTS repository"
- test $did_init -eq 1; and set msg "chore: initialize AGENTS repository"
- if git -C "$agents_dir" -c commit.gpgsign=false commit -q -m "$msg" 2>/dev/null
- set changed 1
- if test $verbose -eq 1
- set -l sha (git -C "$agents_dir" rev-parse --short HEAD 2>/dev/null)
- set -l realmsg (git -C "$agents_dir" log -1 --pretty=%s 2>/dev/null)
- echo "$c_ok→ Committed AGENTS/ ($sha) $c_dim$realmsg$c_reset"
- end
- end
+ # Purely local: no fetch, no push. This function runs synchronously on
+ # every agent launch, and a network round trip there blocks the launch
+ # for as long as an unreachable remote takes to time out. Committing
+ # never needed one -- see _agents_repo_sync.
+ #
+ # Every way the commit can fail is an arm of its own. A sync that did
+ # not commit means agent-made edits were not captured, so it is a
+ # failure rather than a line to walk past -- and the missing `-ne 0`
+ # arm was not a cosmetic gap: fish resolves a branchless `if` to 0, so
+ # a hook-rejected commit fell straight through to a reported success.
+ set -l msg "chore: sync AGENTS repository"
+ test $did_init -eq 1; and set msg "chore: initialize AGENTS repository"
+ set -l sync_out (_agents_repo_sync "$agents_dir" "$msg")
+ set -l sync_rc $status
+ set -l failed 0
+ if test $sync_rc -eq 2
+ echo "$c_warn→ AGENTS/ has an unresolved rebase; nothing committed$c_reset" >&2
+ set failed 1
+ else if test $sync_rc -ne 0
+ echo "$c_err""Error: the AGENTS/ commit failed; nothing recorded$c_reset" >&2
+ set failed 1
+ else if test -n "$sync_out"
+ set changed 1
+ test $verbose -eq 1; and echo "$c_ok$sync_out$c_reset"
end
# Quiet summary: one line at the end, only if something actually changed
@@ -489,4 +492,8 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi
echo "$c_ok→ Synced AGENTS scaffolding$c_reset"
end
end
+
+ # Explicit, because the branchless `if` above resolves to 0 and would
+ # otherwise be this function's exit status.
+ test $failed -eq 0
end
diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish
new file mode 100644
index 0000000..02330f1
--- /dev/null
+++ b/functions/agents-vault.fish
@@ -0,0 +1,1143 @@
+# Copyright (C) 2026 Rootiest
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+# CATEGORY
+# 12-ai-and-developer-tools
+#
+# DEPENDENCIES
+# _agents_vault_dir, _agents_repo_slug, _agents_repo_local_slug,
+# _agents_repo_ensure_symlink, _agents_repo_sync,
+# _agents_repo_install_tools, git, hostname
+#
+# SYNOPSIS
+# agents-vault [--link] [--push] [--restore] [--status]
+# [--adopt=SLUG] [--remote=URL]
+# [-v | --verbose] [-q | --quiet] [-s | --silent]
+# [-h | --help]
+#
+# DESCRIPTION
+# Tracks curated agent memory in a host-scoped git repository so it
+# survives losing a machine. Complements agents-init, which scaffolds the
+# per-project AGENTS/ repo: that holds the shareable agent specification,
+# while this holds the personal memory an agent accumulates.
+#
+# Memory does not live in any project tree. Claude keeps it under
+# ~/.claude/projects//memory/ and agy keeps its knowledge
+# store under ~/.gemini/antigravity-cli/, both outside every repository.
+#
+# Entries are keyed by normalized git remote URL rather than by path, so
+# the key survives a machine change or a directory rename. The live
+# memory directory becomes a symlink into the vault, which makes backup
+# and restore the same operation: on a new machine, clone the vault once
+# and the first agents-vault run in any project relinks its memory
+# automatically. No manifest and no batch restore step are involved.
+#
+# Only curated memory is tracked. Session transcripts are excluded (tens
+# of megabytes per project, growing per session). Paths are allowlisted,
+# never denylisted, so nothing new upstream adds can leak in. The
+# allowlist runs all the way down, not just at the top: inside agy's
+# knowledge store only *.md and *.json files are copied, so a credential
+# file or a conversation database appearing there is left behind by the
+# same rule rather than by being known about in advance. Symlinks found
+# inside the store are neither followed nor copied, so the allowlist
+# bounds whose files it collects and not merely what kind.
+#
+# 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
+# target (no guessing) and migrates that entry to the new slug before
+# relinking, so memory accumulated under the old key is never orphaned.
+# If both the old and new entries already hold content the migration is
+# ambiguous and is refused; resolve it with --adopt=SLUG. An entry that
+# is already at the new key but holds no memory -- the shape a fresh
+# clone always produces, since git cannot track an empty directory -- is
+# moved aside, not deleted, and its origin log is folded into the
+# migrated entry, so a clone's provenance survives the rename. The
+# rename is atomic: a failure at any point leaves the vault exactly as
+# it was and reports it.
+#
+# Run with no flags, the command scaffolds the vault, syncs global state,
+# links the current project, and commits. The other modes are exclusive
+# and each returns as soon as it is done:
+#
+# --status is a report and mutates nothing at all. It is answered before
+# the vault is even scaffolded, so asking what the vault looks like never
+# creates it, never copies agy state into it, and never claims
+# ~/.claude/memory. A missing vault is reported rather than built.
+#
+# --restore walks every vault entry and relinks the live memory directory
+# of each one whose recorded origin path still exists, naming the rest so
+# they can be rebound by hand. It is a convenience: the ordinary per-
+# project run restores a cloned vault's memory on its own.
+#
+# --adopt=SLUG rebinds the current project's entry to SLUG, which is how
+# a machine-specific local-* key or an ambiguous migration is resolved.
+# SLUG must match [a-z0-9._-]+ and be neither "." nor ".." -- the charset
+# the slug formula itself emits -- since it is interpolated into a vault
+# path and handed to git mv. The rename and the relink are atomic: if the
+# live memory directory cannot be repinned onto the new entry the rename
+# is rolled back, so an ordinary run still finds the original entry.
+#
+# --remote=URL points the vault at a remote; --push commits, pulls, and
+# then pushes there. The pull happens only on this path. Committing needs
+# no remote at all, and both wrappers run this command synchronously
+# before starting an agent, so a fetch on the ordinary run would block
+# every launch for as long as an unreachable remote takes to time out --
+# and would take the local commit down with it, leaving an offline
+# machine with no backup at all.
+#
+# ARGUMENTS
+# --link Scaffold the vault and link this project's memory; skip
+# the final commit
+# --push Commit, pull, then push to the vault remote
+# --restore Walk the vault, relink what is possible, report the rest
+# --status Show entries, link health, remote state, and orphans
+# --adopt=SLUG Bind the current project to an existing vault entry
+# --remote=URL Set the vault remote
+# -v, --verbose Print all per-step output (default)
+# -q, --quiet Print one summary line only if changes were made
+# -s, --silent Suppress all output; errors only
+# -h, --help Show this help message and exit
+#
+# EXIT STATUS
+# 0 Completed successfully
+# 1 Fatal error (vault unavailable, git failure, ambiguous migration,
+# invalid --adopt slug, nothing committed, or a push that did not
+# reach the remote)
+#
+# RETURNS
+# --status prints its report on stdout: the vault path, the remote and
+# how far ahead of it the vault is, a warning for an unresolved rebase,
+# then one line per entry reading "linked" or "orphan", the slug, and the
+# file count. Every other mode prints only verbosity-gated progress
+# lines, and nothing at all when there was nothing to do.
+#
+# EXAMPLE
+# agents-vault
+# agents-vault --status
+# agents-vault --remote=https://git.rootiest.dev/rootiest/agent-vault.git
+# agents-vault --push
+# agents-vault --adopt=git.rootiest.dev-rootiest-fish-config
+# agents-vault --restore
+#
+# NOTES
+# Set __fish_agent_vault_dir to relocate the vault. Set
+# __fish_agent_vault_autopush to 1 to also push on wrapper launch; it
+# defaults to off because that push is synchronous and so delays every
+# launch. With it on, the pull and the push are each capped at 20
+# seconds, since git has no connect timeout of its own and an
+# unreachable remote otherwise blocks for minutes. An explicit --push
+# is left uncapped: it is watched, and it must report what a real
+# transfer really did. The cap is timeout(1); on a system that somehow
+# lacks it, autopush says so on stderr and does not push at all, since
+# an unbounded network call in front of a launch is the one outcome the
+# cap exists to prevent. --push still works there.
+#
+# Over ssh the cap is delivered by setting GIT_SSH_COMMAND, which would
+# silently outrank the user's own configuration -- so it is not set at
+# all when GIT_SSH_COMMAND is already exported or git's core.sshCommand
+# is configured. A vault remote reachable only through a particular
+# identity file or ssh wrapper therefore keeps it, uncapped, rather than
+# failing to authenticate for the sake of a timeout.
+#
+# --adopt rebinds an entry; it does not pin its name. The slug is
+# re-derived from the project on every run, so the next ordinary run
+# migrates the adopted entry straight back to the canonical key, carrying
+# the memory and the live link with it. That is the point rather than a
+# wart: adopting is how a mismatched or ambiguous binding is repaired, not
+# how an entry is given a permanent name of its own.
+#
+# An entry's origin file records the project path once, when the entry is
+# created, and is never refreshed. A project that later moves on disk
+# therefore keeps a stale path there and --restore degrades to reporting
+# it as unplaceable rather than relinking the wrong directory. Rebind
+# such an entry from the project itself with --adopt=SLUG.
+#
+# The agy knowledge copy is merge-only. Files are copied into the vault
+# but are never removed from it, so a fact deleted upstream from agy's
+# knowledge store persists in the vault indefinitely, and a restore or a
+# fresh clone brings it back. Prune such an entry from the vault by hand
+# if it must really be gone.
+#
+# 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)
+ set -l c_flag (set_color yellow)
+ set -l c_ok (set_color green)
+ set -l c_warn (set_color yellow)
+ set -l c_err (set_color red)
+ set -l c_dim (set_color brblack)
+ set -l c_reset (set_color normal)
+
+ argparse h/help link push restore status 'adopt=' 'remote=' \
+ v/verbose q/quiet s/silent -- $argv
+ or return 1
+
+ if set -q _flag_help
+ echo "$c_head""Usage:$c_reset $c_cmd""agents-vault$c_reset $c_flag""[--link] [--push] [--restore] [--status] [--adopt=SLUG] [--remote=URL] [-v] [-q] [-s] [-h]$c_reset"
+ echo
+ echo " Track curated agent memory in a host-scoped vault repository."
+ echo
+ echo "$c_head""Options:$c_reset"
+ echo " $c_flag-h$c_reset, $c_flag--help$c_reset Show this help message"
+ echo " $c_flag--link$c_reset Scaffold + link this project; skip the commit"
+ echo " $c_flag--push$c_reset Commit, pull, then push to the remote"
+ echo " $c_flag--restore$c_reset Relink everything possible, report the rest"
+ echo " $c_flag--status$c_reset Show entries, link health, remote, orphans"
+ echo " $c_flag--adopt$c_reset=SLUG Bind this project to an existing vault entry"
+ echo " $c_flag--remote$c_reset=URL Set the vault remote"
+ echo " $c_flag-v$c_reset, $c_flag--verbose$c_reset Print all per-step output (default)"
+ echo " $c_flag-q$c_reset, $c_flag--quiet$c_reset Print one summary line only if changed"
+ echo " $c_flag-s$c_reset, $c_flag--silent$c_reset Suppress all output; errors only"
+ return 0
+ end
+
+ set -l verbose 1
+ set -l quiet 0
+ if set -q _flag_silent
+ set verbose 0
+ else if set -q _flag_quiet
+ set verbose 0
+ set quiet 1
+ end
+
+ if not type -q git
+ echo "$c_err""agents-vault: git is required$c_reset" >&2
+ return 1
+ end
+
+ set -l vault (_agents_vault_dir)
+ set -l changed 0
+ set -l did_init 0
+
+ # ─────────────────────────── --status ──────────────────────────────
+ # A report, and nothing but a report. This is dispatched here -- ahead
+ # of the scaffold, the tool install, and the global-state sync -- on
+ # purpose: asking what the vault looks like must never be the thing
+ # that creates it, copies the agy knowledge store into it, or claims
+ # ~/.claude/memory. A status command that mutates cannot be trusted to
+ # diagnose the thing it just changed.
+ if set -q _flag_status
+ echo "$c_head""Vault:$c_reset $vault"
+ if not test -d "$vault"
+ echo " $c_warn""no vault yet — run agents-vault inside a project to create one$c_reset"
+ return 0
+ end
+
+ set -l url (git -C "$vault" remote get-url origin 2>/dev/null)
+ if test -z "$url"
+ echo "$c_head""Remote:$c_reset $c_warn""no remote configured — nothing is backed up off this machine$c_reset"
+ else
+ echo "$c_head""Remote:$c_reset $url"
+ if git -C "$vault" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1
+ set -l ahead (git -C "$vault" rev-list --count '@{u}..HEAD' 2>/dev/null)
+ if test -n "$ahead"; and test "$ahead" != 0
+ echo " $c_warn$ahead commit(s) not yet pushed$c_reset"
+ end
+ else
+ echo " $c_warn""no upstream branch — never pushed$c_reset"
+ end
+ end
+
+ if test -d "$vault/.git/rebase-merge"; or test -d "$vault/.git/rebase-apply"
+ echo " $c_err""unresolved rebase in progress — resolve it before syncing$c_reset"
+ end
+
+ set -l claude_root $__fish_agent_vault_claude_root
+ test -n "$claude_root"; or set claude_root "$HOME/.claude/projects"
+
+ echo ""
+ echo "$c_head""Entries:$c_reset"
+ set -l seen 0
+ # Listed rather than globbed: fish's * skips dot-led names, and a
+ # dot-led slug is both reachable and sanctioned. A relative-path
+ # remote -- the sibling-bare-mirror idiom, `git remote add origin
+ # ../mirror.git` -- keys as ..-mirror, a dot-led host keys as
+ # .hidden.example.com-o-r, and --adopt accepts a leading dot on
+ # purpose. Such an entry is scaffolded, linked and committed like any
+ # other, so a glob here would under-report the vault without ever
+ # saying that it had. The 2>/dev/null is load-bearing: a vault with
+ # no projects/ is ordinary (running agents-vault outside any git
+ # repo makes exactly that one) and a bare ls would spew.
+ for name in (command ls -A "$vault/projects" 2>/dev/null)
+ set -l entry "$vault/projects/$name"
+ test -d "$entry"; or continue
+ set seen 1
+ set -l eslug $name
+ set -l count (command ls -A "$entry/claude/memory" 2>/dev/null | count)
+ set -l want (path resolve "$entry/claude/memory")
+ set -l linked 0
+ for cand in "$claude_root"/*/memory
+ test -L "$cand"; or continue
+ if test (path resolve "$cand") = "$want"
+ set linked 1
+ break
+ end
+ end
+ if test $linked -eq 1
+ echo " $c_ok""linked$c_reset $eslug $c_dim($count file(s))$c_reset"
+ else
+ echo " $c_warn""orphan$c_reset $eslug $c_dim($count file(s)) — no live project links here$c_reset"
+ end
+ end
+ test $seen -eq 0; and echo " $c_dim(none)$c_reset"
+ return 0
+ end
+
+ # ────────────────────── ensure the vault repo ──────────────────────
+ if not test -d "$vault"
+ if not mkdir -p "$vault"
+ echo "$c_err""agents-vault: could not create $vault$c_reset" >&2
+ return 1
+ end
+ set changed 1
+ set did_init 1
+ end
+ if not test -d "$vault/.git"
+ git -C "$vault" init -q
+ or begin
+ echo "$c_err""agents-vault: git init failed in $vault$c_reset" >&2
+ return 1
+ end
+ set changed 1
+ set did_init 1
+ test $verbose -eq 1; and echo "$c_ok→ Initialized vault repo at $vault$c_reset"
+ end
+
+ if not test -f "$vault/.version"
+ echo 1.0.0 >"$vault/.version"
+ set changed 1
+ end
+
+ if not test -f "$vault/.gitignore"
+ printf '%s\n' \
+ '# A SQLite database and its sidecars are never safe to commit' \
+ '# mid-write. Ignoring only the sidecars is worse than ignoring' \
+ '# none of them: a torn database then lands in the history with' \
+ '# the write-ahead log that would have completed it excluded.' \
+ '*.db' \
+ '*.db-wal' \
+ '*.db-shm' \
+ '' \
+ '# --adopt and the slug migration each stash the entry they are' \
+ '# about to overwrite inside .git/, out of reach of `git add -A`.' \
+ '# These cover the fallback locations they use when .git is not a' \
+ '# directory.' \
+ '/.adopt-stash' \
+ '/.migrate-stash' >"$vault/.gitignore"
+ set changed 1
+ end
+
+ if not test -f "$vault/README.md"
+ printf '%s\n' \
+ '# Agent Memory Vault' \
+ '' \
+ 'Curated agent memory, tracked so it survives losing a machine.' \
+ 'Managed by `agents-vault` from rootiest/fish-config.' \
+ '' \
+ '## Restore' \
+ '' \
+ 'Clone this repository to the path `agents-vault` resolves to' \
+ '(`$XDG_DATA_HOME/agent-vault`, or `$__fish_agent_vault_dir`).' \
+ 'Then simply run `claude` in any project: the wrapper derives that' \
+ "project's slug, finds its entry here, and relinks the live memory" \
+ 'directory automatically. There is no separate restore step.' \
+ '' \
+ 'Entries are keyed by normalized git remote URL. A `local-*` key' \
+ 'belongs to a project with no remote and is machine-specific;' \
+ 'rebind one with `agents-vault --adopt=SLUG`.' >"$vault/README.md"
+ set changed 1
+ end
+
+ set -l tools_msg (_agents_repo_install_tools "$vault")
+ or begin
+ echo "$c_err""agents-vault: could not install .agents-tools/ into $vault$c_reset" >&2
+ return 1
+ end
+ if test -n "$tools_msg"
+ set changed 1
+ test $verbose -eq 1; and echo "$c_ok$tools_msg$c_reset"
+ end
+
+ set -l hp (git -C "$vault" config --local core.hooksPath 2>/dev/null)
+ if test "$hp" != .agents-tools/hooks
+ git -C "$vault" config --local core.hooksPath .agents-tools/hooks
+ or begin
+ echo "$c_err""agents-vault: could not set core.hooksPath in $vault$c_reset" >&2
+ return 1
+ end
+ set changed 1
+ end
+
+ # ─────────────────────────── --remote ──────────────────────────────
+ # Mutating modes are dispatched here: after the vault repo exists (they
+ # all need one) but before the global-state sync below, which belongs
+ # to a default backup run and has no business running as a side effect
+ # of rebinding an entry or setting a URL.
+ if set -q _flag_remote
+ if test -z "$_flag_remote"
+ echo "$c_err""agents-vault: --remote needs a URL$c_reset" >&2
+ return 1
+ end
+ # The result is captured explicitly rather than chained off the
+ # block terminator with `or`. `end` does carry the taken branch's
+ # status in fish, but only when a branch was taken at all: the same
+ # construct one `else` away silently reports success, which is
+ # exactly how a hook-rejected commit once passed for a good one.
+ set -l rc 0
+ if git -C "$vault" remote get-url origin >/dev/null 2>&1
+ git -C "$vault" remote set-url origin "$_flag_remote"
+ set rc $status
+ else
+ git -C "$vault" remote add origin "$_flag_remote"
+ set rc $status
+ end
+ if test $rc -ne 0
+ echo "$c_err""agents-vault: could not set the vault remote to $_flag_remote$c_reset" >&2
+ return 1
+ end
+ test $verbose -eq 1; and echo "$c_ok→ Vault remote set to $_flag_remote$c_reset"
+ return 0
+ end
+
+ # ─────────────────────────── --adopt ───────────────────────────────
+ if set -q _flag_adopt
+ # The requested slug is interpolated into a vault path and handed
+ # to `git mv`, so it is validated before it is used anywhere:
+ # --adopt=../../../etc would otherwise walk straight out of the
+ # vault. Only the charset the slug formula itself emits is
+ # accepted, which excludes the slash that any traversal needs, and
+ # the two names that traverse without one are refused by name.
+ #
+ # A leading dot is *not* refused: _agents_repo_slug legitimately
+ # emits one for a dot-led subdomain (https://.hidden.example.com/r
+ # keys as .hidden.example.com-r), and refusing it would make such
+ # an entry impossible to adopt. Inside projects/ a leading dot is
+ # a hidden directory, not an escape.
+ set -l bad_slug 0
+ string match -qr '^[a-z0-9._-]+$' -- "$_flag_adopt"; or set bad_slug 1
+ contains -- "$_flag_adopt" . ..; and set bad_slug 1
+ if test $bad_slug -eq 1
+ echo "$c_err""agents-vault: invalid slug '$_flag_adopt'$c_reset" >&2
+ echo "$c_err"" A slug is [a-z0-9._-]+, is not '.' or '..', and holds no slash.$c_reset" >&2
+ return 1
+ end
+
+ set -l root (git rev-parse --show-toplevel 2>/dev/null)
+ if test -z "$root"
+ echo "$c_err""agents-vault: --adopt must run inside a project$c_reset" >&2
+ return 1
+ end
+ set -l cur (_agents_repo_slug "$root")
+ set -l from "$vault/projects/$cur"
+ set -l to "$vault/projects/$_flag_adopt"
+ if test "$cur" = "$_flag_adopt"
+ test $verbose -eq 1; and echo "$c_ok→ Already bound to $_flag_adopt$c_reset"
+ return 0
+ end
+ if not test -d "$from"
+ echo "$c_err""agents-vault: no vault entry for this project ($cur)$c_reset" >&2
+ return 1
+ end
+
+ set -l to_content
+ test -d "$to/claude/memory"; and set to_content (command ls -A "$to/claude/memory" 2>/dev/null)
+ if test (count $to_content) -gt 0
+ echo "$c_err""agents-vault: $_flag_adopt already holds content; refusing to overwrite$c_reset" >&2
+ return 1
+ end
+ # Adopting is a rename plus a relink, and it must be all or
+ # nothing. A rename that lands while the relink fails leaves the
+ # memory intact at the new slug but unreferenced: the next ordinary
+ # run finds no live link, recomputes the old slug, finds nothing
+ # there, and fabricates a fresh empty entry, so the agent writes
+ # history-less memory from then on. No bytes are lost, but
+ # continuity is, and nothing recovers it automatically.
+ #
+ # So a contentless target entry is moved aside rather than deleted,
+ # and the origin note is appended only once the relink has
+ # succeeded. Both are undone below if it does not. The stash lives
+ # inside .git/: it is on the same filesystem, so the rename stays a
+ # rename; it is not under projects/, so a crash between the two moves
+ # cannot leave something that reads as an entry; and `git add -A`
+ # never descends there, so a crash cannot leave junk that the next
+ # ordinary run commits either. A vault whose .git is not a directory
+ # is not one this tool made, so that case falls back to the vault
+ # root, which the scaffolded .gitignore covers.
+ set -l stash ""
+ if test -d "$to"
+ if test -d "$vault/.git"
+ set stash "$vault/.git/agents-vault-adopt-stash"
+ else
+ set stash "$vault/.adopt-stash"
+ end
+ rm -rf "$stash"
+ command mv "$to" "$stash"; or return 1
+ end
+ if not git -C "$vault" mv "projects/$cur" "projects/$_flag_adopt" 2>/dev/null
+ if not command mv "$from" "$to"
+ test -n "$stash"; and command mv "$stash" "$to"
+ # Putting the worktree back is only half a rollback. Every
+ # move here is a plain rename as far as git is concerned, so
+ # the index still describes the half-applied state, and a
+ # hand `git commit` in that window would record it -- the
+ # next ordinary run's `git add -A` heals it, but only later.
+ # projects/ is re-read whole rather than the two entries
+ # 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. 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
+ # Without this the user is left holding a raw coreutils
+ # `mv:` line and no statement of what it cost them. Every
+ # other error exit in this function is branded and says
+ # what state it left behind; this one reaching the terminal
+ # bare made a clean rollback look like a crash.
+ echo "$c_err""agents-vault: could not rename $cur → $_flag_adopt$c_reset" >&2
+ echo "$c_err"" The adopt was abandoned; $cur was left as it was.$c_reset" >&2
+ return 1
+ end
+ end
+
+ set -l claude_root $__fish_agent_vault_claude_root
+ test -n "$claude_root"; or set claude_root "$HOME/.claude/projects"
+ set -l mangled (string replace -a '/' '-' -- "$root" | string replace -a '.' '-')
+ # The live link is deliberately left in place for ensure_symlink to
+ # repin, which it does on its own for a link pointing elsewhere.
+ # Removing it first would only widen the window in which a failure
+ # leaves the project with no link at all.
+ if not _agents_repo_ensure_symlink "$claude_root/$mangled/memory" "$to/claude/memory" >/dev/null
+ if not git -C "$vault" mv "projects/$_flag_adopt" "projects/$cur" 2>/dev/null
+ command mv "$to" "$from"
+ end
+ test -n "$stash"; and command mv "$stash" "$to"
+ # Only now, with the worktree whole again -- the stash restore
+ # included -- is the index worth re-reading. Unconditional
+ # 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,
+ # 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
+ printf 'adopted: %s → %s (%s)\n' "$cur" "$_flag_adopt" (date -I) >>"$to/origin"
+ test -n "$stash"; and rm -rf "$stash"
+
+ _agents_repo_sync "$vault" "chore: adopt $cur as $_flag_adopt" >/dev/null
+ test $verbose -eq 1; and echo "$c_ok→ Adopted $cur as $_flag_adopt$c_reset"
+ return 0
+ end
+
+ # ────────────────────────── --restore ──────────────────────────────
+ # The batch counterpart of the emergent per-project restore. Each entry
+ # records the path it was created at; where that path still exists the
+ # live memory directory is relinked, and where it does not the entry is
+ # named so it can be rebound with --adopt. The origin file is written
+ # once and never refreshed, so a project that has since moved on disk
+ # simply degrades to "cannot place" rather than relinking the wrong
+ # directory.
+ if set -q _flag_restore
+ set -l claude_root $__fish_agent_vault_claude_root
+ test -n "$claude_root"; or set claude_root "$HOME/.claude/projects"
+ set -l restore_failed 0
+ # Listed rather than globbed, for the reason spelled out at --status:
+ # fish's * skips a dot-led slug, which is a legitimate key, and a
+ # batch restore that silently walks past an entry is worse here than
+ # in a report -- that project is simply left unlinked.
+ for name in (command ls -A "$vault/projects" 2>/dev/null)
+ set -l entry "$vault/projects/$name"
+ test -d "$entry/claude/memory"; or continue
+ set -l eslug $name
+ set -l opath ""
+ if test -f "$entry/origin"
+ set -l line (command grep -m1 '^path:' "$entry/origin" 2>/dev/null)
+ test -n "$line"; and set opath (string replace -r '^path:\s+' '' -- "$line")
+ end
+ if test -n "$opath"; and test -d "$opath"
+ set -l m (string replace -a '/' '-' -- "$opath" | string replace -a '.' '-')
+ set -l msg (_agents_repo_ensure_symlink "$claude_root/$m/memory" "$entry/claude/memory")
+ if test $status -ne 0
+ echo "$c_err""agents-vault: could not relink $eslug$c_reset" >&2
+ set restore_failed 1
+ else if test -n "$msg"
+ test $verbose -eq 1; and echo "$c_ok→ Restored $eslug$c_reset"
+ end
+ else
+ test $verbose -eq 1
+ and echo "$c_warn→ Cannot place $eslug: no live project found; use --adopt from the project$c_reset"
+ end
+ end
+ # An entry that could not be relinked is a failure, not a note in
+ # passing: the same reasoning as the commit and push paths below.
+ # An entry with no live project is not -- there is nothing wrong
+ # with the vault, the project simply is not on this machine.
+ return $restore_failed
+ 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
+ set -l knowledge "$agy_root/knowledge"
+ if test -d "$knowledge"
+ # The allowlist has to hold *inside* knowledge/ too, not just at the
+ # agy root. A recursive copy of the directory is a denylist wearing
+ # an allowlist's clothes: it promises that nothing new upstream adds
+ # can leak in while copying, verbatim, whatever upstream chooses to
+ # put one level down. A .credentials.json dropped in there went
+ # straight into a commit.
+ #
+ # Extensions, because that is what the store actually is: agy's
+ # knowledge is written as Markdown notes with JSON metadata beside
+ # them. Everything else there is machinery, not knowledge --
+ # knowledge.lock is a live lock file whose committed copy is at best
+ # meaningless and at worst confusing on restore, and the SQLite
+ # databases must never be captured mid-write. Both are excluded by
+ # having no business in a backup, not by being individually known
+ # about, which is the property that survives upstream adding a file.
+ #
+ # Fish wildcards skip dot-led names at every path component, so
+ # dotfiles and hidden subdirectories are already out; the extension
+ # allowlist is what keeps them out on purpose rather than by luck.
+ #
+ # The tree is walked a level at a time rather than globbed with **,
+ # because a symlink has to stop the walk and ** has no way to say
+ # so. A recursive ** descends through a symlinked directory and cp
+ # follows a symlinked file, which between them undo the whole point
+ # of the allowlist twice over. A `ln -s ~ knowledge/x` puts every
+ # qualifying .md and .json in the home directory -- settings.json,
+ # CLAUDE.md, cache and status files -- into the vault and into a
+ # commit: the extension rule still bounds what *kind* of file goes
+ # in, but the store boundary that decides *whose* files they are is
+ # gone. Worse, `ln -s / knowledge/x` makes the walk itself
+ # unbounded, and this runs synchronously in front of every agent
+ # launch. Fish does stop a true self-referential cycle; a symlink
+ # to a merely enormous tree is not a cycle.
+ #
+ # So nothing that is a symlink is ever followed or copied, whether
+ # it names a file or a directory. The knowledge store's own root
+ # may still be a link -- that one is the configured location of the
+ # store rather than something found inside it.
+ #
+ # The walk prints its finds and the list is built once from that
+ # output, rather than appending each path to a list as it goes.
+ # `set -a` rewrites the whole variable every time, so appending n
+ # paths one at a time costs O(n^2) copying -- 500 files took 21ms
+ # and 20,000 took 58s on this machine, on a path that runs in
+ # front of every agent launch. Printing is flat: the same 20,000
+ # files take 756ms, and 500 take 13ms. Batching per directory does
+ # not help, because a knowledge store is mostly one flat directory
+ # and that is exactly where the growing list lives.
+ #
+ # NUL separators and `string split0`, not newlines: a filename may
+ # legally contain a newline, and splitting on one would saw such a
+ # path into two entries and copy neither. NUL is the one byte a
+ # path cannot hold, so the round trip is lossless.
+ set -l kfiles (begin
+ set -l kdirs "$knowledge"
+ while set -q kdirs[1]
+ set -l dir $kdirs[1]
+ set -e kdirs[1]
+ for e in $dir/*
+ test -L "$e"; and continue
+ if test -d "$e"
+ set -a kdirs "$e"
+ else if string match -qr '\.(md|json)$' -- "$e"
+ printf '%s\0' "$e"
+ end
+ end
+ end
+ end | string split0)
+ set -l kfailed 0
+ for f in $kfiles
+ test -f "$f"; or continue
+ set -l rel (string replace -- "$knowledge/" "" "$f")
+ set -l dest "$vault/global/agy/knowledge/$rel"
+ if not mkdir -p (path dirname "$dest")
+ set kfailed 1
+ continue
+ end
+ if command cp "$f" "$dest"
+ set agy_copied 1
+ else
+ set kfailed 1
+ end
+ end
+ if test $kfailed -eq 1
+ echo "$c_warn""agents-vault: could not copy part of the agy knowledge store$c_reset" >&2
+ 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.
+ # -e rather than -d on the live side so a *broken* global path (a stray
+ # regular file where the directory belongs) is noticed and reported on
+ # every run, instead of being silently skipped and mistaken for the
+ # absent-by-default case.
+ if test -e "$glive"; or test -L "$glive"; or test (count $gvault_content) -gt 0
+ # Best-effort, exactly like the agy copy above. Global memory is
+ # optional and frequently absent, and this block runs before the
+ # per-project link, so a fault here must warn and continue rather
+ # than return: aborting would let a stray file or a permission
+ # problem at ~/.claude/memory kill the per-project memory backup
+ # and its commit for every project, on every agent launch. The
+ # secondary feature must not take the primary one down with it.
+ #
+ # Continuing is safe: _agents_repo_ensure_symlink validates and
+ # refuses before it mutates anything, so its failure window is the
+ # same whether the caller returns or carries on. $changed stays
+ # untouched unless the link actually succeeded, and nothing is
+ # recorded to make a later run believe the global memory is linked
+ # when it is not -- the next run re-enters this block and retries.
+ if not mkdir -p "$gvault"
+ echo "$c_warn""agents-vault: could not create $gvault; skipping global memory$c_reset" >&2
+ else
+ set -l gmsg (_agents_repo_ensure_symlink "$glive" "$gvault")
+ set -l grc $status
+ if test $grc -ne 0
+ echo "$c_warn""agents-vault: could not link $glive; global memory not backed up$c_reset" >&2
+ else if test -n "$gmsg"
+ set changed 1
+ test $verbose -eq 1; and echo "$c_ok$gmsg$c_reset"
+ end
+ end
+ end
+
+ # ──────────────────── link the current project ─────────────────────
+ set -l root (git rev-parse --show-toplevel 2>/dev/null)
+ if test -n "$root"
+ set -l slug (_agents_repo_slug "$root")
+ set -l entry "$vault/projects/$slug"
+ set -l vmem "$entry/claude/memory"
+
+ set -l claude_root $__fish_agent_vault_claude_root
+ test -n "$claude_root"; or set claude_root "$HOME/.claude/projects"
+ set -l mangled (string replace -a '/' '-' -- "$root" | string replace -a '.' '-')
+ set -l live "$claude_root/$mangled/memory"
+
+ # ── Slug migration (spec 4.5) ─────────────────────────────────────
+ # The previous slug never has to be guessed: the live symlink still
+ # points at the old entry. This covers a remote being added, a
+ # remote URL being rewritten, and a remote being removed.
+ set -l prev_slug ""
+ if test -L "$live"
+ set -l tgt (path resolve "$live")
+ set -l pdir (path resolve "$vault/projects")
+ if string match -q "$pdir/*" -- "$tgt"
+ set -l rest (string replace "$pdir/" "" -- "$tgt")
+ set prev_slug (string split -f1 '/' -- $rest)
+ end
+ end
+ if test -z "$prev_slug"
+ # No link yet (fresh machine): try the path-derived candidate.
+ set -l cand (_agents_repo_local_slug "$root")
+ if test "$cand" != "$slug"; and test -d "$vault/projects/$cand"
+ set prev_slug $cand
+ end
+ end
+
+ if test -n "$prev_slug"; and test "$prev_slug" != "$slug"
+ set -l cur_content
+ test -d "$vmem"; and set cur_content (command ls -A "$vmem" 2>/dev/null)
+ if test (count $cur_content) -gt 0
+ echo "$c_err""agents-vault: cannot migrate $prev_slug → $slug; both entries hold content.$c_reset" >&2
+ echo "$c_err"" Resolve with: agents-vault --adopt=SLUG$c_reset" >&2
+ return 1
+ end
+ # What matters here is whether the destination *entry* exists,
+ # not whether it has a memory subdirectory. Gating on the
+ # subdirectory looks equivalent and is not: an entry can exist
+ # with no claude/ subtree at all, and then `git mv A B` moves A
+ # *inside* B, the mkdir below fabricates a fresh empty memory
+ # directory, the live link is pinned to that, and the real
+ # memory is stranded one level deeper than --status and
+ # --restore ever look. The run reports success while the
+ # backup is gone.
+ #
+ # That shape is not exotic; it is what git hands back. Git
+ # cannot track an empty directory, so an entry committed while
+ # its memory was empty materialises after a clone as
+ # projects//origin and nothing else -- and cloning the
+ # vault onto a new machine is this feature's own advertised
+ # recovery path.
+ #
+ # So the destination is moved aside rather than deleted, for
+ # the same reason --adopt does it: widening the old rm -rf
+ # would throw away the destination's origin log, which is real
+ # provenance and which the clone case always has. The stash
+ # lives inside .git/ -- same filesystem, so the move stays a
+ # rename; outside projects/, so a crash cannot leave something
+ # that reads as an entry; and never descended into by
+ # `git add -A`, so a crash cannot leave junk to be committed
+ # either. See --adopt above for the .git-is-not-a-directory
+ # fallback.
+ set -l stash ""
+ if test -d "$entry"
+ if test -d "$vault/.git"
+ set stash "$vault/.git/agents-vault-migrate-stash"
+ else
+ set stash "$vault/.migrate-stash"
+ end
+ rm -rf "$stash"
+ if not command mv "$entry" "$stash"
+ echo "$c_err""agents-vault: could not set aside the existing $slug entry$c_reset" >&2
+ echo "$c_err"" The migration was abandoned; $prev_slug was left as it was.$c_reset" >&2
+ return 1
+ end
+ end
+ if not git -C "$vault" mv "projects/$prev_slug" "projects/$slug" 2>/dev/null
+ if not command mv "$vault/projects/$prev_slug" "$entry"
+ test -n "$stash"; and command mv "$stash" "$entry"
+ # Every move here is a plain rename as far as git is
+ # concerned, so the index still describes the
+ # half-applied state even once the worktree is whole
+ # again. projects/ is re-read whole rather than the two
+ # entries named, and its stderr is left visible, for
+ # the reasons spelled out at --adopt.
+ git -C "$vault" add -A -- projects
+ echo "$c_err""agents-vault: could not migrate $prev_slug → $slug$c_reset" >&2
+ echo "$c_err"" The vault was left exactly as it was.$c_reset" >&2
+ return 1
+ end
+ end
+ # The set-aside entry is folded back in rather than dropped.
+ # -n keeps everything the migrated entry already has, so this
+ # only ever adds what the destination held and the migrated
+ # entry lacks; origin is the one file both sides always have,
+ # so its history is appended by hand instead. Neither failing
+ # is fatal -- the memory and the rename have already landed,
+ # and losing a provenance note is not worth undoing that.
+ #
+ # Appended, not prepended, and that order is load-bearing:
+ # --restore reads the *first* "path:" line out of origin, and
+ # that has to stay this project's own. The set-aside entry's
+ # path came from whichever machine created it and would send a
+ # restore at a directory that is not this one.
+ if test -n "$stash"
+ test -f "$stash/origin"
+ and command cat "$stash/origin" >>"$entry/origin" 2>/dev/null
+ command cp -rn "$stash/." "$entry/" 2>/dev/null
+ rm -rf "$stash"
+ end
+ printf 'renamed: %s → %s (%s)\n' "$prev_slug" "$slug" (date -I) \
+ >>"$entry/origin"
+ # Only a link is dropped here, and only so the relink below has
+ # somewhere to put the new one. Usually $live is exactly that: a
+ # symlink at the old entry, now dangling. But the migration is
+ # also reachable from the path-derived fallback candidate, and
+ # there $live can be a real, populated directory -- someone's
+ # actual memory. rm -f cannot delete it, which is the right
+ # outcome, but it says so on stderr in rm's own voice, so a
+ # --silent run that succeeded printed what reads as an error.
+ # The directory case needs no removal anyway:
+ # _agents_repo_ensure_symlink copies a populated live directory
+ # into the vault without clobbering before it replaces it.
+ #
+ # A stray *regular* file at $live is left alone too, and that is
+ # deliberate rather than an oversight in the test. Nothing this
+ # function created is a plain file there, so whatever it is came
+ # from the user or from something else writing to the same path,
+ # and deleting it unasked would destroy data to make room for a
+ # symlink. _agents_repo_ensure_symlink refuses the path and says
+ # why, and the launch stops until someone looks -- the same
+ # answer the global-memory block gives for the same shape of
+ # surprise.
+ test -L "$live"; and rm -f "$live"
+ set changed 1
+ test $verbose -eq 1; and echo "$c_ok→ Migrated vault entry $prev_slug → $slug$c_reset"
+ end
+
+ if not test -d "$vmem"
+ if not mkdir -p "$vmem"
+ echo "$c_err""agents-vault: could not create $vmem$c_reset" >&2
+ return 1
+ end
+ set changed 1
+ end
+
+ # Unconditional: this is the emergent-restore path. On a freshly
+ # cloned vault, $vmem already exists (populated from the clone) and
+ # $live does not exist yet -- skipping the link here would silently
+ # leave the clone's memory unlinked and let a starting agent write
+ # fresh, history-less memory instead. _agents_repo_ensure_symlink is
+ # idempotent and makes its own parent directories, so there is
+ # nothing this guard would protect that the helper does not already
+ # handle on its own.
+ set -l link_msg (_agents_repo_ensure_symlink "$live" "$vmem")
+ set -l link_rc $status
+ if test $link_rc -ne 0
+ echo "$c_err""agents-vault: could not link $live$c_reset" >&2
+ return 1
+ end
+ if test -n "$link_msg"
+ set changed 1
+ test $verbose -eq 1; and echo "$c_ok$link_msg$c_reset"
+ end
+
+ if not test -f "$entry/origin"
+ set -l url (git -C "$root" remote get-url origin 2>/dev/null)
+ test -n "$url"; or set url "(none)"
+ set -l host ""
+ if type -q hostname
+ set host (hostname 2>/dev/null)
+ end
+ printf 'remote: %s\npath: %s\nhost: %s\n' \
+ "$url" "$root" "$host" >"$entry/origin"
+ set changed 1
+ end
+ end
+
+ # ───────────────────────────── commit ──────────────────────────────
+ # A sync that did not commit is a backup that did not happen, so it is
+ # reported as a failure rather than warned about and walked past. The
+ # function otherwise ends on a branchless `if`, which resolves to 0,
+ # and a backup tool that reports success while nothing was recorded
+ # recreates the exact loss the vault exists to prevent.
+ set -l failed 0
+ if not set -q _flag_link
+ set -l msg "chore: sync agent memory vault"
+ test $did_init -eq 1; and set msg "chore: initialize agent memory vault"
+ set -l sync_out (_agents_repo_sync "$vault" "$msg")
+ set -l sync_rc $status
+ if test $sync_rc -eq 2
+ echo "$c_err""agents-vault: unresolved rebase in the vault; nothing committed$c_reset" >&2
+ set failed 1
+ else if test $sync_rc -ne 0
+ echo "$c_err""agents-vault: the vault commit failed; nothing recorded$c_reset" >&2
+ set failed 1
+ else if test -n "$sync_out"
+ set changed 1
+ test $verbose -eq 1; and echo "$c_ok$sync_out$c_reset"
+ end
+ end
+
+ # ────────────────────────────── push ───────────────────────────────
+ # Pushing is explicit. Autopush exists but is opt-in, because this runs
+ # on every agent launch and a network operation there can hang or
+ # prompt for credentials invisibly underneath a starting agent.
+ set -l do_push 0
+ set -q _flag_push; and set do_push 1
+ if set -q __fish_agent_vault_autopush; and test "$__fish_agent_vault_autopush" = 1
+ set do_push 1
+ end
+ # timeout(1) is the only thing bounding autopush, and an unbounded
+ # network call in front of an agent launch is the exact block this
+ # design exists to remove -- so without it, autopush does not happen
+ # at all rather than happening open-endedly. Nothing is lost that was
+ # not already local: the commit has landed, and `agents-vault --push`
+ # still sends it by hand, deliberately unbounded because the user is
+ # watching that one. It says so rather than skipping quietly, because
+ # a vault that stopped leaving the machine must never look like one
+ # that did not. timeout ships with coreutils, so this is a guard
+ # against the impossible-until-it-happens, not a real dependency.
+ if test $do_push -eq 1; and not set -q _flag_push; and not type -q timeout
+ echo "$c_warn""agents-vault: timeout is unavailable, so autopush cannot be bounded; the vault is committed locally but not pushed$c_reset" >&2
+ set do_push 0
+ end
+ if test $do_push -eq 1
+ if git -C "$vault" remote get-url origin >/dev/null 2>&1
+ # The pull belongs here and nowhere earlier. Fetching is only
+ # ever needed in order to push; committing needs no remote at
+ # all. Keeping it on the commit path put a network round trip
+ # in front of every agent launch, where an unreachable remote
+ # blocks the launch until it times out and a credential prompt
+ # has nobody to answer it -- and, worse, a failed fetch there
+ # took the local commit down with it, so an offline laptop
+ # silently stopped being backed up at all.
+ #
+ # GIT_TERMINAL_PROMPT=0 and GIT_ASKPASS make git fail fast
+ # rather than ask. Neither disturbs a configured credential
+ # helper, which git consults before it ever falls back to
+ # prompting; they only close off the interactive last resort,
+ # which under a starting agent is indistinguishable from a hang.
+ #
+ # They close the prompt, not the socket, and git has no knob
+ # that closes the socket either: there is no HTTP connect
+ # timeout in its configuration at all, and http.lowSpeedLimit /
+ # http.lowSpeedTime -- the usual suggestion -- only start
+ # counting once bytes are moving, so they expire never against
+ # an address that simply blackholes the SYN. Measured against
+ # 192.0.2.1 with both set: no return inside 30s; unset: 135s.
+ #
+ # ssh is the one transport that can time itself out, so it is
+ # told to, unless the user has already said how to run ssh.
+ # Everything else is bounded from outside with timeout(1).
+ #
+ # "Already said" has two spellings and both have to count.
+ # GIT_SSH_COMMAND is the obvious one; core.sshCommand is the
+ # documented place to name an identity file or an ssh wrapper,
+ # and it is the one a vault on a private host is most likely to
+ # need. An injected environment variable outranks the config,
+ # so consulting only the environment does not merely miss the
+ # user's setting -- it overrides it, and a remote reachable
+ # only as `ssh -i ~/.ssh/vault_key` then fails to authenticate
+ # on every push. A ten-second connect bound is not worth that.
+ #
+ # -qx rather than -q on the environment side: a fish variable
+ # that was never exported satisfies -q but is not in the
+ # environment git runs in, so treating it as the user's answer
+ # would leave the push with neither their ssh command nor a
+ # connect timeout. Only what git can actually see counts, and
+ # exporting it is the user's call to make, not this function's.
+ set -l gitenv GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=true
+ set -l user_ssh (git -C "$vault" config --get core.sshCommand 2>/dev/null)
+ if not set -qx GIT_SSH_COMMAND; and test -z "$user_ssh"
+ set -a gitenv 'GIT_SSH_COMMAND=ssh -o ConnectTimeout=10'
+ end
+ set -l gitnet env $gitenv
+
+ # Only autopush is wrapped. An explicit --push is a thing the
+ # user asked for and is watching, and it must report the real
+ # exit status of a real transfer, so it is allowed to take as
+ # long as the transfer honestly takes. Autopush runs
+ # synchronously in front of every agent launch, which is the
+ # block this whole design exists to remove; there an
+ # unreachable remote costs a bounded 20s per operation instead
+ # of an open-ended wait. timeout's own 124 is a non-zero exit
+ # like any other, so a bounded push still reports as failed and
+ # the commit it could not send has already landed locally. A
+ # push too slow for the bound can always be run by hand. An
+ # autopush with no timeout(1) to wrap it never reaches here;
+ # it was turned off above.
+ if not set -q _flag_push
+ set gitnet timeout 20 $gitnet
+ end
+ set -l reached 1
+ if git -C "$vault" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1
+ if not $gitnet git -C "$vault" pull --rebase --autostash -q \
+ >/dev/null 2>/dev/null
+ # Two unrelated failures land here and reporting them as
+ # one sends the user hunting for a conflict that never
+ # existed. A rebase that genuinely started and stopped
+ # on a conflict leaves rebase-merge/ or rebase-apply/
+ # behind; that rebase is ours, so it is aborted and the
+ # vault is left at local HEAD. Everything else -- an
+ # unreachable remote being far and away the common case
+ # -- never began a rebase at all.
+ if test -d "$vault/.git/rebase-merge"; or test -d "$vault/.git/rebase-apply"
+ git -C "$vault" rebase --abort >/dev/null 2>/dev/null
+ echo "$c_err""agents-vault: rebase conflict in the vault; aborted at local HEAD, nothing pushed$c_reset" >&2
+ else
+ echo "$c_err""agents-vault: could not reach the vault remote; the vault is committed locally but not backed up off this machine$c_reset" >&2
+ end
+ set reached 0
+ set failed 1
+ end
+ end
+ # A remote we could not read from is not worth pushing to: the
+ # push would only fail a second time, more confusingly, and the
+ # pull has already said exactly what went wrong.
+ if test $reached -eq 1
+ if $gitnet git -C "$vault" push -q origin HEAD
+ test $verbose -eq 1; and echo "$c_ok→ Pushed the vault to origin$c_reset"
+ else
+ # The commit above did happen, so the memory is safe
+ # locally and the next push will carry it -- but nothing
+ # left this machine, which is the whole point of pushing,
+ # so this is a failure and not a warning to walk past.
+ echo "$c_err""agents-vault: push failed; the vault is committed locally but not backed up off this machine$c_reset" >&2
+ set failed 1
+ end
+ end
+ else if set -q _flag_push
+ # An explicit --push that pushed nowhere must not read as a
+ # successful backup. Autopush stays quiet: it is a background
+ # convenience on a vault that may deliberately have no remote.
+ echo "$c_err""agents-vault: no remote configured; set one with --remote=URL$c_reset" >&2
+ return 1
+ end
+ end
+
+ if test $quiet -eq 1; and test $changed -eq 1
+ if test $did_init -eq 1
+ echo "$c_ok→ Initialized agent memory vault$c_reset"
+ else
+ echo "$c_ok→ Synced agent memory vault$c_reset"
+ end
+ end
+
+ # Explicit, because the branchless `if` above resolves to 0 and would
+ # otherwise be this function's exit status.
+ test $failed -eq 0
+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
new file mode 100644
index 0000000..ef590d0
--- /dev/null
+++ b/tests/test-agents-vault.fish
@@ -0,0 +1,1929 @@
+#!/usr/bin/env fish
+# Copyright (C) 2026 Rootiest
+# SPDX-License-Identifier: AGPL-3.0-or-later
+#
+# Hermetic tests for the agent memory vault helpers. Every test builds its
+# own throwaway git repos and directories under mktemp; nothing touches the
+# live vault, ~/.claude, or this checkout.
+#
+# Usage: fish tests/test-agents-vault.fish
+
+set -l here (realpath (dirname (status filename)))
+set -g repo_root (realpath $here/..)
+set -p fish_function_path $repo_root/functions
+
+set -g TESTS_RUN 0
+set -g TESTS_FAILED 0
+set -g TMPDIRS
+
+function check --argument-names label want got
+ set -g TESTS_RUN (math $TESTS_RUN + 1)
+ if test "$want" = "$got"
+ echo " PASS $label"
+ else
+ echo " FAIL $label"
+ echo " want: $want"
+ echo " got: $got"
+ set -g TESTS_FAILED (math $TESTS_FAILED + 1)
+ end
+end
+
+# Create a throwaway git repo, optionally with an origin remote.
+function new_repo --argument-names url
+ set -l d (mktemp -d)
+ set -ga TMPDIRS $d
+ git -C $d init -q
+ git -C $d config user.email t@t
+ git -C $d config user.name t
+ git -C $d config commit.gpgsign false
+ git -C $d config core.hooksPath /dev/null
+ test -n "$url"; and git -C $d remote add origin $url
+ printf '%s\n' $d
+end
+
+function cleanup
+ for d in $TMPDIRS
+ test -n "$d"; and rm -rf $d
+ 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 =="
+
+set -l want git.rootiest.dev-rootiest-fish-config
+
+set -l r (new_repo https://git.rootiest.dev/rootiest/fish-config.git)
+check "https form" $want (_agents_repo_slug $r)
+
+set r (new_repo git@git.rootiest.dev:rootiest/fish-config.git)
+check "scp form" $want (_agents_repo_slug $r)
+
+set r (new_repo ssh://git@git.rootiest.dev:22/rootiest/fish-config.git)
+check "ssh form with port" $want (_agents_repo_slug $r)
+
+set r (new_repo https://git.rootiest.dev/rootiest/fish-config)
+check "no .git suffix" $want (_agents_repo_slug $r)
+
+set r (new_repo HTTPS://Git.Rootiest.DEV/Rootiest/Fish-Config.git)
+check "case folded" $want (_agents_repo_slug $r)
+
+# Remote-less: local- prefix, stable across runs, distinct per path.
+set -l n1 (new_repo)
+set -l s1 (_agents_repo_slug $n1)
+set -l s2 (_agents_repo_slug $n1)
+check "local slug is stable" $s1 $s2
+check "local slug is prefixed" true (string match -q 'local-*' -- $s1; and echo true; or echo false)
+
+set -l n2 (new_repo)
+check "local slugs differ by path" false (test "$s1" = (_agents_repo_slug $n2); and echo true; or echo false)
+
+# A non-origin remote is used when origin is absent.
+set -l r3 (new_repo)
+git -C $r3 remote add upstream https://git.rootiest.dev/rootiest/fish-config.git
+check "falls back to first remote" $want (_agents_repo_slug $r3)
+
+# Slug sanitization test: special chars in fallback (local-) branch.
+set -l dirt (mktemp -d); set -ga TMPDIRS $dirt
+mkdir -p "$dirt/projects/My Project!"
+git -C "$dirt/projects/My Project!" init -q
+git -C "$dirt/projects/My Project!" config user.email t@t
+git -C "$dirt/projects/My Project!" config user.name t
+set -l slug_dirty (_agents_repo_slug "$dirt/projects/My Project!")
+set -l has_bad_chars (string match -q '*[ !]*' -- "$slug_dirty"; and echo true; or echo false)
+check "sanitizes special chars in local slug" false "$has_bad_chars"
+
+# ──────────────────────── ensure_symlink rails ─────────────────────────
+echo ""
+echo "== _agents_repo_ensure_symlink =="
+
+set -l w (mktemp -d); set -ga TMPDIRS $w
+mkdir -p $w/target $w/live
+
+# Fresh link onto an empty live parent.
+_agents_repo_ensure_symlink $w/live/memory $w/target >/dev/null
+check "creates the link" true (test -L $w/live/memory; and echo true; or echo false)
+check "link resolves to target" (path resolve $w/target) (path resolve $w/live/memory)
+
+# Idempotent: a second run prints nothing.
+set -l second (_agents_repo_ensure_symlink $w/live/memory $w/target)
+check "idempotent, silent" "" "$second"
+
+# Refuses a non-directory target (a symlinked FILE breaks agent edits).
+touch $w/afile
+check "refuses file target" 1 (_agents_repo_ensure_symlink $w/live/f2 $w/afile 2>/dev/null; echo $status)
+
+# Refuses to create a dangling link when the target is missing.
+check "refuses missing target" 1 (_agents_repo_ensure_symlink $w/live/f3 $w/nope 2>/dev/null; echo $status)
+check "no dangling link left" false (test -L $w/live/f3; and echo true; or echo false)
+
+# Non-destructive adoption: content on both sides, nothing overwritten.
+set -l a (mktemp -d); set -ga TMPDIRS $a
+mkdir -p $a/vault $a/live/memory
+echo vault-version >$a/vault/shared.md
+echo vault-only >$a/vault/vaultonly.md
+echo live-version >$a/live/memory/shared.md
+echo live-only >$a/live/memory/liveonly.md
+_agents_repo_ensure_symlink $a/live/memory $a/vault >/dev/null
+check "adoption keeps vault copy" vault-version (cat $a/vault/shared.md)
+check "adoption imports live-only file" live-only (cat $a/vault/liveonly.md)
+check "adoption keeps vault-only file" vault-only (cat $a/vault/vaultonly.md)
+check "adoption replaced dir with link" true (test -L $a/live/memory; and echo true; or echo false)
+
+# Repins a link that points somewhere else.
+set -l p (mktemp -d); set -ga TMPDIRS $p
+mkdir -p $p/one $p/two
+ln -s $p/one $p/link
+_agents_repo_ensure_symlink $p/link $p/two >/dev/null
+check "repins a wrong link" (path resolve $p/two) (path resolve $p/link)
+
+# ─────────────────────────── sync policy ───────────────────────────────
+echo ""
+echo "== _agents_repo_sync =="
+
+set -l s (new_repo)
+echo hello >$s/a.md
+_agents_repo_sync $s "chore: test" >/dev/null
+check "commits new content" 1 (git -C $s rev-list --count HEAD)
+
+# Nothing to do: no second commit, no output.
+set -l out (_agents_repo_sync $s "chore: test")
+check "idempotent, no new commit" 1 (git -C $s rev-list --count HEAD)
+check "idempotent, silent" "" "$out"
+
+# A diverged upstream is no longer this function's business. It commits
+# locally and never fetches, so neither divergence nor an unreachable
+# remote may stop the commit -- that is the entire offline-backup
+# guarantee, and the old pull-first shape broke it: a failed fetch took
+# the local commit down with it. The divergence is still built here so
+# that guarantee is tested against the case that used to fail.
+set -l origin (mktemp -d); set -ga TMPDIRS $origin
+git -C $origin init -q --bare
+git -C $s remote add origin $origin
+git -C $s push -q -u origin HEAD:refs/heads/main 2>/dev/null
+
+set -l clone (mktemp -d); set -ga TMPDIRS $clone
+git clone -q $origin $clone
+git -C $clone config user.email t@t
+git -C $clone config user.name t
+git -C $clone config commit.gpgsign false
+git -C $clone config core.hooksPath /dev/null
+echo theirs >$clone/a.md
+git -C $clone commit -qam theirs
+git -C $clone push -q origin HEAD:main
+
+echo ours >$s/a.md
+set -l before_count (git -C $s rev-list --count HEAD)
+set -l rc (_agents_repo_sync $s "chore: test" >/dev/null 2>/dev/null; echo $status)
+check "diverged upstream still commits" 0 "$rc"
+check "diverged upstream recorded the commit" (math $before_count + 1) (git -C $s rev-list --count HEAD)
+check "diverged upstream kept our content" ours (cat $s/a.md)
+check "diverged upstream left no markers" false (grep -q '<<<<<<<' $s/a.md; and echo true; or echo false)
+check "diverged upstream started no rebase" false (test -d $s/.git/rebase-merge -o -d $s/.git/rebase-apply; and echo true; or echo false)
+
+# An unreachable remote is a non-event for the same reason. A bogus local
+# path is used rather than a real unroutable host: it fails instantly
+# instead of waiting out a DNS timeout, and the code path being asserted
+# is that there is no network code path at all.
+git -C $s remote set-url origin /nonexistent/unreachable.git
+echo offline >$s/b.md
+set -l ocount (git -C $s rev-list --count HEAD)
+set -l orc (_agents_repo_sync $s "chore: offline" >/dev/null 2>/dev/null; echo $status)
+check "unreachable upstream still commits" 0 "$orc"
+check "unreachable upstream recorded the commit" (math $ocount + 1) (git -C $s rev-list --count HEAD)
+check "unreachable upstream captured the new file" offline (git -C $s show HEAD:b.md 2>/dev/null)
+
+# The one case that must still refuse: a rebase genuinely in progress. The
+# worktree then holds conflict markers and committing them under a routine
+# message buries the conflict instead of reporting it. It is left standing
+# rather than aborted -- this function did not start it, so it is not its
+# to throw away.
+git -C $s remote set-url origin $origin
+git -C $s -c core.hooksPath=/dev/null pull --rebase -q >/dev/null 2>&1
+check "fixture really left a rebase in progress" true (test -d $s/.git/rebase-merge -o -d $s/.git/rebase-apply; and echo true; or echo false)
+set -l rrc (_agents_repo_sync $s "chore: blocked" 2>/dev/null; echo $status)
+check "in-progress rebase returns 2" 2 "$rrc"
+check "in-progress rebase recorded no commit" false (git -C $s log --all --pretty=%s 2>/dev/null | grep -qx 'chore: blocked'; and echo true; or echo false)
+check "in-progress rebase is left standing" true (test -d $s/.git/rebase-merge -o -d $s/.git/rebase-apply; and echo true; or echo false)
+git -C $s rebase --abort >/dev/null 2>&1
+
+# Commit-hook rejection: the commit call itself fails (e.g. a secret
+# scanner in a pre-commit hook), distinct from "not a git repository" --
+# both currently map to exit 1, so this must not fall through silently or
+# report success.
+set -l h (new_repo)
+echo first >$h/a.md
+_agents_repo_sync $h "chore: init" >/dev/null
+
+set -l hooks (mktemp -d); set -ga TMPDIRS $hooks
+printf '#!/bin/sh\nexit 1\n' >$hooks/pre-commit
+chmod +x $hooks/pre-commit
+git -C $h config core.hooksPath $hooks
+
+echo second >$h/a.md
+set -l before_hook_count (git -C $h rev-list --count HEAD)
+set -l hrc (_agents_repo_sync $h "chore: blocked" 2>/dev/null; echo $status)
+check "commit hook rejection returns 1" 1 "$hrc"
+check "commit hook rejection commits nothing" $before_hook_count (git -C $h rev-list --count HEAD)
+
+# ──────────────────────── vault scaffold + link ────────────────────────
+echo ""
+echo "== agents-vault (scaffold + link) =="
+
+set -l vroot (mktemp -d); set -ga TMPDIRS $vroot
+set -l croot (mktemp -d); set -ga TMPDIRS $croot
+set -g __fish_agent_vault_dir $vroot/agent-vault
+set -g __fish_agent_vault_claude_root $croot
+
+set -l proj (new_repo https://git.rootiest.dev/rootiest/fish-config.git)
+set -l pslug git.rootiest.dev-rootiest-fish-config
+
+# Seed a live memory directory the way Claude would.
+set -l mangled (string replace -a '/' '-' -- $proj | string replace -a '.' '-')
+mkdir -p $croot/$mangled/memory
+echo "a memory" >$croot/$mangled/memory/thing.md
+
+pushd $proj >/dev/null
+agents-vault --silent
+popd >/dev/null
+
+check "vault repo created" true (test -d $vroot/agent-vault/.git; and echo true; or echo false)
+check "vault version seeded" 1.0.0 (cat $vroot/agent-vault/.version 2>/dev/null)
+check "entry created for slug" true (test -d $vroot/agent-vault/projects/$pslug/claude/memory; and echo true; or echo false)
+check "live memory is now a link" true (test -L $croot/$mangled/memory; and echo true; or echo false)
+check "memory content preserved" "a memory" (cat $croot/$mangled/memory/thing.md)
+check "content lives in the vault" "a memory" (cat $vroot/agent-vault/projects/$pslug/claude/memory/thing.md)
+check "origin file written" true (test -f $vroot/agent-vault/projects/$pslug/origin; and echo true; or echo false)
+check "vault committed" true (test (git -C $vroot/agent-vault rev-list --count HEAD) -ge 1; and echo true; or echo false)
+
+# Idempotence: a second run prints nothing and adds no commit.
+set -l before (git -C $vroot/agent-vault rev-list --count HEAD)
+pushd $proj >/dev/null
+set -l again (agents-vault --verbose)
+popd >/dev/null
+check "second run is silent" "" "$again"
+check "second run adds no commit" $before (git -C $vroot/agent-vault rev-list --count HEAD)
+
+set -e __fish_agent_vault_dir
+set -e __fish_agent_vault_claude_root
+
+# ─────────────────── emergent restore (vault -> live) ──────────────────
+# The reverse of the adoption case above: a vault entry already has curated
+# memory (as if cloned from a remote onto a fresh machine) but the live
+# Claude project directory does not exist at all yet. agents-vault must
+# still create the link so the pre-existing memory is what a starting
+# agent sees, rather than silently skipping the link because there was
+# nothing local to notice yet.
+echo ""
+echo "== agents-vault (emergent restore) =="
+
+set -l vroot2 (mktemp -d); set -ga TMPDIRS $vroot2
+set -l croot2 (mktemp -d); set -ga TMPDIRS $croot2
+set -g __fish_agent_vault_dir $vroot2/agent-vault
+set -g __fish_agent_vault_claude_root $croot2
+
+set -l proj2 (new_repo https://git.rootiest.dev/rootiest/agent-vault.git)
+set -l pslug2 git.rootiest.dev-rootiest-agent-vault
+set -l mangled2 (string replace -a '/' '-' -- $proj2 | string replace -a '.' '-')
+
+# Pre-seed the vault entry the way a cloned vault would already have it.
+# Deliberately no $croot2/$mangled2 directory at all -- not even the
+# project's own entry, let alone a memory/ subdirectory -- so the fix under
+# test is exercised: linking must not depend on the live side existing.
+mkdir -p $vroot2/agent-vault/projects/$pslug2/claude/memory
+echo "restored memory" >$vroot2/agent-vault/projects/$pslug2/claude/memory/old.md
+
+pushd $proj2 >/dev/null
+agents-vault --silent
+popd >/dev/null
+
+check "restore: live memory link created with no prior live dir" true (test -L $croot2/$mangled2/memory; and echo true; or echo false)
+check "restore: pre-existing vault content readable through the link" "restored memory" (cat $croot2/$mangled2/memory/old.md 2>/dev/null)
+
+set -e __fish_agent_vault_dir
+set -e __fish_agent_vault_claude_root
+
+# ─────────────────── a refused link is reported as failure ─────────────
+# _agents_repo_ensure_symlink refuses (exit 1, no stdout) when it cannot
+# make the link -- e.g. its mkdir -p of the link's parent directory fails.
+# agents-vault must surface that as its own exit 1, not swallow it and
+# report success because stdout happened to be empty either way.
+echo ""
+echo "== agents-vault (link failure surfaces as exit 1) =="
+
+set -l vroot3 (mktemp -d); set -ga TMPDIRS $vroot3
+set -l croot3 (mktemp -d); set -ga TMPDIRS $croot3
+set -g __fish_agent_vault_dir $vroot3/agent-vault
+set -g __fish_agent_vault_claude_root $croot3
+
+set -l proj3 (new_repo https://git.rootiest.dev/rootiest/link-fail-test.git)
+set -l mangled3 (string replace -a '/' '-' -- $proj3 | string replace -a '.' '-')
+
+# Make the mangled project path a plain FILE. _agents_repo_ensure_symlink's
+# own `mkdir -p (path dirname $link)` then fails outright (ENOTDIR), which
+# is exactly the class of failure (permissions, ENOSPC, ...) this guards.
+touch $croot3/$mangled3
+
+pushd $proj3 >/dev/null
+set -l rc (agents-vault --silent 2>/dev/null; echo $status)
+popd >/dev/null
+
+check "link failure: agents-vault exits 1" 1 "$rc"
+check "link failure: no link was left behind" false (test -L $croot3/$mangled3/memory; and echo true; or echo false)
+
+set -e __fish_agent_vault_dir
+set -e __fish_agent_vault_claude_root
+
+# ────────────────────────── slug migration ─────────────────────────────
+echo ""
+echo "== agents-vault (slug migration) =="
+
+set -l vroot2 (mktemp -d); set -ga TMPDIRS $vroot2
+set -l croot2 (mktemp -d); set -ga TMPDIRS $croot2
+set -g __fish_agent_vault_dir $vroot2/agent-vault
+set -g __fish_agent_vault_claude_root $croot2
+
+# Start with no remote, so the project is keyed local-*.
+set -l mp (new_repo)
+set -l mmangled (string replace -a '/' '-' -- $mp | string replace -a '.' '-')
+mkdir -p $croot2/$mmangled/memory
+echo "precious" >$croot2/$mmangled/memory/keep.md
+
+pushd $mp >/dev/null
+agents-vault --silent
+set -l local_slug (_agents_repo_slug $mp)
+popd >/dev/null
+check "local entry populated" precious (cat $vroot2/agent-vault/projects/$local_slug/claude/memory/keep.md)
+
+# Now add a remote: the slug changes and the entry must migrate.
+git -C $mp remote add origin https://git.rootiest.dev/rootiest/later.git
+pushd $mp >/dev/null
+agents-vault --silent
+popd >/dev/null
+set -l new_slug git.rootiest.dev-rootiest-later
+
+check "migrated to remote slug" precious (cat $vroot2/agent-vault/projects/$new_slug/claude/memory/keep.md)
+check "old entry removed" false (test -d $vroot2/agent-vault/projects/$local_slug; and echo true; or echo false)
+check "memory still reachable live" precious (cat $croot2/$mmangled/memory/keep.md)
+check "link repinned to new entry" (path resolve $vroot2/agent-vault/projects/$new_slug/claude/memory) (path resolve $croot2/$mmangled/memory)
+check "rename recorded in origin" true (grep -q "$local_slug" $vroot2/agent-vault/projects/$new_slug/origin; and echo true; or echo false)
+
+# Ambiguous migration: both entries hold content. Nothing may move.
+set -l amb (new_repo)
+set -l amangled (string replace -a '/' '-' -- $amb | string replace -a '.' '-')
+mkdir -p $croot2/$amangled/memory
+echo old >$croot2/$amangled/memory/x.md
+pushd $amb >/dev/null
+agents-vault --silent
+set -l aslug (_agents_repo_slug $amb)
+popd >/dev/null
+
+git -C $amb remote add origin https://git.rootiest.dev/rootiest/clash.git
+mkdir -p $vroot2/agent-vault/projects/git.rootiest.dev-rootiest-clash/claude/memory
+echo new >$vroot2/agent-vault/projects/git.rootiest.dev-rootiest-clash/claude/memory/y.md
+
+pushd $amb >/dev/null
+set -l arc (agents-vault --silent 2>/dev/null; echo $status)
+popd >/dev/null
+check "ambiguous migration fails" 1 "$arc"
+check "ambiguous leaves old entry" old (cat $vroot2/agent-vault/projects/$aslug/claude/memory/x.md)
+check "ambiguous leaves new entry" new (cat $vroot2/agent-vault/projects/git.rootiest.dev-rootiest-clash/claude/memory/y.md)
+
+# Case 2 of the three required cases: the *current* (new-slug) entry is
+# present but empty -- neither absent (handled above) nor holding content
+# (the ambiguous case above). Migration must still proceed.
+set -l emp (new_repo)
+set -l emangled (string replace -a '/' '-' -- $emp | string replace -a '.' '-')
+mkdir -p $croot2/$emangled/memory
+echo precious2 >$croot2/$emangled/memory/keep.md
+pushd $emp >/dev/null
+agents-vault --silent
+set -l eslug (_agents_repo_slug $emp)
+popd >/dev/null
+
+git -C $emp remote add origin https://git.rootiest.dev/rootiest/emptycase.git
+set -l enew_slug git.rootiest.dev-rootiest-emptycase
+# Built the way git itself would leave it, which is the only shape that
+# matters here: git cannot track an empty directory, so an entry committed
+# while its memory was empty comes back from a clone as projects//
+# origin and nothing else -- no claude/ subtree at all. Hand-building it
+# with claude/memory/ instead (as this fixture used to) tests a shape the
+# recovery path never produces, and hid a migration that moved the old
+# entry *inside* the new one while still returning 0. The equivalent
+# hand-built shape is covered separately just below.
+mkdir -p $vroot2/agent-vault/projects/$enew_slug
+printf 'remote: %s\npath: %s\nhost: %s\n' \
+ https://git.rootiest.dev/rootiest/emptycase.git /gone/elsewhere othermachine \
+ >$vroot2/agent-vault/projects/$enew_slug/origin
+
+pushd $emp >/dev/null
+set -l erc (agents-vault --silent 2>/dev/null; echo $status)
+popd >/dev/null
+check "empty-current migration succeeds" 0 "$erc"
+check "empty-current migrated content" precious2 (cat $vroot2/agent-vault/projects/$enew_slug/claude/memory/keep.md)
+check "empty-current old entry removed" false (test -d $vroot2/agent-vault/projects/$eslug; and echo true; or echo false)
+check "empty-current did not nest the old entry" false (test -d $vroot2/agent-vault/projects/$enew_slug/$eslug; and echo true; or echo false)
+check "empty-current memory reachable live" precious2 (cat $croot2/$emangled/memory/keep.md)
+# The destination's origin log is real provenance -- a clone always has
+# one -- so it is folded in rather than deleted along with the directory.
+check "empty-current kept the destination provenance" true (grep -q othermachine $vroot2/agent-vault/projects/$enew_slug/origin; and echo true; or echo false)
+
+# The other "present but empty" shape, for completeness: claude/memory/
+# exists and is empty. Only a hand-built vault looks like this, but the
+# guard has to cover it too.
+set -l em2 (new_repo)
+set -l em2mangled (string replace -a '/' '-' -- $em2 | string replace -a '.' '-')
+mkdir -p $croot2/$em2mangled/memory
+echo precious3 >$croot2/$em2mangled/memory/keep.md
+pushd $em2 >/dev/null
+agents-vault --silent
+set -l em2slug (_agents_repo_slug $em2)
+popd >/dev/null
+
+git -C $em2 remote add origin https://git.rootiest.dev/rootiest/emptydir.git
+set -l em2new git.rootiest.dev-rootiest-emptydir
+mkdir -p $vroot2/agent-vault/projects/$em2new/claude/memory
+
+pushd $em2 >/dev/null
+set -l em2rc (agents-vault --silent 2>/dev/null; echo $status)
+popd >/dev/null
+check "empty-memory-dir migration succeeds" 0 "$em2rc"
+check "empty-memory-dir migrated content" precious3 (cat $vroot2/agent-vault/projects/$em2new/claude/memory/keep.md)
+check "empty-memory-dir did not nest the old entry" false (test -d $vroot2/agent-vault/projects/$em2new/$em2slug; and echo true; or echo false)
+check "empty-memory-dir memory reachable live" precious3 (cat $croot2/$em2mangled/memory/keep.md)
+
+# Remote-URL-rewrite transition: origin changes from one forge URL to
+# another (distinct from adding a remote where none existed).
+set -l rw (new_repo https://git.rootiest.dev/rootiest/rewrite-old.git)
+set -l rwmangled (string replace -a '/' '-' -- $rw | string replace -a '.' '-')
+mkdir -p $croot2/$rwmangled/memory
+echo rewrite-precious >$croot2/$rwmangled/memory/keep.md
+pushd $rw >/dev/null
+agents-vault --silent
+popd >/dev/null
+set -l rw_old_slug git.rootiest.dev-rootiest-rewrite-old
+check "rewrite: old entry populated" rewrite-precious (cat $vroot2/agent-vault/projects/$rw_old_slug/claude/memory/keep.md)
+
+git -C $rw remote set-url origin https://git.rootiest.dev/rootiest/rewrite-new.git
+pushd $rw >/dev/null
+agents-vault --silent
+popd >/dev/null
+set -l rw_new_slug git.rootiest.dev-rootiest-rewrite-new
+check "rewrite: migrated to new remote slug" rewrite-precious (cat $vroot2/agent-vault/projects/$rw_new_slug/claude/memory/keep.md)
+check "rewrite: old entry removed" false (test -d $vroot2/agent-vault/projects/$rw_old_slug; and echo true; or echo false)
+
+# Remote-removal transition: origin removed, slug reverts to local-*.
+set -l rmv (new_repo https://git.rootiest.dev/rootiest/removeme.git)
+set -l rmvmangled (string replace -a '/' '-' -- $rmv | string replace -a '.' '-')
+mkdir -p $croot2/$rmvmangled/memory
+echo removal-precious >$croot2/$rmvmangled/memory/keep.md
+pushd $rmv >/dev/null
+agents-vault --silent
+popd >/dev/null
+set -l rmv_remote_slug git.rootiest.dev-rootiest-removeme
+check "removal: remote entry populated" removal-precious (cat $vroot2/agent-vault/projects/$rmv_remote_slug/claude/memory/keep.md)
+
+git -C $rmv remote remove origin
+pushd $rmv >/dev/null
+agents-vault --silent
+set -l rmv_local_slug (_agents_repo_slug $rmv)
+popd >/dev/null
+check "removal: migrated to local slug" removal-precious (cat $vroot2/agent-vault/projects/$rmv_local_slug/claude/memory/keep.md)
+check "removal: old remote entry removed" false (test -d $vroot2/agent-vault/projects/$rmv_remote_slug; and echo true; or echo false)
+
+# Fallback-candidate sanitization: a dirty basename (space, !) must produce
+# the same local-* slug that _agents_repo_slug would derive, so that a
+# lost-symlink recovery (no live link, but the vault entry survives) can
+# still find and adopt it. Before the fix, the fallback only lowercased
+# the basename instead of sanitizing it like _agents_repo_slug does, so it
+# could never match the real entry directory for a name like this.
+set -l dirty_root (mktemp -d); set -ga TMPDIRS $dirty_root
+set -l dp "$dirty_root/My Project!"
+mkdir -p "$dp"
+git -C "$dp" init -q
+git -C "$dp" config user.email t@t
+git -C "$dp" config user.name t
+git -C "$dp" config commit.gpgsign false
+git -C "$dp" config core.hooksPath /dev/null
+
+set -l dmangled (string replace -a '/' '-' -- $dp | string replace -a '.' '-')
+mkdir -p $croot2/$dmangled/memory
+echo "dirty-precious" >$croot2/$dmangled/memory/keep.md
+
+pushd $dp >/dev/null
+agents-vault --silent
+set -l dirty_local_slug (_agents_repo_slug $dp)
+popd >/dev/null
+check "dirty local entry populated" dirty-precious (cat $vroot2/agent-vault/projects/$dirty_local_slug/claude/memory/keep.md)
+
+# Lose the live symlink, as a fresh-machine restore would, so migration
+# must fall back to recomputing the candidate from the path instead of
+# reading it off the (now-absent) live symlink.
+rm -f $croot2/$dmangled/memory
+
+git -C $dp remote add origin https://git.rootiest.dev/rootiest/dirty.git
+pushd $dp >/dev/null
+agents-vault --silent
+popd >/dev/null
+set -l dirty_new_slug git.rootiest.dev-rootiest-dirty
+check "fallback finds sanitized local entry" dirty-precious (cat $vroot2/agent-vault/projects/$dirty_new_slug/claude/memory/keep.md)
+check "fallback old entry removed" false (test -d $vroot2/agent-vault/projects/$dirty_local_slug; and echo true; or echo false)
+
+# Same fallback path, but the live memory directory is a real populated
+# directory rather than a symlink -- the shape a machine ends up in when
+# an agent wrote memory while the link was missing. The migration then
+# tries to clear the live path out of the relink's way, and clearing a
+# directory is not something it may do: that is somebody's memory, and
+# _agents_repo_ensure_symlink already folds it into the vault without
+# clobbering. Refusing is therefore correct, but refusing in rm's voice
+# is not: a --silent run that did the right thing and returned 0 still
+# printed "rm: cannot remove ...: Is a directory", which is the only
+# thing the user sees and reads as a failure.
+set -l real_root (mktemp -d); set -ga TMPDIRS $real_root
+set -l rp "$real_root/proj"
+mkdir -p "$rp"
+git -C "$rp" init -q
+git -C "$rp" config user.email t@t
+git -C "$rp" config user.name t
+git -C "$rp" config commit.gpgsign false
+git -C "$rp" config core.hooksPath /dev/null
+
+set -l rmangled (string replace -a '/' '-' -- $rp | string replace -a '.' '-')
+mkdir -p $croot2/$rmangled/memory
+echo "banked" >$croot2/$rmangled/memory/old.md
+
+pushd $rp >/dev/null
+agents-vault --silent
+set -l real_local_slug (_agents_repo_slug $rp)
+popd >/dev/null
+
+# Replace the link with a real directory holding memory the vault has
+# never seen, then change the slug so the migration runs.
+rm -f $croot2/$rmangled/memory
+mkdir -p $croot2/$rmangled/memory
+echo "written-live" >$croot2/$rmangled/memory/fresh.md
+git -C $rp remote add origin https://git.rootiest.dev/rootiest/realdir.git
+
+set -l rerr (mktemp); set -ga TMPDIRS $rerr
+pushd $rp >/dev/null
+set -l real_rc (agents-vault --silent 2>$rerr; echo $status)
+popd >/dev/null
+set -l real_err (cat $rerr)
+set -l real_new_slug git.rootiest.dev-rootiest-realdir
+check "real-directory migration succeeds" 0 "$real_rc"
+check "real-directory migration stays silent" "" "$real_err"
+check "real-directory migration keeps banked memory" banked (cat $vroot2/agent-vault/projects/$real_new_slug/claude/memory/old.md 2>/dev/null)
+check "real-directory migration keeps live memory" written-live (cat $vroot2/agent-vault/projects/$real_new_slug/claude/memory/fresh.md 2>/dev/null)
+check "real-directory migration relinks" true (test -L $croot2/$rmangled/memory; and echo true; or echo false)
+check "real-directory old entry removed" false (test -d $vroot2/agent-vault/projects/$real_local_slug; and echo true; or echo false)
+
+set -e __fish_agent_vault_dir
+set -e __fish_agent_vault_claude_root
+
+# ───────────────────────── a real git clone ────────────────────────────
+# Every other fixture in this file is hand-built, and a hand-built
+# directory can have a shape git itself would never produce. That blind
+# spot has now shipped two bugs. So this section builds its vault the way
+# the feature's own advertised recovery path does -- run the tool, let it
+# commit, then clone the result with git -- and runs agents-vault against
+# the clone.
+echo ""
+echo "== agents-vault (a real git clone) =="
+
+# Machine A: populate a vault and let agents-vault commit it.
+set -l cl_vroot (mktemp -d); set -ga TMPDIRS $cl_vroot
+set -l cl_croot (mktemp -d); set -ga TMPDIRS $cl_croot
+set -g __fish_agent_vault_dir $cl_vroot/agent-vault
+set -g __fish_agent_vault_claude_root $cl_croot
+
+# Two projects: one whose memory holds a file at commit time, one whose
+# memory is empty. The empty one is the interesting case -- git cannot
+# track an empty directory, so its entry survives the clone as origin and
+# nothing else.
+set -l cl_full (new_repo https://git.rootiest.dev/rootiest/clone-full.git)
+set -l cl_full_slug git.rootiest.dev-rootiest-clone-full
+set -l cl_full_mangled (string replace -a '/' '-' -- $cl_full | string replace -a '.' '-')
+mkdir -p $cl_croot/$cl_full_mangled/memory
+echo cloned-memory >$cl_croot/$cl_full_mangled/memory/keep.md
+pushd $cl_full >/dev/null
+agents-vault --silent
+popd >/dev/null
+
+set -l cl_empty (new_repo https://git.rootiest.dev/rootiest/clone-empty.git)
+set -l cl_empty_slug git.rootiest.dev-rootiest-clone-empty
+pushd $cl_empty >/dev/null
+agents-vault --silent
+popd >/dev/null
+
+# The clone, exactly as the README tells a user to make it.
+set -l cl_new (mktemp -d); set -ga TMPDIRS $cl_new
+git clone -q $cl_vroot/agent-vault $cl_new/agent-vault
+git -C $cl_new/agent-vault config user.email t@t
+git -C $cl_new/agent-vault config user.name t
+git -C $cl_new/agent-vault config commit.gpgsign false
+
+check "clone: the populated entry came back whole" cloned-memory (cat $cl_new/agent-vault/projects/$cl_full_slug/claude/memory/keep.md 2>/dev/null)
+check "clone: the empty entry has no claude/ subtree" false (test -d $cl_new/agent-vault/projects/$cl_empty_slug/claude; and echo true; or echo false)
+check "clone: the empty entry is its origin file alone" true (test -f $cl_new/agent-vault/projects/$cl_empty_slug/origin; and echo true; or echo false)
+
+# Machine B, case 1: an ordinary run against the clone restores memory.
+set -l cl_croot2 (mktemp -d); set -ga TMPDIRS $cl_croot2
+set -g __fish_agent_vault_dir $cl_new/agent-vault
+set -g __fish_agent_vault_claude_root $cl_croot2
+
+set -l cl_proj (new_repo https://git.rootiest.dev/rootiest/clone-full.git)
+set -l cl_proj_mangled (string replace -a '/' '-' -- $cl_proj | string replace -a '.' '-')
+pushd $cl_proj >/dev/null
+set -l cl_rc (agents-vault --silent 2>/dev/null; echo $status)
+popd >/dev/null
+check "clone: an ordinary run against the clone returns 0" 0 "$cl_rc"
+check "clone: the live memory became a link" true (test -L $cl_croot2/$cl_proj_mangled/memory; and echo true; or echo false)
+check "clone: memory is reachable through the live link" cloned-memory (cat $cl_croot2/$cl_proj_mangled/memory/keep.md 2>/dev/null)
+
+# Machine B, case 2: migrating onto the clone-shaped entry. The project
+# starts with no remote (keyed local-*); adding the remote the clone's
+# empty entry belongs to points the migration straight at the origin-only
+# directory git produced. This is the case that used to move the old entry
+# *inside* the new one, fabricate a fresh empty memory directory over it,
+# pin the live link to that, and return 0 -- stranding the real memory
+# one level below where --status and --restore ever look.
+set -l cl_mig (new_repo)
+set -l cl_mig_mangled (string replace -a '/' '-' -- $cl_mig | string replace -a '.' '-')
+mkdir -p $cl_croot2/$cl_mig_mangled/memory
+echo clone-precious >$cl_croot2/$cl_mig_mangled/memory/keep.md
+pushd $cl_mig >/dev/null
+agents-vault --silent
+set -l cl_mig_slug (_agents_repo_slug $cl_mig)
+popd >/dev/null
+check "clone: the local entry was populated first" clone-precious (cat $cl_new/agent-vault/projects/$cl_mig_slug/claude/memory/keep.md 2>/dev/null)
+
+git -C $cl_mig remote add origin https://git.rootiest.dev/rootiest/clone-empty.git
+pushd $cl_mig >/dev/null
+set -l cl_mrc (agents-vault --silent 2>/dev/null; echo $status)
+popd >/dev/null
+check "clone: migration onto a cloned entry returns 0" 0 "$cl_mrc"
+check "clone: migrated memory is reachable through the live link" clone-precious (cat $cl_croot2/$cl_mig_mangled/memory/keep.md 2>/dev/null)
+check "clone: migrated memory landed in the new entry" clone-precious (cat $cl_new/agent-vault/projects/$cl_empty_slug/claude/memory/keep.md 2>/dev/null)
+check "clone: the old entry was not nested inside the new one" false (test -d $cl_new/agent-vault/projects/$cl_empty_slug/$cl_mig_slug; and echo true; or echo false)
+check "clone: the old entry is gone" false (test -d $cl_new/agent-vault/projects/$cl_mig_slug; and echo true; or echo false)
+check "clone: the cloned entry's provenance survived" true (grep -q clone-empty.git $cl_new/agent-vault/projects/$cl_empty_slug/origin; and echo true; or echo false)
+check "clone: the live link points at the migrated entry" (path resolve $cl_new/agent-vault/projects/$cl_empty_slug/claude/memory) (path resolve $cl_croot2/$cl_mig_mangled/memory)
+
+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
+
+# The allowlist has to hold *inside* knowledge/ too, not only at the agy
+# root. These are the same hostile shapes planted above, one level down --
+# where a recursive copy of the directory took them verbatim into a commit
+# while the documentation promised nothing new upstream added could leak.
+mkdir -p $agy5/knowledge/notes $agy5/knowledge/.hidden
+echo nested >$agy5/knowledge/notes/deep.md
+echo '{"k":"v"}' >$agy5/knowledge/meta.json
+echo SECRET-INSIDE-KNOWLEDGE >$agy5/knowledge/.credentials.json
+echo SECRET-INSIDE-KNOWLEDGE >$agy5/knowledge/.hidden/leak.json
+echo SECRET-INSIDE-KNOWLEDGE >$agy5/knowledge/history.jsonl
+printf 'transcript\n' >$agy5/knowledge/session.jsonl
+: >$agy5/knowledge/knowledge.lock
+: >$agy5/knowledge/conversations.db
+: >$agy5/knowledge/conversations.db-wal
+: >$agy5/knowledge/conversations.db-shm
+
+# Symlinks inside the store. The extension allowlist bounds what *kind* of
+# file is collected; it says nothing about whose. A store-relative walk
+# that dereferenced links would pull qualifying .md and .json out of
+# whatever the link names -- a real home directory is full of them -- so
+# the store boundary has to hold on its own. $outside5 stands in for that
+# home: a directory the store has no business reaching into, planted with
+# exactly the shapes that qualify.
+set -l outside5 (mktemp -d); set -ga TMPDIRS $outside5
+mkdir -p $outside5/nested
+echo SECRET-OUTSIDE-KNOWLEDGE >$outside5/leaked.json
+echo SECRET-OUTSIDE-KNOWLEDGE >$outside5/target.md
+echo SECRET-OUTSIDE-KNOWLEDGE >$outside5/nested/deep.md
+ln -s $outside5 $agy5/knowledge/linked
+ln -s $outside5/target.md $agy5/knowledge/alias.md
+# Not a cycle, so a recursive glob's cycle guard does not catch it: a link
+# to the filesystem root simply makes the walk enormous. This one is here
+# for the clock as much as for the contents -- the copy runs synchronously
+# in front of every agent launch, and a `**` glob over this fixture did
+# not return within 20s.
+ln -s / $agy5/knowledge/root
+# A genuine cycle too, since the walk must not depend on the glob's guard.
+mkdir -p $agy5/knowledge/cyc
+ln -s $agy5/knowledge $agy5/knowledge/cyc/loop
+
+# 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)
+set -l t5_start (date +%s)
+pushd $gp >/dev/null
+agents-vault --silent
+popd >/dev/null
+set -l t5_elapsed (math (date +%s) - $t5_start)
+
+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)
+
+# Inside knowledge/: the notes come through, everything else stays out.
+check "knowledge: nested markdown copied" nested (cat $vroot5/agent-vault/global/agy/knowledge/notes/deep.md 2>/dev/null)
+check "knowledge: json metadata copied" '{"k":"v"}' (cat $vroot5/agent-vault/global/agy/knowledge/meta.json 2>/dev/null)
+for decoy in .credentials.json .hidden history.jsonl session.jsonl knowledge.lock conversations.db conversations.db-wal conversations.db-shm
+ check "knowledge: $decoy stayed out" false (test -e $vroot5/agent-vault/global/agy/knowledge/$decoy; and echo true; or echo false)
+end
+# Not merely absent from the worktree: absent from the history, which is
+# what actually leaves the machine on a push.
+check "knowledge: no secret reached a commit" false (git -C $vroot5/agent-vault grep -q SECRET-INSIDE-KNOWLEDGE HEAD -- global 2>/dev/null; and echo true; or echo false)
+
+# Symlinks: nothing the links name may appear, under any name. The linked
+# directory must not exist in the vault at all (following it would recreate
+# its tree wholesale), and the aliased file must not exist either, even
+# though its own name qualifies -- a dereferencing copy writes a real file
+# at the link's name and the extension rule waves it through.
+for escapee in linked linked/leaked.json linked/nested/deep.md alias.md root cyc/loop
+ check "knowledge: symlinked $escapee stayed out" false (test -e $vroot5/agent-vault/global/agy/knowledge/$escapee; and echo true; or echo false)
+end
+check "knowledge: nothing outside the store reached a commit" false (git -C $vroot5/agent-vault grep -q SECRET-OUTSIDE-KNOWLEDGE HEAD 2>/dev/null; and echo true; or echo false)
+# The clock, not the contents: a walk that descends a link to / does not
+# finish, and this is the every-launch path. Generous enough that a loaded
+# machine cannot fail it by being slow.
+check "knowledge: a link to / does not stall the launch path" true (test $t5_elapsed -lt 20; and echo true; or echo false)
+# A torn database with its completing write-ahead log deliberately excluded
+# is worse than no database at all, so the scaffold ignores all three.
+check "scaffolded .gitignore excludes *.db" true (grep -qxF '*.db' $vroot5/agent-vault/.gitignore; and echo true; or echo false)
+# Both stash fallbacks, or a crash mid-rename leaves a shadow copy of an
+# entry sitting at the vault root for the next `git add -A` to commit.
+check "scaffolded .gitignore excludes /.adopt-stash" true (grep -qxF '/.adopt-stash' $vroot5/agent-vault/.gitignore; and echo true; or echo false)
+check "scaffolded .gitignore excludes /.migrate-stash" true (grep -qxF '/.migrate-stash' $vroot5/agent-vault/.gitignore; 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
+
+# ────────────── a failed global link must not abort the run ────────────
+# The global block runs before the per-project link and before the commit,
+# and global memory is optional and frequently absent. A fault there must
+# never take the primary per-project backup down with it: otherwise a
+# stray file or a permission problem at ~/.claude/memory would break
+# memory backup for every project, on every agent launch.
+echo ""
+echo "== agents-vault (global link failure is non-fatal) =="
+
+set -l vroot8 (mktemp -d); set -ga TMPDIRS $vroot8
+set -l croot8 (mktemp -d); set -ga TMPDIRS $croot8
+set -l chome8 (mktemp -d); set -ga TMPDIRS $chome8
+set -l agy8 (mktemp -d); set -ga TMPDIRS $agy8
+set -g __fish_agent_vault_dir $vroot8/agent-vault
+set -g __fish_agent_vault_claude_root $croot8
+set -g __fish_agent_vault_claude_home $chome8
+set -g __fish_agent_vault_agy_root $agy8
+
+# A regular FILE where the global memory directory belongs.
+# _agents_repo_ensure_symlink refuses to replace a non-directory, so the
+# global link cannot succeed here.
+echo not-a-directory >$chome8/memory
+
+# ... while this project has perfectly good memory waiting to be backed up.
+set -l fp (new_repo https://git.rootiest.dev/rootiest/globalfail.git)
+set -l fslug git.rootiest.dev-rootiest-globalfail
+set -l fmangled (string replace -a '/' '-' -- $fp | string replace -a '.' '-')
+mkdir -p $croot8/$fmangled/memory
+echo project-memory >$croot8/$fmangled/memory/p.md
+
+set -l ferr (mktemp); set -ga TMPDIRS $ferr
+pushd $fp >/dev/null
+set -l frc (agents-vault --silent 2>$ferr; echo $status)
+popd >/dev/null
+set -l fwarn (cat $ferr)
+
+check "global link failure: exits 0" 0 "$frc"
+check "global link failure: warns on stderr" true (string match -q "*$chome8/memory*" -- "$fwarn"; and echo true; or echo false)
+check "global link failure: live file left alone" not-a-directory (cat $chome8/memory)
+
+# The finding itself: the primary, per-project backup must still happen.
+check "global link failure: per-project link still created" true (test -L $croot8/$fmangled/memory; and echo true; or echo false)
+check "global link failure: per-project memory reached the vault" project-memory (cat $vroot8/agent-vault/projects/$fslug/claude/memory/p.md)
+check "global link failure: vault still committed" true (test (git -C $vroot8/agent-vault rev-list --count HEAD) -ge 1; and echo true; or echo false)
+check "global link failure: per-project memory is committed" true (git -C $vroot8/agent-vault ls-files --error-unmatch projects/$fslug/claude/memory/p.md >/dev/null 2>&1; and echo true; or echo false)
+
+# A failed global link must not be recorded as done: the next run has to
+# re-enter the block and warn again, not treat the vault as correct.
+pushd $fp >/dev/null
+set -l ferr2 (mktemp); set -ga TMPDIRS $ferr2
+agents-vault --silent 2>$ferr2
+popd >/dev/null
+check "global link failure: retried on the next run" true (string match -q "*$chome8/memory*" -- (cat $ferr2); 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
+
+# ──────────────────── status / adopt / remote / push ───────────────────
+# The four report-and-rebind modes. --status is a *report*: the checks
+# below pin that it never mutates, because it is dispatched ahead of the
+# scaffold rather than behind it.
+echo ""
+echo "== agents-vault (status, remote, adopt) =="
+
+set -l vroot9 (mktemp -d); set -ga TMPDIRS $vroot9
+set -l croot9 (mktemp -d); set -ga TMPDIRS $croot9
+set -l chome9 (mktemp -d); set -ga TMPDIRS $chome9
+set -l agy9 (mktemp -d); set -ga TMPDIRS $agy9
+set -g __fish_agent_vault_dir $vroot9/agent-vault
+set -g __fish_agent_vault_claude_root $croot9
+set -g __fish_agent_vault_claude_home $chome9
+set -g __fish_agent_vault_agy_root $agy9
+
+# A report asked for before the vault exists must say so, not scaffold one.
+set -l s0out (mktemp); set -ga TMPDIRS $s0out
+set -l s0rc (agents-vault --status >$s0out; echo $status)
+check "status without a vault exits 0" 0 "$s0rc"
+check "status without a vault says so" true (string match -q '*no vault*' -- (cat $s0out); and echo true; or echo false)
+check "status without a vault scaffolds nothing" false (test -e $vroot9/agent-vault; and echo true; or echo false)
+
+set -l sp (new_repo https://git.rootiest.dev/rootiest/statusrepo.git)
+set -l smangled (string replace -a '/' '-' -- $sp | string replace -a '.' '-')
+mkdir -p $croot9/$smangled/memory
+echo m >$croot9/$smangled/memory/m.md
+pushd $sp >/dev/null
+agents-vault --silent
+set -l report (agents-vault --status)
+popd >/dev/null
+
+check "status names the slug" true (string match -q '*git.rootiest.dev-rootiest-statusrepo*' -- "$report"; and echo true; or echo false)
+check "status reports no remote" true (string match -q '*no remote*' -- "$report"; and echo true; or echo false)
+check "status reports the link as healthy" true (string match -q '*linked*' -- "$report"; and echo true; or echo false)
+
+# --status must not mutate. Global state that a default run *would* sync is
+# staged here and must still be untouched afterwards: a report that first
+# copies the agy knowledge store and claims ~/.claude/memory is not a
+# report. This is what dispatching --status ahead of the scaffold buys.
+mkdir -p $agy9/knowledge
+echo learned >$agy9/knowledge/fact.md
+mkdir -p $chome9/memory
+echo global >$chome9/memory/g.md
+set -l head_before (git -C $vroot9/agent-vault rev-list --count HEAD)
+set -l porcelain_before (git -C $vroot9/agent-vault status --porcelain | string join ',')
+agents-vault --status >/dev/null
+check "status did not copy agy state" false (test -e $vroot9/agent-vault/global/agy; and echo true; or echo false)
+check "status did not claim the global memory path" false (test -L $chome9/memory; and echo true; or echo false)
+check "status made no commit" "$head_before" (git -C $vroot9/agent-vault rev-list --count HEAD)
+check "status left the vault worktree as it found it" "$porcelain_before" (git -C $vroot9/agent-vault status --porcelain | string join ',')
+
+# An entry no live project links to is surfaced as an orphan.
+mkdir -p $vroot9/agent-vault/projects/ghost-entry/claude/memory
+echo x >$vroot9/agent-vault/projects/ghost-entry/claude/memory/x.md
+set -l oreport (agents-vault --status)
+check "status lists the orphan" true (string match -q '*orphan*ghost-entry*' -- "$oreport"; and echo true; or echo false)
+rm -rf $vroot9/agent-vault/projects/ghost-entry
+
+# --remote sets origin on the vault.
+agents-vault --remote=https://git.rootiest.dev/rootiest/agent-vault.git --silent
+check "remote set" https://git.rootiest.dev/rootiest/agent-vault.git (git -C $vroot9/agent-vault remote get-url origin)
+check "status reports the remote" true (string match -q '*rootiest/agent-vault.git*' -- (agents-vault --status); and echo true; or echo false)
+
+# A *failed* remote update must return non-zero. Reporting success after a
+# git command that did not run is the same silent-false-success shape that
+# a hook-rejected commit produced earlier in this project.
+set -l rerr (mktemp); set -ga TMPDIRS $rerr
+chmod 500 $vroot9/agent-vault/.git
+set -l rrc (agents-vault --remote=https://git.rootiest.dev/rootiest/other.git --silent 2>$rerr; echo $status)
+chmod 700 $vroot9/agent-vault/.git
+check "failing --remote returns 1" 1 "$rrc"
+check "failing --remote reports on stderr" true (string match -q '*could not set*remote*' -- (cat $rerr); and echo true; or echo false)
+check "failing --remote left the old remote in place" https://git.rootiest.dev/rootiest/agent-vault.git (git -C $vroot9/agent-vault remote get-url origin)
+
+# --adopt renames the current project's entry.
+set -l ap (new_repo)
+set -l amang (string replace -a '/' '-' -- $ap | string replace -a '.' '-')
+mkdir -p $croot9/$amang/memory
+echo adopted >$croot9/$amang/memory/a.md
+pushd $ap >/dev/null
+agents-vault --silent
+set -l aslug (_agents_repo_slug $ap)
+agents-vault --adopt=my-chosen-slug --silent
+popd >/dev/null
+check "adopt renamed the entry" adopted (cat $vroot9/agent-vault/projects/my-chosen-slug/claude/memory/a.md)
+check "adopt repinned the link" (path resolve $vroot9/agent-vault/projects/my-chosen-slug/claude/memory) (path resolve $croot9/$amang/memory)
+check "adopt removed the old entry" false (test -d $vroot9/agent-vault/projects/$aslug; and echo true; or echo false)
+check "adopt recorded the rebind" true (string match -q "*$aslug*my-chosen-slug*" -- (cat $vroot9/agent-vault/projects/my-chosen-slug/origin); and echo true; or echo false)
+
+# An unvalidated --adopt slug is a path-traversal primitive: it lands in
+# "$vault/projects/$slug" and in `git mv`. Only the charset the slug
+# formula itself emits is accepted, plus a by-name refusal of the two
+# traversal names that need no slash.
+set -l bp (new_repo)
+set -l bmang (string replace -a '/' '-' -- $bp | string replace -a '.' '-')
+mkdir -p $croot9/$bmang/memory
+echo bad >$croot9/$bmang/memory/b.md
+pushd $bp >/dev/null
+agents-vault --silent
+popd >/dev/null
+set -l projects_before (command ls -A $vroot9/agent-vault/projects | sort | string join ',')
+
+set -l bad_slugs ../escape has/slash . .. 'UPPER' 'sp ace' ''
+pushd $bp >/dev/null
+for bad in $bad_slugs
+ set -l berr (mktemp); set -ga TMPDIRS $berr
+ set -l brc (agents-vault --adopt=$bad --silent 2>$berr; echo $status)
+ check "adopt refuses '$bad'" 1 "$brc"
+ check "adopt refuses '$bad' out loud" true (string match -q '*invalid*' -- (cat $berr); and echo true; or echo false)
+end
+popd >/dev/null
+
+check "refused adopts moved nothing" "$projects_before" (command ls -A $vroot9/agent-vault/projects | sort | string join ',')
+check "refused adopts escaped nothing above projects/" false (test -e $vroot9/agent-vault/escape; and echo true; or echo false)
+
+# ... but a *leading* dot is legitimate, not traversal. _agents_repo_slug
+# emits one for a dot-led subdomain, so refusing it would make such an
+# entry impossible to adopt. Inside projects/ it is a hidden directory.
+pushd $bp >/dev/null
+set -l drc (agents-vault --adopt=.hidden.example.com-repo --silent; echo $status)
+popd >/dev/null
+check "adopt accepts a leading-dot slug" 0 "$drc"
+check "leading-dot slug landed inside projects/" bad (cat $vroot9/agent-vault/projects/.hidden.example.com-repo/claude/memory/b.md 2>/dev/null)
+check "leading-dot slug escaped nothing" false (test -e $vroot9/agent-vault/.hidden.example.com-repo; and echo true; or echo false)
+check "leading-dot slug repinned the link" (path resolve $vroot9/agent-vault/projects/.hidden.example.com-repo/claude/memory) (path resolve $croot9/$bmang/memory)
+
+# --adopt must be atomic. A rename that lands while the relink fails
+# leaves the memory intact but unreferenced: the next ordinary run finds
+# no live link, recomputes the old slug, finds nothing there, and
+# fabricates a fresh empty entry, so the agent writes history-less memory
+# from then on. The relink is forced to fail by making the project's live
+# parent directory read-only, which stops ensure_symlink repinning it.
+set -l tp (new_repo https://git.rootiest.dev/rootiest/atomic.git)
+set -l tslug git.rootiest.dev-rootiest-atomic
+set -l tmang (string replace -a '/' '-' -- $tp | string replace -a '.' '-')
+mkdir -p $croot9/$tmang/memory
+echo atomic-precious >$croot9/$tmang/memory/keep.md
+pushd $tp >/dev/null
+agents-vault --silent
+popd >/dev/null
+
+set -l pre_entries (command ls -A $vroot9/agent-vault/projects | sort | string join ',')
+set -l pre_head (git -C $vroot9/agent-vault rev-list --count HEAD)
+set -l pre_porcelain (git -C $vroot9/agent-vault status --porcelain | string join ',')
+set -l pre_link (path resolve $croot9/$tmang/memory)
+
+set -l terr (mktemp); set -ga TMPDIRS $terr
+chmod 500 $croot9/$tmang
+pushd $tp >/dev/null
+set -l trc (agents-vault --adopt=atomic-target --silent 2>$terr; echo $status)
+popd >/dev/null
+chmod 700 $croot9/$tmang
+
+check "atomic adopt: failed relink returns 1" 1 "$trc"
+check "atomic adopt: says the entry was left alone" true (string match -q "*$tslug*left as it was*" -- (cat $terr); and echo true; or echo false)
+check "atomic adopt: rename rolled back" "$pre_entries" (command ls -A $vroot9/agent-vault/projects | sort | string join ',')
+check "atomic adopt: target entry not created" false (test -e $vroot9/agent-vault/projects/atomic-target; and echo true; or echo false)
+check "atomic adopt: original entry intact" atomic-precious (cat $vroot9/agent-vault/projects/$tslug/claude/memory/keep.md 2>/dev/null)
+check "atomic adopt: no origin note appended" false (string match -q '*adopted:*' -- (cat $vroot9/agent-vault/projects/$tslug/origin); and echo true; or echo false)
+check "atomic adopt: nothing committed" "$pre_head" (git -C $vroot9/agent-vault rev-list --count HEAD)
+check "atomic adopt: index and worktree unchanged" "$pre_porcelain" (git -C $vroot9/agent-vault status --porcelain | string join ',')
+check "atomic adopt: live link never removed" "$pre_link" (path resolve $croot9/$tmang/memory)
+check "atomic adopt: no stash left behind" false (test -e $vroot9/agent-vault/.adopt-stash; and echo true; or echo false)
+
+# The point of rolling back: an ordinary run afterwards must find the
+# original entry and must NOT fabricate a second one.
+pushd $tp >/dev/null
+agents-vault --silent
+popd >/dev/null
+check "atomic adopt: ordinary run fabricated no entry" "$pre_entries" (command ls -A $vroot9/agent-vault/projects | sort | string join ',')
+check "atomic adopt: ordinary run kept the original link" (path resolve $vroot9/agent-vault/projects/$tslug/claude/memory) (path resolve $croot9/$tmang/memory)
+check "atomic adopt: memory still reachable through the link" atomic-precious (cat $croot9/$tmang/memory/keep.md 2>/dev/null)
+
+# ... and once the underlying problem is fixed, --adopt simply works.
+pushd $tp >/dev/null
+set -l t2rc (agents-vault --adopt=atomic-target --silent; echo $status)
+popd >/dev/null
+check "atomic adopt: retry succeeds" 0 "$t2rc"
+check "atomic adopt: retry moved the memory" atomic-precious (cat $vroot9/agent-vault/projects/atomic-target/claude/memory/keep.md 2>/dev/null)
+
+# The cases above all adopt onto a slug with no entry at all. The other
+# half of --adopt is a target that already exists but holds no memory -- a
+# scaffolded entry, or one a departed project left behind. That one is
+# stashed rather than deleted, so a failed relink can put it back exactly
+# as it was.
+set -l sa (new_repo https://git.rootiest.dev/rootiest/stash-src.git)
+set -l saslug git.rootiest.dev-rootiest-stash-src
+set -l samang (string replace -a '/' '-' -- $sa | string replace -a '.' '-')
+mkdir -p $croot9/$samang/memory
+echo stash-precious >$croot9/$samang/memory/keep.md
+pushd $sa >/dev/null
+agents-vault --silent
+popd >/dev/null
+
+# The target must be *tracked* for this to bite: stashing it takes files
+# git knows about out from under the index, which is the whole hazard.
+mkdir -p $vroot9/agent-vault/projects/stash-target/claude/memory
+printf 'remote: (none)\npath: %s\nhost: t\n' /nowhere \
+ >$vroot9/agent-vault/projects/stash-target/origin
+pushd $sa >/dev/null
+agents-vault --silent
+popd >/dev/null
+check "stash adopt: target entry is tracked" true (git -C $vroot9/agent-vault ls-files --error-unmatch projects/stash-target/origin >/dev/null 2>&1; and echo true; or echo false)
+check "stash adopt: vault clean before the adopt" "" (git -C $vroot9/agent-vault status --porcelain | string join ',')
+
+# Where the stash lives is load-bearing rather than cosmetic: at the vault
+# root, a crash between the two moves leaves it for the next ordinary run's
+# `git add -A` to commit as permanent junk. Inside .git/ it is out of reach
+# of both the entry walk and `git add -A`. Pinned by making the vault root
+# itself unwritable for the duration -- adopt writes nothing there, so only
+# a stash at the root would need it.
+chmod 500 $vroot9/agent-vault
+pushd $sa >/dev/null
+set -l sarc (agents-vault --adopt=stash-target --silent; echo $status)
+popd >/dev/null
+chmod 700 $vroot9/agent-vault
+check "stash adopt: succeeds over a contentless target" 0 "$sarc"
+check "stash adopt: memory moved to the target slug" stash-precious (cat $vroot9/agent-vault/projects/stash-target/claude/memory/keep.md 2>/dev/null)
+check "stash adopt: source entry removed" false (test -d $vroot9/agent-vault/projects/$saslug; and echo true; or echo false)
+check "stash adopt: link repinned onto the target" (path resolve $vroot9/agent-vault/projects/stash-target/claude/memory) (path resolve $croot9/$samang/memory)
+check "stash adopt: stash cleaned up" false (test -e $vroot9/agent-vault/.git/agents-vault-adopt-stash; and echo true; or echo false)
+check "stash adopt: nothing stashed at the vault root" false (test -e $vroot9/agent-vault/.adopt-stash; and echo true; or echo false)
+check "stash adopt: the root fallback location is gitignored" true (git -C $vroot9/agent-vault check-ignore -q .adopt-stash; and echo true; or echo false)
+check "stash adopt: vault clean afterwards" "" (git -C $vroot9/agent-vault status --porcelain | string join ',')
+
+# ... and when the relink fails, the stash must come back and the *index*
+# must come back with it. Restoring the worktree alone leaves git
+# describing a half-applied rename -- the files are right, git status is
+# not -- and a hand commit in that window records it.
+set -l sf (new_repo https://git.rootiest.dev/rootiest/stash-fail.git)
+set -l sfslug git.rootiest.dev-rootiest-stash-fail
+set -l sfmang (string replace -a '/' '-' -- $sf | string replace -a '.' '-')
+mkdir -p $croot9/$sfmang/memory
+echo stashfail-precious >$croot9/$sfmang/memory/keep.md
+pushd $sf >/dev/null
+agents-vault --silent
+popd >/dev/null
+
+mkdir -p $vroot9/agent-vault/projects/stashfail-target/claude/memory
+printf 'remote: (none)\npath: %s\nhost: t\n' /nowhere-else \
+ >$vroot9/agent-vault/projects/stashfail-target/origin
+pushd $sf >/dev/null
+agents-vault --silent
+popd >/dev/null
+
+set -l sf_entries (command ls -A $vroot9/agent-vault/projects | sort | string join ',')
+set -l sf_head (git -C $vroot9/agent-vault rev-list --count HEAD)
+set -l sf_porcelain (git -C $vroot9/agent-vault status --porcelain | string join ',')
+set -l sf_link (path resolve $croot9/$sfmang/memory)
+check "stash adopt rollback: vault clean before the adopt" "" "$sf_porcelain"
+
+set -l sferr (mktemp); set -ga TMPDIRS $sferr
+chmod 500 $croot9/$sfmang
+pushd $sf >/dev/null
+set -l sfrc (agents-vault --adopt=stashfail-target --silent 2>$sferr; echo $status)
+popd >/dev/null
+chmod 700 $croot9/$sfmang
+
+check "stash adopt rollback: failed relink returns 1" 1 "$sfrc"
+check "stash adopt rollback: says the entry was left alone" true (string match -q "*$sfslug*left as it was*" -- (cat $sferr); and echo true; or echo false)
+check "stash adopt rollback: entries unchanged" "$sf_entries" (command ls -A $vroot9/agent-vault/projects | sort | string join ',')
+check "stash adopt rollback: source entry intact" stashfail-precious (cat $vroot9/agent-vault/projects/$sfslug/claude/memory/keep.md 2>/dev/null)
+check "stash adopt rollback: stashed target came back" true (string match -q '*nowhere-else*' -- (cat $vroot9/agent-vault/projects/stashfail-target/origin 2>/dev/null); and echo true; or echo false)
+check "stash adopt rollback: target memory still contentless" 0 (command ls -A $vroot9/agent-vault/projects/stashfail-target/claude/memory 2>/dev/null | count)
+check "stash adopt rollback: nothing committed" "$sf_head" (git -C $vroot9/agent-vault rev-list --count HEAD)
+check "stash adopt rollback: index and worktree unchanged" "$sf_porcelain" (git -C $vroot9/agent-vault status --porcelain | string join ',')
+check "stash adopt rollback: 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)
+check "stash adopt rollback: live link never removed" "$sf_link" (path resolve $croot9/$sfmang/memory)
+check "stash adopt rollback: memory still reachable live" stashfail-precious (cat $croot9/$sfmang/memory/keep.md 2>/dev/null)
+
+# The point of rolling back: the ordinary run afterwards finds the original
+# entry instead of fabricating a fresh, history-less one.
+pushd $sf >/dev/null
+agents-vault --silent
+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
+set -g __fish_agent_vault_agy_root $HERMETIC_HOME/agy
+
+# ─────────────────────────────── restore ───────────────────────────────
+# The batch counterpart of the emergent per-project restore: walk the vault
+# and relink every entry whose recorded origin path still exists.
+echo ""
+echo "== agents-vault (restore) =="
+
+set -l vroot10 (mktemp -d); set -ga TMPDIRS $vroot10
+set -l croot10 (mktemp -d); set -ga TMPDIRS $croot10
+set -l chome10 (mktemp -d); set -ga TMPDIRS $chome10
+set -l agy10 (mktemp -d); set -ga TMPDIRS $agy10
+set -g __fish_agent_vault_dir $vroot10/agent-vault
+set -g __fish_agent_vault_claude_root $croot10
+set -g __fish_agent_vault_claude_home $chome10
+set -g __fish_agent_vault_agy_root $agy10
+
+set -l rp (new_repo https://git.rootiest.dev/rootiest/restoreme.git)
+set -l rmang (string replace -a '/' '-' -- $rp | string replace -a '.' '-')
+mkdir -p $croot10/$rmang/memory
+echo restore-precious >$croot10/$rmang/memory/keep.md
+pushd $rp >/dev/null
+agents-vault --silent
+popd >/dev/null
+
+# Lose the live link the way a reinstalled machine would.
+rm -f $croot10/$rmang/memory
+check "restore: link gone to begin with" false (test -e $croot10/$rmang/memory; and echo true; or echo false)
+
+set -l rout (agents-vault --restore)
+check "restore: relinked from the origin file" restore-precious (cat $croot10/$rmang/memory/keep.md 2>/dev/null)
+check "restore: the live path is a link" true (test -L $croot10/$rmang/memory; and echo true; or echo false)
+check "restore: names what it restored" true (string match -q '*restoreme*' -- "$rout"; and echo true; or echo false)
+
+# An entry whose recorded path is gone cannot be placed; the run still
+# succeeds and says which entry needs --adopt.
+mkdir -p $vroot10/agent-vault/projects/ghost-entry/claude/memory
+echo x >$vroot10/agent-vault/projects/ghost-entry/claude/memory/x.md
+printf 'remote: (none)\npath: %s\nhost: t\n' $vroot10/gone-forever \
+ >$vroot10/agent-vault/projects/ghost-entry/origin
+set -l r2out (mktemp); set -ga TMPDIRS $r2out
+set -l r2rc (agents-vault --restore >$r2out; echo $status)
+check "restore: exits 0 with an unplaceable entry" 0 "$r2rc"
+check "restore: reports the unplaceable entry" true (string match -q '*ghost-entry*' -- (cat $r2out); and echo true; or echo false)
+
+# A dot-led slug is a real key, not a curiosity: the sibling-bare-mirror
+# idiom (`git remote add origin ../mirror.git`) keys as ..-mirror, a
+# dot-led host keys as .hidden.example.com-o-r, and --adopt accepts a
+# leading dot on purpose. Fish's * skips such a name, so both walks list
+# instead of globbing -- otherwise --status under-reports the entry and
+# --restore leaves that project unlinked, both without saying a word.
+set -l dotp (new_repo https://git.rootiest.dev/rootiest/dotted.git)
+set -l dotmang (string replace -a '/' '-' -- $dotp | string replace -a '.' '-')
+mkdir -p $vroot10/agent-vault/projects/.dot-entry/claude/memory
+echo dot-precious >$vroot10/agent-vault/projects/.dot-entry/claude/memory/keep.md
+printf 'remote: (none)\npath: %s\nhost: t\n' $dotp \
+ >$vroot10/agent-vault/projects/.dot-entry/origin
+check "dot-led entry: a glob really does skip it" false (string match -q '*.dot-entry*' -- (echo $vroot10/agent-vault/projects/*); and echo true; or echo false)
+
+set -l dotreport (agents-vault --status)
+check "status lists a dot-led entry" true (string match -q '*.dot-entry*' -- "$dotreport"; and echo true; or echo false)
+
+set -l dotout (agents-vault --restore)
+check "restore: relinks a dot-led entry" dot-precious (cat $croot10/$dotmang/memory/keep.md 2>/dev/null)
+check "restore: the dot-led live path is a link" true (test -L $croot10/$dotmang/memory; and echo true; or echo false)
+check "restore: names the dot-led entry it restored" true (string match -q '*.dot-entry*' -- "$dotout"; and echo true; or echo false)
+
+# An entry that *could* be placed but could not be relinked is a failure,
+# not a note in passing -- the same branchless-`if` false zero as the
+# commit and push paths. An entry with no live project is not a failure.
+rm -f $croot10/$rmang/memory
+chmod 500 $croot10/$rmang
+set -l r3err (mktemp); set -ga TMPDIRS $r3err
+set -l r3rc (agents-vault --restore >/dev/null 2>$r3err; echo $status)
+chmod 700 $croot10/$rmang
+check "restore: a failed relink returns non-zero" 1 "$r3rc"
+check "restore: a failed relink is reported" true (string match -q '*restoreme*' -- (cat $r3err); 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
+
+# ──────────────────────────────── push ─────────────────────────────────
+echo ""
+echo "== agents-vault (push) =="
+
+set -l vroot11 (mktemp -d); set -ga TMPDIRS $vroot11
+set -l croot11 (mktemp -d); set -ga TMPDIRS $croot11
+set -l chome11 (mktemp -d); set -ga TMPDIRS $chome11
+set -l agy11 (mktemp -d); set -ga TMPDIRS $agy11
+set -l bare (mktemp -d); set -ga TMPDIRS $bare
+git init -q --bare $bare
+set -g __fish_agent_vault_dir $vroot11/agent-vault
+set -g __fish_agent_vault_claude_root $croot11
+set -g __fish_agent_vault_claude_home $chome11
+set -g __fish_agent_vault_agy_root $agy11
+
+set -l pp (new_repo https://git.rootiest.dev/rootiest/pushme.git)
+set -l pslug git.rootiest.dev-rootiest-pushme
+set -l pmang (string replace -a '/' '-' -- $pp | string replace -a '.' '-')
+mkdir -p $croot11/$pmang/memory
+echo pushed >$croot11/$pmang/memory/p.md
+
+# --push with no remote must fail loudly. The commit still happened, so
+# silently returning 0 would read as "backed up off this machine".
+set -l perr (mktemp); set -ga TMPDIRS $perr
+pushd $pp >/dev/null
+set -l prc0 (agents-vault --push --silent 2>$perr; echo $status)
+popd >/dev/null
+check "push without a remote returns 1" 1 "$prc0"
+check "push without a remote says so" true (string match -q '*no remote*' -- (cat $perr); and echo true; or echo false)
+check "push without a remote still committed locally" true (git -C $vroot11/agent-vault ls-files --error-unmatch projects/$pslug/claude/memory/p.md >/dev/null 2>&1; and echo true; or echo false)
+
+agents-vault --remote=$bare --silent
+set -l vbranch (git -C $vroot11/agent-vault rev-parse --abbrev-ref HEAD)
+pushd $pp >/dev/null
+set -l prc (agents-vault --push --silent; echo $status)
+popd >/dev/null
+check "push exits 0" 0 "$prc"
+check "push landed in the remote" pushed (git -C $bare show $vbranch:projects/$pslug/claude/memory/p.md 2>/dev/null)
+
+# Autopush is opt-in and off by default: a plain run must not push.
+echo pushed-later >$croot11/$pmang/memory/p2.md
+pushd $pp >/dev/null
+agents-vault --silent
+popd >/dev/null
+check "no autopush by default" false (git -C $bare cat-file -e $vbranch:projects/$pslug/claude/memory/p2.md 2>/dev/null; and echo true; or echo false)
+
+set -g __fish_agent_vault_autopush 1
+echo pushed-auto >$croot11/$pmang/memory/p3.md
+pushd $pp >/dev/null
+agents-vault --silent
+popd >/dev/null
+set -e __fish_agent_vault_autopush
+check "autopush pushes when enabled" pushed-auto (git -C $bare show $vbranch:projects/$pslug/claude/memory/p3.md 2>/dev/null)
+
+# A push that *fails* against a configured remote must return non-zero.
+# The function otherwise ends on a branchless `if`, which resolves to 0,
+# so warning on stderr and falling through reports a successful backup
+# while nothing left the machine -- the exact loss the vault prevents.
+# The remote is a real path that is not a repository, not a mock.
+set -l deadremote $vroot11/not-a-repo.git
+agents-vault --remote=$deadremote --silent
+echo pushed-never >$croot11/$pmang/memory/p4.md
+set -l fperr (mktemp); set -ga TMPDIRS $fperr
+pushd $pp >/dev/null
+set -l fprc (agents-vault --push --silent 2>$fperr; echo $status)
+popd >/dev/null
+check "failing push returns non-zero" 1 "$fprc"
+check "failing push warns on stderr" true (string match -q '*push failed*' -- (cat $fperr); and echo true; or echo false)
+check "failing push still committed locally" true (git -C $vroot11/agent-vault ls-files --error-unmatch projects/$pslug/claude/memory/p4.md >/dev/null 2>&1; and echo true; or echo false)
+
+# Autopush is the same failure through the quiet path: a summary line
+# saying "Synced" must not come with a zero exit when the push failed.
+echo pushed-never-2 >$croot11/$pmang/memory/p5.md
+set -g __fish_agent_vault_autopush 1
+set -l fp2err (mktemp); set -ga TMPDIRS $fp2err
+pushd $pp >/dev/null
+set -l fp2rc (agents-vault --quiet 2>$fp2err >/dev/null; echo $status)
+popd >/dev/null
+set -e __fish_agent_vault_autopush
+check "failing autopush returns non-zero" 1 "$fp2rc"
+
+# Autopush runs synchronously in front of every agent launch, so it must be
+# bounded. Nothing in git bounds it: there is no HTTP connect timeout in
+# its configuration, http.lowSpeedLimit/http.lowSpeedTime only start
+# counting once bytes move, and GIT_TERMINAL_PROMPT/GIT_ASKPASS close the
+# credential prompt rather than the socket. Unwrapped, this remote took
+# 135s to give up. 192.0.2.1 is TEST-NET-1: reserved, unrouted, and
+# therefore a blackhole rather than a fast refusal. A network that does
+# refuse it quickly makes this pass without proving much, which is the
+# right way round for a test that must never fail spuriously.
+#
+# This test costs its own bound in wall time. That is the price of
+# measuring a timeout, and this defect -- a network call on the launch
+# path -- has now been introduced twice.
+set -l blackhole https://192.0.2.1/vault.git
+agents-vault --remote=$blackhole --silent
+echo pushed-into-the-void >$croot11/$pmang/memory/p6.md
+set -g __fish_agent_vault_autopush 1
+set -l bh_start (date +%s)
+pushd $pp >/dev/null
+set -l bhrc (agents-vault --silent 2>/dev/null; echo $status)
+popd >/dev/null
+set -l bh_elapsed (math (date +%s) - $bh_start)
+set -e __fish_agent_vault_autopush
+check "autopush to a blackholed remote returns non-zero" 1 "$bhrc"
+check "autopush to a blackholed remote is bounded" true (test $bh_elapsed -lt 60; and echo true; or echo false)
+check "a bounded autopush still committed locally" true (git -C $vroot11/agent-vault ls-files --error-unmatch projects/$pslug/claude/memory/p6.md >/dev/null 2>&1; 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
+
+# ──────────────── the bound never overrides the user's ssh ─────────────
+# The connect bound is delivered by injecting GIT_SSH_COMMAND, and an
+# environment variable outranks core.sshCommand -- so a guard that reads
+# only the environment does not merely fail to notice the config, it
+# overrules it. A vault reachable only as `ssh -i ~/.ssh/vault_key` then
+# fails to authenticate on every push, autopush and --push alike, for the
+# sake of a ten-second timeout. Counting ssh invocations is the honest
+# measurement here: it asks what git actually ran, not what this function
+# meant to arrange.
+echo ""
+echo "== agents-vault (the user's ssh command wins) =="
+
+set -l vroot14 (mktemp -d); set -ga TMPDIRS $vroot14
+set -l croot14 (mktemp -d); set -ga TMPDIRS $croot14
+set -l chome14 (mktemp -d); set -ga TMPDIRS $chome14
+set -l agy14 (mktemp -d); set -ga TMPDIRS $agy14
+set -g __fish_agent_vault_dir $vroot14/agent-vault
+set -g __fish_agent_vault_claude_root $croot14
+set -g __fish_agent_vault_claude_home $chome14
+set -g __fish_agent_vault_agy_root $agy14
+
+# Two fake ssh binaries that record their arguments: one found on PATH as
+# plain `ssh` (what the injected default resolves to) and one named
+# explicitly by the user. Both refuse the connection, so nothing leaves
+# the machine and no test waits on a network.
+set -l sbin (mktemp -d); set -ga TMPDIRS $sbin
+set -l pathssh_log $sbin/path-ssh.log
+set -l usessh $sbin/user-ssh
+set -l usessh_log $sbin/user-ssh.log
+printf '#!/bin/sh\nprintf "%%s\\n" "$*" >>%s\nexit 255\n' $pathssh_log >$sbin/ssh
+printf '#!/bin/sh\nprintf "%%s\\n" "$*" >>%s\nexit 255\n' $usessh_log >$usessh
+chmod +x $sbin/ssh $usessh
+
+set -l sp14 (new_repo https://git.rootiest.dev/rootiest/sshcmd.git)
+set -l smang14 (string replace -a '/' '-' -- $sp14 | string replace -a '.' '-')
+mkdir -p $croot14/$smang14/memory
+echo s1 >$croot14/$smang14/memory/s1.md
+pushd $sp14 >/dev/null
+agents-vault --remote='ssh://git@example.invalid/vault.git' --silent >/dev/null 2>&1
+popd >/dev/null
+
+set -l realpath14 $PATH
+set -g PATH $sbin $PATH
+
+# Runs one autopush (or an explicit --push) with a fresh memory file, so
+# there is always a commit worth pushing, and reports nothing itself.
+function ssh_run --argument-names memroot proj name mode
+ rm -f $argv[5..]
+ echo $name >$memroot/memory/$name.md
+ test "$mode" = auto; and set -g __fish_agent_vault_autopush 1
+ pushd $proj >/dev/null
+ if test "$mode" = auto
+ agents-vault --silent >/dev/null 2>&1
+ else
+ agents-vault --push --silent >/dev/null 2>&1
+ end
+ popd >/dev/null
+ set -e __fish_agent_vault_autopush
+end
+
+git -C $vroot14/agent-vault config core.sshCommand $usessh
+ssh_run $croot14/$smang14 $sp14 s2 auto $usessh_log $pathssh_log
+check "autopush runs the user's core.sshCommand" true (test -s $usessh_log; and echo true; or echo false)
+ssh_run $croot14/$smang14 $sp14 s3 push $usessh_log $pathssh_log
+check "--push runs the user's core.sshCommand" true (test -s $usessh_log; and echo true; or echo false)
+
+# With nothing configured either way the bound is still applied -- the
+# fix must not have simply removed it.
+git -C $vroot14/agent-vault config --unset core.sshCommand
+ssh_run $croot14/$smang14 $sp14 s4 auto $usessh_log $pathssh_log
+check "an unconfigured ssh still gets a connect bound" true (string match -q '*ConnectTimeout=10*' -- (cat $pathssh_log 2>/dev/null); and echo true; or echo false)
+
+# A fish variable that was never exported satisfies `set -q` but is not in
+# git's environment, so treating it as the user's answer would leave the
+# push with neither their ssh command nor a bound.
+set -g GIT_SSH_COMMAND $usessh
+ssh_run $croot14/$smang14 $sp14 s5 auto $usessh_log $pathssh_log
+set -e GIT_SSH_COMMAND
+check "an unexported GIT_SSH_COMMAND does not suppress the bound" true (string match -q '*ConnectTimeout=10*' -- (cat $pathssh_log 2>/dev/null); and echo true; or echo false)
+
+# An exported one is git's already and is passed through untouched.
+set -gx GIT_SSH_COMMAND "$usessh -o Marker=yes"
+ssh_run $croot14/$smang14 $sp14 s6 auto $usessh_log $pathssh_log
+set -e GIT_SSH_COMMAND
+check "an exported GIT_SSH_COMMAND is used verbatim" true (string match -q '*Marker=yes*' -- (cat $usessh_log 2>/dev/null); and echo true; or echo false)
+check "an exported GIT_SSH_COMMAND is not overridden" false (string match -q '*ConnectTimeout*' -- (cat $usessh_log 2>/dev/null); and echo true; or echo false)
+
+set -g PATH $realpath14
+functions -e ssh_run
+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
+
+# ─────────────── autopush without timeout(1) does not run ──────────────
+# timeout(1) is the only thing bounding autopush, so without it the launch
+# path would be back to an open-ended network call -- the exact block this
+# design exists to remove. The push is skipped and says so instead. An
+# explicit --push is deliberately unaffected: it was never wrapped.
+#
+# The PATH here is the real one with every directory that provides a
+# timeout replaced by a symlink farm of itself missing that one entry, so
+# everything else agents-vault shells out to still resolves normally.
+echo ""
+echo "== agents-vault (autopush without timeout) =="
+
+set -l vroot15 (mktemp -d); set -ga TMPDIRS $vroot15
+set -l croot15 (mktemp -d); set -ga TMPDIRS $croot15
+set -l chome15 (mktemp -d); set -ga TMPDIRS $chome15
+set -l agy15 (mktemp -d); set -ga TMPDIRS $agy15
+set -l bare15 (mktemp -d); set -ga TMPDIRS $bare15
+git init -q --bare $bare15
+set -g __fish_agent_vault_dir $vroot15/agent-vault
+set -g __fish_agent_vault_claude_root $croot15
+set -g __fish_agent_vault_claude_home $chome15
+set -g __fish_agent_vault_agy_root $agy15
+
+set -l tp15 (new_repo https://git.rootiest.dev/rootiest/notimeout.git)
+set -l tslug15 git.rootiest.dev-rootiest-notimeout
+set -l tmang15 (string replace -a '/' '-' -- $tp15 | string replace -a '.' '-')
+mkdir -p $croot15/$tmang15/memory
+echo t1 >$croot15/$tmang15/memory/t1.md
+pushd $tp15 >/dev/null
+agents-vault --remote=$bare15 --silent >/dev/null 2>&1
+popd >/dev/null
+# symbolic-ref, not rev-parse: the vault branch is still unborn here and
+# rev-parse would report the literal string HEAD with a fatal on stderr.
+set -l vb15 (git -C $vroot15/agent-vault symbolic-ref --short HEAD)
+
+set -l shimroot (mktemp -d); set -ga TMPDIRS $shimroot
+set -l nopath
+set -l shimn 0
+for d in $PATH
+ test -d "$d"; or continue
+ if test -x "$d/timeout"
+ set shimn (math $shimn + 1)
+ mkdir -p $shimroot/$shimn
+ command cp -rs "$d/." $shimroot/$shimn/ 2>/dev/null
+ rm -f $shimroot/$shimn/timeout
+ set -a nopath $shimroot/$shimn
+ else
+ set -a nopath $d
+ end
+end
+set -l realpath15 $PATH
+
+echo t2 >$croot15/$tmang15/memory/t2.md
+set -l terr (mktemp); set -ga TMPDIRS $terr
+set -g __fish_agent_vault_autopush 1
+set -g PATH $nopath
+check "the shimmed PATH really has no timeout" false (type -q timeout; and echo true; or echo false)
+pushd $tp15 >/dev/null
+set -l trc (agents-vault --silent 2>$terr; echo $status)
+popd >/dev/null
+set -g PATH $realpath15
+set -e __fish_agent_vault_autopush
+check "an unboundable autopush is skipped, not run" false (git -C $bare15 cat-file -e $vb15:projects/$tslug15/claude/memory/t2.md 2>/dev/null; and echo true; or echo false)
+check "a skipped autopush says why" true (string match -q '*timeout is unavailable*' -- (cat $terr); and echo true; or echo false)
+check "a skipped autopush still committed locally" true (git -C $vroot15/agent-vault ls-files --error-unmatch projects/$tslug15/claude/memory/t2.md >/dev/null 2>&1; and echo true; or echo false)
+# Not a failure: nothing was attempted and failed, and the one thing the
+# user did not ask for -- a hang in front of an agent launch -- did not
+# happen. --push is where a caller demands a real transfer answer.
+check "a skipped autopush is not a failure" 0 "$trc"
+
+echo t3 >$croot15/$tmang15/memory/t3.md
+set -g PATH $nopath
+pushd $tp15 >/dev/null
+set -l t3rc (agents-vault --push --silent 2>/dev/null; echo $status)
+popd >/dev/null
+set -g PATH $realpath15
+check "--push still pushes without timeout" 0 "$t3rc"
+check "--push landed in the remote without timeout" t3 (git -C $bare15 show $vb15:projects/$tslug15/claude/memory/t3.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
+set -g __fish_agent_vault_agy_root $HERMETIC_HOME/agy
+
+# ────────────── a failed vault commit is fatal, not a note ─────────────
+# _agents_repo_sync's own rejection path is covered further up, but
+# agents-vault has to *propagate* it. The function otherwise ends on a
+# branchless `if`, which fish resolves to 0, and a backup tool that reports
+# success while nothing was recorded recreates the exact loss the vault
+# exists to prevent. That false zero has already bitten this project three
+# times, so both ways a sync can fail are pinned here rather than one.
+echo ""
+echo "== agents-vault (a failed vault commit is fatal) =="
+
+set -l vroot13 (mktemp -d); set -ga TMPDIRS $vroot13
+set -l croot13 (mktemp -d); set -ga TMPDIRS $croot13
+set -l chome13 (mktemp -d); set -ga TMPDIRS $chome13
+set -l agy13 (mktemp -d); set -ga TMPDIRS $agy13
+set -g __fish_agent_vault_dir $vroot13/agent-vault
+set -g __fish_agent_vault_claude_root $croot13
+set -g __fish_agent_vault_claude_home $chome13
+set -g __fish_agent_vault_agy_root $agy13
+
+set -l hp13 (new_repo https://git.rootiest.dev/rootiest/hookfail.git)
+set -l hmang13 (string replace -a '/' '-' -- $hp13 | string replace -a '.' '-')
+mkdir -p $croot13/$hmang13/memory
+echo hook-precious >$croot13/$hmang13/memory/keep.md
+pushd $hp13 >/dev/null
+agents-vault --silent
+popd >/dev/null
+
+# The vault runs its own hooks out of .agents-tools/hooks -- agents-vault
+# points core.hooksPath there itself -- so a rejecting pre-commit in that
+# directory is exactly the shape of a real secret scanner blocking the
+# vault commit. _agents_repo_install_tools only refreshes the shims when
+# the version marker moves, so the replacement survives the run under test.
+printf '#!/bin/sh\nexit 1\n' >$vroot13/agent-vault/.agents-tools/hooks/pre-commit
+chmod +x $vroot13/agent-vault/.agents-tools/hooks/pre-commit
+echo hook-more >$croot13/$hmang13/memory/keep2.md
+set -l hhead13 (git -C $vroot13/agent-vault rev-list --count HEAD)
+set -l herr13 (mktemp); set -ga TMPDIRS $herr13
+pushd $hp13 >/dev/null
+set -l hrc13 (agents-vault --silent 2>$herr13; echo $status)
+popd >/dev/null
+check "rejected vault commit returns non-zero" 1 "$hrc13"
+check "rejected vault commit says nothing was recorded" true (string match -q '*nothing recorded*' -- (cat $herr13); and echo true; or echo false)
+check "rejected vault commit really recorded nothing" "$hhead13" (git -C $vroot13/agent-vault rev-list --count HEAD)
+
+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
+
+# The other way a backup fails is a diverged remote -- but that is a
+# push-time problem, not a launch-time one. The ordinary run must commit
+# regardless, because a backup that stops working the moment the remote
+# moves ahead (or goes out of reach) is not a backup; --push is where the
+# divergence has to be reckoned with, and where the two ways it can fail
+# have to be told apart.
+set -l vroot14 (mktemp -d); set -ga TMPDIRS $vroot14
+set -l croot14 (mktemp -d); set -ga TMPDIRS $croot14
+set -l chome14 (mktemp -d); set -ga TMPDIRS $chome14
+set -l agy14 (mktemp -d); set -ga TMPDIRS $agy14
+set -g __fish_agent_vault_dir $vroot14/agent-vault
+set -g __fish_agent_vault_claude_root $croot14
+set -g __fish_agent_vault_claude_home $chome14
+set -g __fish_agent_vault_agy_root $agy14
+
+set -l bare14 (mktemp -d); set -ga TMPDIRS $bare14
+git init -q --bare $bare14
+
+set -l cp14 (new_repo https://git.rootiest.dev/rootiest/conflict.git)
+set -l cslug14 git.rootiest.dev-rootiest-conflict
+set -l cmang14 (string replace -a '/' '-' -- $cp14 | string replace -a '.' '-')
+mkdir -p $croot14/$cmang14/memory
+echo base >$croot14/$cmang14/memory/keep.md
+pushd $cp14 >/dev/null
+agents-vault --silent
+popd >/dev/null
+agents-vault --remote=$bare14 --silent
+git -C $vroot14/agent-vault push -q -u origin HEAD:refs/heads/main
+
+# Another machine records a conflicting change to the same line...
+set -l cclone14 (mktemp -d); set -ga TMPDIRS $cclone14
+git clone -q $bare14 $cclone14
+git -C $cclone14 config user.email t@t
+git -C $cclone14 config user.name t
+git -C $cclone14 config commit.gpgsign false
+git -C $cclone14 config core.hooksPath /dev/null
+echo theirs >$cclone14/projects/$cslug14/claude/memory/keep.md
+git -C $cclone14 commit -qam theirs
+git -C $cclone14 push -q origin HEAD:main
+
+# ... while this one writes conflicting memory of its own on the same
+# line. The ordinary run has to commit it: it never fetches, so the
+# divergence is invisible to it and irrelevant.
+set -l cerr14 (mktemp); set -ga TMPDIRS $cerr14
+echo ours >$croot14/$cmang14/memory/keep.md
+set -l chead14 (git -C $vroot14/agent-vault rev-list --count HEAD)
+pushd $cp14 >/dev/null
+set -l crc14 (agents-vault --silent 2>$cerr14; echo $status)
+popd >/dev/null
+check "diverged vault: ordinary run returns 0" 0 "$crc14"
+check "diverged vault: ordinary run said nothing" "" (cat $cerr14)
+check "diverged vault: ordinary run committed" (math $chead14 + 1) (git -C $vroot14/agent-vault rev-list --count HEAD)
+
+# --push is where it is reckoned with: the pre-push pull replays that
+# commit onto theirs, conflicts, aborts back to local HEAD, and reports a
+# conflict. Nothing is pushed and the local memory survives untouched.
+pushd $cp14 >/dev/null
+set -l prc14 (agents-vault --push --silent 2>$cerr14; echo $status)
+popd >/dev/null
+check "vault push rebase conflict returns non-zero" 1 "$prc14"
+check "vault push rebase conflict is named as one" true (string match -q '*rebase conflict*' -- (cat $cerr14); and echo true; or echo false)
+check "vault push rebase conflict left no rebase in progress" false (test -d $vroot14/agent-vault/.git/rebase-merge -o -d $vroot14/agent-vault/.git/rebase-apply; and echo true; or echo false)
+check "vault push rebase conflict kept the local memory" ours (cat $croot14/$cmang14/memory/keep.md)
+
+# The other push-time failure is the remote being unreachable, and it must
+# not be reported as the one above: no rebase ever starts, so calling it a
+# rebase conflict sends the user hunting for a conflict that does not
+# exist. (The old code said exactly that.) A bogus local path stands in
+# for an unroutable host so the check costs nothing; the branch under test
+# is the same one.
+git -C $vroot14/agent-vault remote set-url origin /nonexistent/unreachable.git
+echo more >$croot14/$cmang14/memory/keep2.md
+set -l uhead14 (git -C $vroot14/agent-vault rev-list --count HEAD)
+pushd $cp14 >/dev/null
+set -l urc14 (agents-vault --push --silent 2>$cerr14; echo $status)
+popd >/dev/null
+check "unreachable remote: push returns non-zero" 1 "$urc14"
+check "unreachable remote: not called a rebase conflict" false (string match -q '*rebase conflict*' -- (cat $cerr14); and echo true; or echo false)
+check "unreachable remote: says it could not be reached" true (string match -q '*could not reach*' -- (cat $cerr14); and echo true; or echo false)
+check "unreachable remote: the memory was still committed" (math $uhead14 + 1) (git -C $vroot14/agent-vault rev-list --count HEAD)
+check "unreachable remote: left no rebase in progress" false (test -d $vroot14/agent-vault/.git/rebase-merge -o -d $vroot14/agent-vault/.git/rebase-apply; and echo true; or echo false)
+
+# And the launch path itself -- the ordinary run both wrappers make -- is
+# entirely unaffected by the unreachable remote. This is the regression
+# that mattered most: with the pull on the commit path, a laptop off the
+# network stopped being backed up at all while reporting nothing wrong.
+echo offline-precious >$croot14/$cmang14/memory/keep3.md
+set -l ohead14 (git -C $vroot14/agent-vault rev-list --count HEAD)
+pushd $cp14 >/dev/null
+set -l orc14 (agents-vault --silent 2>$cerr14; echo $status)
+popd >/dev/null
+check "offline launch run returns 0" 0 "$orc14"
+check "offline launch run stayed silent" "" (cat $cerr14)
+check "offline launch run committed the memory" (math $ohead14 + 1) (git -C $vroot14/agent-vault rev-list --count HEAD)
+check "offline launch run really recorded it" offline-precious (git -C $vroot14/agent-vault show HEAD:projects/$cslug14/claude/memory/keep3.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
+set -g __fish_agent_vault_agy_root $HERMETIC_HOME/agy
+
+# ────────────── a dangling global memory link is repinned ──────────────
+# The one state from the real incident the suite never pinned down. For a
+# *broken* symlink both -d and -e are false, so only the `-L` disjunct in
+# the global-memory guard can notice it; the vault side is deliberately
+# left empty so no other disjunct can stand in and pass this by accident.
+echo ""
+echo "== agents-vault (dangling global memory link) =="
+
+set -l vroot12 (mktemp -d); set -ga TMPDIRS $vroot12
+set -l croot12 (mktemp -d); set -ga TMPDIRS $croot12
+set -l chome12 (mktemp -d); set -ga TMPDIRS $chome12
+set -l agy12 (mktemp -d); set -ga TMPDIRS $agy12
+set -g __fish_agent_vault_dir $vroot12/agent-vault
+set -g __fish_agent_vault_claude_root $croot12
+set -g __fish_agent_vault_claude_home $chome12
+set -g __fish_agent_vault_agy_root $agy12
+
+ln -s $vroot12/vanished-vault/global/claude/memory $chome12/memory
+check "dangling: -e is false for the broken link" false (test -e $chome12/memory; and echo true; or echo false)
+check "dangling: -L is the only signal" true (test -L $chome12/memory; and echo true; or echo false)
+check "dangling: the vault side is empty" false (test -e $vroot12/agent-vault/global/claude/memory; and echo true; or echo false)
+
+set -l dp (new_repo https://git.rootiest.dev/rootiest/dangling.git)
+set -l derr (mktemp); set -ga TMPDIRS $derr
+pushd $dp >/dev/null
+set -l drc (agents-vault --silent 2>$derr; echo $status)
+popd >/dev/null
+
+check "dangling: exits 0" 0 "$drc"
+check "dangling: warns about nothing" "" (cat $derr)
+check "dangling: repinned into the vault" (path resolve $vroot12/agent-vault/global/claude/memory) (path resolve $chome12/memory)
+check "dangling: the link resolves again" true (test -d $chome12/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
+
+# ──────────────── agents-init reports what really happened ─────────────
+# agents-init shares _agents_repo_sync with agents-vault and shared its
+# false zero too: it ended on a branchless `if` with no arm for a failed
+# commit, so fish resolved the function to 0 and a rejected commit was
+# reported as a successful sync. It also runs on every agent launch, so it
+# has to keep committing with the remote out of reach.
+echo ""
+echo "== agents-init (commit reporting) =="
+
+set -l ip (new_repo)
+pushd $ip >/dev/null
+set -l irc (agents-init --silent 2>/dev/null; echo $status)
+popd >/dev/null
+check "agents-init: scaffolds and returns 0" 0 "$irc"
+check "agents-init: committed the AGENTS repo" true (test (git -C $ip/AGENTS rev-list --count HEAD) -ge 1; and echo true; or echo false)
+
+# Offline. The pull that used to run here blocked the launch until the
+# remote timed out and then took the commit down with it, so an agent's
+# edits went unrecorded on every launch away from the network.
+set -l ibare (mktemp -d); set -ga TMPDIRS $ibare
+git init -q --bare $ibare
+git -C $ip/AGENTS remote add origin $ibare
+git -C $ip/AGENTS push -q -u origin HEAD 2>/dev/null
+git -C $ip/AGENTS remote set-url origin /nonexistent/unreachable.git
+echo note >$ip/AGENTS/devlogs/offline.md
+set -l ihead (git -C $ip/AGENTS rev-list --count HEAD)
+pushd $ip >/dev/null
+set -l iorc (agents-init --silent 2>/dev/null; echo $status)
+popd >/dev/null
+check "agents-init: offline run returns 0" 0 "$iorc"
+check "agents-init: offline run still committed" (math $ihead + 1) (git -C $ip/AGENTS rev-list --count HEAD)
+check "agents-init: the offline commit holds the file" note (git -C $ip/AGENTS show HEAD:devlogs/offline.md 2>/dev/null)
+
+# A rejected commit records nothing, so reporting success tells the user
+# their agent's edits were captured when they were not. agents-init points
+# core.hooksPath at .agents-tools/hooks itself, which is where a real
+# secret scanner would sit, and the shims are only refreshed when their
+# version marker moves -- so this replacement survives the run under test.
+printf '#!/bin/sh\nexit 1\n' >$ip/AGENTS/.agents-tools/hooks/pre-commit
+chmod +x $ip/AGENTS/.agents-tools/hooks/pre-commit
+echo blocked >$ip/AGENTS/devlogs/blocked.md
+set -l ibhead (git -C $ip/AGENTS rev-list --count HEAD)
+set -l ierr (mktemp); set -ga TMPDIRS $ierr
+pushd $ip >/dev/null
+set -l ibrc (agents-init --silent 2>$ierr; echo $status)
+popd >/dev/null
+check "agents-init: a rejected commit returns non-zero" 1 "$ibrc"
+check "agents-init: a rejected commit says nothing was recorded" true (string match -q '*nothing recorded*' -- (cat $ierr); and echo true; or echo false)
+check "agents-init: a rejected commit really recorded nothing" $ibhead (git -C $ip/AGENTS rev-list --count HEAD)
+
+# ──────────────────────── 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"
+exit $TESTS_FAILED