diff --git a/functions/_agents_repo_sync.fish b/functions/_agents_repo_sync.fish
index 876ee33..eb2772b 100644
--- a/functions/_agents_repo_sync.fish
+++ b/functions/_agents_repo_sync.fish
@@ -5,13 +5,25 @@
# _agents_repo_sync
#
# DESCRIPTION
-# Pulls (when an upstream is configured), stages everything, and commits
-# with . Shared by agents-init and agents-vault.
+# Stages everything in and commits it with . 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 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 () " line on stdout when it
@@ -38,12 +50,11 @@ 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
- return 2
- end
+ # 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
diff --git a/functions/agents-init.fish b/functions/agents-init.fish
index b461f68..8c5ef93 100644
--- a/functions/agents-init.fish
+++ b/functions/agents-init.fish
@@ -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
diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish
index 5b41da0..393d8ae 100644
--- a/functions/agents-vault.fish
+++ b/functions/agents-vault.fish
@@ -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 agy_copied 1
+ 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//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,15 +916,56 @@ 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
- 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
+ # 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
+ # 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
diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish
index 51b6efe..eee5415 100644
--- a/tests/test-agents-vault.fish
+++ b/tests/test-agents-vault.fish
@@ -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//
+# 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