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