feat(agents-vault): back up curated agent memory to a host-scoped vault repo #126

Merged
rootiest merged 19 commits from feat/agent-memory-vault into main 2026-09-03 23:09:57 +00:00
4 changed files with 560 additions and 82 deletions
Showing only changes of commit 16ea31289d - Show all commits
+23 -12
View File
@@ -5,13 +5,25 @@
# _agents_repo_sync <dir> <message>
#
# DESCRIPTION
# Pulls (when an upstream is configured), stages everything, and commits
# with <message>. Shared by agents-init and agents-vault.
# Stages everything in <dir> and commits it with <message>. Shared by
# agents-init and agents-vault.
#
# A failed rebase is aborted and nothing is committed. Committing blindly
# after a failed pull would stage conflict markers and record them under a
# routine-looking message, so the failure is surfaced instead: the repo is
# left clean at local HEAD for the user to resolve by hand.
# 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
@@ -26,7 +38,7 @@
# 0 Committed, or nothing needed committing
# 1 <dir> is not a git repository, arguments were missing, or the commit
# itself failed (e.g. a pre-commit/commit-msg hook rejected it)
# 2 Rebase conflict; aborted, nothing committed
# 2 A rebase is in progress; nothing committed, nothing touched
#
# RETURNS
# A single "→ Committed (<sha>) <subject>" line on stdout when it
@@ -38,13 +50,12 @@ function _agents_repo_sync --argument-names dir msg
test -n "$dir" -a -n "$msg"; or return 1
test -d "$dir/.git"; or return 1
if git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1
if not git -C "$dir" pull --rebase --autostash -q >/dev/null 2>/dev/null
git -C "$dir" rebase --abort >/dev/null 2>/dev/null
echo "_agents_repo_sync: rebase conflict in $dir; aborted, left at local HEAD" >&2
# 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
end
git -C "$dir" add -A 2>/dev/null
set -l status_out (git -C "$dir" status --porcelain 2>/dev/null)
+32 -9
View File
@@ -53,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.
#
@@ -71,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
@@ -452,14 +458,27 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi
end
# ──────────────────────── Auto-commit AGENTS/ ────────────────────────────
# Pulls first when an upstream is configured (no-op for local-only repos)
# and refuses to commit a failed rebase's conflict markers.
# 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 conflict; nothing committed$c_reset" >&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"
@@ -473,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
+188 -18
View File
@@ -34,7 +34,11 @@
#
# 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.
# 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.
#
# Global state that belongs to no project is tracked as well. Claude's
# global memory directory (~/.claude/memory) is symlinked into the vault
@@ -53,7 +57,13 @@
# 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.
# 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
@@ -77,13 +87,18 @@
# 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 and then
# pushes there.
# --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 and push to the vault remote
# --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
@@ -176,7 +191,7 @@ function agents-vault --description 'track curated agent memory in a host-scoped
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 and push to the vault remote"
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"
@@ -306,7 +321,11 @@ function agents-vault --description 'track curated agent memory in a host-scoped
if not test -f "$vault/.gitignore"
printf '%s\n' \
'# SQLite sidecars are never safe to commit mid-write.' \
'# 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' \
'' \
@@ -481,6 +500,13 @@ function agents-vault --description 'track curated agent memory in a host-scoped
# 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
@@ -580,13 +606,45 @@ function agents-vault --description 'track curated agent memory in a host-scoped
# leaves the agent fully working, unlike a broken memory symlink, and
# this runs on every agent launch.
set -l agy_copied 0
if test -d "$agy_root/knowledge"
if not mkdir -p "$vault/global/agy/knowledge"
echo "$c_err""agents-vault: could not create $vault/global/agy/knowledge$c_reset" >&2
else if not command cp -r "$agy_root/knowledge/." "$vault/global/agy/knowledge/"
echo "$c_warn""agents-vault: could not copy the agy knowledge store$c_reset" >&2
else
set -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.
set -l kfiles $knowledge/**.md $knowledge/**.json
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"
@@ -701,12 +759,83 @@ function agents-vault --description 'track curated agent memory in a host-scoped
echo "$c_err"" Resolve with: agents-vault --adopt=SLUG$c_reset" >&2
return 1
end
test -d "$vmem"; and rm -rf "$vault/projects/$slug"
# 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/<slug>/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
command mv "$vault/projects/$prev_slug" "$vault/projects/$slug"; or return 1
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) \
>>"$vault/projects/$slug/origin"
>>"$entry/origin"
rm -f "$live"
set changed 1
test $verbose -eq 1; and echo "$c_ok→ Migrated vault entry $prev_slug$slug$c_reset"
@@ -765,7 +894,7 @@ function agents-vault --description 'track curated agent memory in a host-scoped
set -l sync_out (_agents_repo_sync "$vault" "$msg")
set -l sync_rc $status
if test $sync_rc -eq 2
echo "$c_warn""agents-vault: unresolved rebase conflict in the vault; nothing committed$c_reset" >&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
@@ -787,7 +916,47 @@ function agents-vault --description 'track curated agent memory in a host-scoped
end
if test $do_push -eq 1
if git -C "$vault" remote get-url origin >/dev/null 2>&1
if git -C "$vault" push -q origin HEAD
# 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.
set -l reached 1
if git -C "$vault" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1
if not GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=true \
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 GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=true 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
@@ -797,6 +966,7 @@ function agents-vault --description 'track curated agent memory in a host-scoped
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
+307 -33
View File
@@ -185,14 +185,12 @@ 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"
# Conflict: two clones diverge with conflicting commits on the same line.
# (Merely leaving "ours" uncommitted in the worktree isn't enough to force
# a rebase conflict -- with nothing local to replay, --autostash's rebase
# step fast-forwards cleanly and only the stash *pop* would conflict,
# leaving "theirs" committed on HEAD with "ours" stranded in the stash.
# Committing "ours" locally first means the rebase itself must replay a
# real commit over "theirs" on the same line, which is where the intended
# conflict-and-abort path actually lives.)
# 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
@@ -209,14 +207,39 @@ git -C $clone commit -qam theirs
git -C $clone push -q origin HEAD:main
echo ours >$s/a.md
git -C $s commit -qam ours
set -l before_count (git -C $s rev-list --count HEAD)
set -l rc (_agents_repo_sync $s "chore: test" 2>/dev/null; echo $status)
check "conflict returns 2" 2 "$rc"
check "conflict leaves no rebase in progress" false (test -d $s/.git/rebase-merge -o -d $s/.git/rebase-apply; and echo true; or echo false)
check "conflict commits nothing" $before_count (git -C $s rev-list --count HEAD)
check "conflict content survives" ours (cat $s/a.md)
check "conflict left no markers" false (grep -q '<<<<<<<' $s/a.md; and echo true; or echo false)
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" --
@@ -414,9 +437,18 @@ 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
# Pre-create the destination entry as an empty directory -- present, not
# absent -- before migration runs.
mkdir -p $vroot2/agent-vault/projects/$enew_slug/claude/memory
# 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/<slug>/
# 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)
@@ -424,6 +456,35 @@ 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).
@@ -505,6 +566,98 @@ check "fallback old entry removed" false (test -d $vroot2/agent-vault/projects/$
set -e __fish_agent_vault_dir
set -e __fish_agent_vault_claude_root
# ───────────────────────── 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
@@ -530,6 +683,22 @@ echo '{"model":"x"}' >$agy5/settings.json
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
# 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*
@@ -548,6 +717,19 @@ check "agy knowledge is a copy not a link" false (test -L $vroot5/agent-vault/gl
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)
# 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)
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)
@@ -1241,10 +1423,12 @@ 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 sync fails: a rebase conflict in the vault, which
# _agents_repo_sync aborts (exit 2) rather than committing conflict markers
# under a routine-looking message. That is a backup that did not happen
# too, and must not exit 0 either.
# 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
@@ -1279,21 +1463,61 @@ 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 has a conflicting commit of its own waiting to be
# replayed on top. It has to be committed: an uncommitted change is merely
# autostashed, and the rebase then fast-forwards instead of conflicting.
echo ours >$croot14/$cmang14/memory/keep.md
git -C $vroot14/agent-vault -c user.email=t@t -c user.name=t \
-c commit.gpgsign=false -c core.hooksPath=/dev/null commit -qam ours
# ... 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 "vault rebase conflict returns non-zero" 1 "$crc14"
check "vault rebase conflict says nothing was committed" true (string match -q '*nothing committed*' -- (cat $cerr14); and echo true; or echo false)
check "vault 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 rebase conflict kept the local memory" ours (cat $croot14/$cmang14/memory/keep.md)
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
@@ -1338,6 +1562,56 @@ 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