fix(agents-vault): repair slug migration, keep the network off the launch path

Six findings from the whole-branch review, all of which end in the same
place: a backup tool reporting success while nothing was backed up.

Slug migration nested the old entry inside the new one. The clear before
the rename was gated on the destination's claude/memory subdirectory
rather than on the destination itself, so an entry that exists without
one survived, `git mv A B` moved A *inside* B, and the mkdir below
fabricated a fresh empty memory directory for the live link to point at.
The real memory ended up one level deeper than --status and --restore
ever look, and the run returned 0. That shape is not exotic: 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 -- and cloning the vault is this feature's own recovery path. The
destination is now moved aside the way --adopt already does it rather
than deleted (widening the rm -rf would have destroyed the clone's
origin log), its provenance is folded into the migrated entry, and every
failure path rolls back and reports.

The launch path pulled over the network. Both wrappers call agents-vault
synchronously before starting an agent, and the pull in the shared sync
helper was unguarded once an upstream existed: against a blackholed
remote it blocked the launch indefinitely and then aborted the commit,
so an offline laptop silently stopped being backed up at all. Committing
never needed a remote, so the pull moved to the push path, which was
already opt-in for exactly this reason. A failure there now distinguishes
a real rebase conflict (rebase-merge/ or rebase-apply/ present) from an
unreachable remote instead of calling both a conflict, and both network
calls set GIT_TERMINAL_PROMPT=0 and GIT_ASKPASS so they fail fast rather
than prompt with nobody watching. The helper still refuses to commit a
rebase in progress, and leaves it standing rather than aborting one it
did not start. This also restores agents-init's pre-refactor ability to
commit while offline.

The agy knowledge copy was unfiltered. The allowlist held at the agy root
and nowhere below it, so a planted .credentials.json inside knowledge/
was committed verbatim while the documentation promised nothing new
upstream added could leak in. Only *.md and *.json are copied now --
which is what the store actually holds -- so lock files, transcripts and
conversation databases are excluded by having no business in a backup
rather than by being known about. The scaffolded .gitignore also ignored
only the SQLite sidecars and not the databases, which is worse than
ignoring neither: a torn database landed in history with the write-ahead
log that would have completed it deliberately excluded. Both changes are
template-only, on a feature that has never shipped.

agents-init reported success when nothing was committed. It ended on a
branchless `if` with no arm for a failed commit, which fish resolves to
0 -- the same false zero already fixed in agents-vault, left in the
function the refactor was rewriting. It now has the arm and an explicit
final status.

The --adopt forward-failure path with no stash left a raw coreutils `mv:`
line and no statement that the adopt had been abandoned cleanly; it is
branded like every other error exit in the function.

Tests: the suite now clones a vault with git and runs agents-vault
against the clone, instead of trusting hand-built fixtures to have shapes
git can actually produce -- that blind spot shipped both of the merge
blockers. The "present but empty" migration fixture is rebuilt as the
origin-only directory a clone leaves behind, with the hand-built shape
kept as a separate case. Reverting each fix drops the suite from 285 to
279 (migration), 261 (network), 275 (knowledge allowlist) and 283
(agents-init status).
This commit is contained in:
2026-09-03 18:56:46 -04:00
parent 2ad5bf75d2
commit 16ea31289d
4 changed files with 560 additions and 82 deletions
+24 -13
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,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
+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
+197 -27
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 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/<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,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
+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