feat(agents-vault): back up curated agent memory to a host-scoped vault repo #126
@@ -5,13 +5,25 @@
|
|||||||
# _agents_repo_sync <dir> <message>
|
# _agents_repo_sync <dir> <message>
|
||||||
#
|
#
|
||||||
# DESCRIPTION
|
# DESCRIPTION
|
||||||
# Pulls (when an upstream is configured), stages everything, and commits
|
# Stages everything in <dir> and commits it with <message>. Shared by
|
||||||
# with <message>. Shared by agents-init and agents-vault.
|
# agents-init and agents-vault.
|
||||||
#
|
#
|
||||||
# A failed rebase is aborted and nothing is committed. Committing blindly
|
# It never touches the network, and that is the point rather than an
|
||||||
# after a failed pull would stage conflict markers and record them under a
|
# omission. Both callers run on every agent launch, synchronously, ahead
|
||||||
# routine-looking message, so the failure is surfaced instead: the repo is
|
# of the agent itself, and a fetch there blocks the launch for as long as
|
||||||
# left clean at local HEAD for the user to resolve by hand.
|
# 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
|
# 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
|
# 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
|
# 0 Committed, or nothing needed committing
|
||||||
# 1 <dir> is not a git repository, arguments were missing, or the commit
|
# 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)
|
# 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
|
# RETURNS
|
||||||
# A single "→ Committed (<sha>) <subject>" line on stdout when it
|
# 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 -n "$dir" -a -n "$msg"; or return 1
|
||||||
test -d "$dir/.git"; 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
|
# The guard above proved .git is a directory, so these are the same two
|
||||||
if not git -C "$dir" pull --rebase --autostash -q >/dev/null 2>/dev/null
|
# paths `agents-vault --status` reports an unresolved rebase from.
|
||||||
git -C "$dir" rebase --abort >/dev/null 2>/dev/null
|
if test -d "$dir/.git/rebase-merge"; or test -d "$dir/.git/rebase-apply"
|
||||||
echo "_agents_repo_sync: rebase conflict in $dir; aborted, left at local HEAD" >&2
|
echo "_agents_repo_sync: unresolved rebase in $dir; nothing committed" >&2
|
||||||
return 2
|
return 2
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
git -C "$dir" add -A 2>/dev/null
|
git -C "$dir" add -A 2>/dev/null
|
||||||
|
|||||||
@@ -53,11 +53,16 @@
|
|||||||
#
|
#
|
||||||
# With no flags, runs both --agents and --plugins setup; --agents re-runs
|
# With no flags, runs both --agents and --plugins setup; --agents re-runs
|
||||||
# only the AGENTS.md / symlink step and --plugins only the plans/specs/
|
# only the AGENTS.md / symlink step and --plugins only the plans/specs/
|
||||||
# devlogs wiring step. Managed paths are added to .gitignore. The sub-repo
|
# devlogs wiring step. Managed paths are added to .gitignore. At the end
|
||||||
# is pulled first when it has an upstream, and at the end of every
|
# of every invocation any uncommitted changes inside the sub-repo are
|
||||||
# invocation any uncommitted changes inside it are auto-committed so
|
# auto-committed so agent-made edits are captured automatically. Fully
|
||||||
# agent-made edits are captured automatically. Fully idempotent: a second
|
# idempotent: a second run produces no output and no new commits.
|
||||||
# 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.
|
# Called automatically by the claude and agy wrappers on every invocation.
|
||||||
#
|
#
|
||||||
@@ -71,7 +76,8 @@
|
|||||||
#
|
#
|
||||||
# EXIT STATUS
|
# EXIT STATUS
|
||||||
# 0 Setup completed successfully
|
# 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
|
# EXAMPLE
|
||||||
# agents-init
|
# agents-init
|
||||||
@@ -452,14 +458,27 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi
|
|||||||
end
|
end
|
||||||
|
|
||||||
# ──────────────────────── Auto-commit AGENTS/ ────────────────────────────
|
# ──────────────────────── Auto-commit AGENTS/ ────────────────────────────
|
||||||
# Pulls first when an upstream is configured (no-op for local-only repos)
|
# Purely local: no fetch, no push. This function runs synchronously on
|
||||||
# and refuses to commit a failed rebase's conflict markers.
|
# 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"
|
set -l msg "chore: sync AGENTS repository"
|
||||||
test $did_init -eq 1; and set msg "chore: initialize 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_out (_agents_repo_sync "$agents_dir" "$msg")
|
||||||
set -l sync_rc $status
|
set -l sync_rc $status
|
||||||
|
set -l failed 0
|
||||||
if test $sync_rc -eq 2
|
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"
|
else if test -n "$sync_out"
|
||||||
set changed 1
|
set changed 1
|
||||||
test $verbose -eq 1; and echo "$c_ok$sync_out$c_reset"
|
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"
|
echo "$c_ok→ Synced AGENTS scaffolding$c_reset"
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
+197
-27
@@ -34,7 +34,11 @@
|
|||||||
#
|
#
|
||||||
# Only curated memory is tracked. Session transcripts are excluded (tens
|
# Only curated memory is tracked. Session transcripts are excluded (tens
|
||||||
# of megabytes per project, growing per session). Paths are allowlisted,
|
# 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 state that belongs to no project is tracked as well. Claude's
|
||||||
# global memory directory (~/.claude/memory) is symlinked into the vault
|
# 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
|
# target (no guessing) and migrates that entry to the new slug before
|
||||||
# relinking, so memory accumulated under the old key is never orphaned.
|
# relinking, so memory accumulated under the old key is never orphaned.
|
||||||
# If both the old and new entries already hold content the migration is
|
# 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,
|
# Run with no flags, the command scaffolds the vault, syncs global state,
|
||||||
# links the current project, and commits. The other modes are exclusive
|
# 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
|
# live memory directory cannot be repinned onto the new entry the rename
|
||||||
# is rolled back, so an ordinary run still finds the original entry.
|
# is rolled back, so an ordinary run still finds the original entry.
|
||||||
#
|
#
|
||||||
# --remote=URL points the vault at a remote; --push commits and then
|
# --remote=URL points the vault at a remote; --push commits, pulls, and
|
||||||
# pushes there.
|
# 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
|
# ARGUMENTS
|
||||||
# --link Scaffold the vault and link this project's memory; skip
|
# --link Scaffold the vault and link this project's memory; skip
|
||||||
# the final commit
|
# 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
|
# --restore Walk the vault, relink what is possible, report the rest
|
||||||
# --status Show entries, link health, remote state, and orphans
|
# --status Show entries, link health, remote state, and orphans
|
||||||
# --adopt=SLUG Bind the current project to an existing vault entry
|
# --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_head""Options:$c_reset"
|
||||||
echo " $c_flag-h$c_reset, $c_flag--help$c_reset Show this help message"
|
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--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--restore$c_reset Relink everything possible, report the rest"
|
||||||
echo " $c_flag--status$c_reset Show entries, link health, remote, orphans"
|
echo " $c_flag--status$c_reset Show entries, link health, remote, orphans"
|
||||||
echo " $c_flag--adopt$c_reset=SLUG Bind this project to an existing vault entry"
|
echo " $c_flag--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"
|
if not test -f "$vault/.gitignore"
|
||||||
printf '%s\n' \
|
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-wal' \
|
||||||
'*.db-shm' \
|
'*.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
|
# index.lock, say -- after which the index stays
|
||||||
# half-applied while nothing says so.
|
# half-applied while nothing says so.
|
||||||
git -C "$vault" add -A -- projects
|
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
|
return 1
|
||||||
end
|
end
|
||||||
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
|
# leaves the agent fully working, unlike a broken memory symlink, and
|
||||||
# this runs on every agent launch.
|
# this runs on every agent launch.
|
||||||
set -l agy_copied 0
|
set -l agy_copied 0
|
||||||
if test -d "$agy_root/knowledge"
|
set -l knowledge "$agy_root/knowledge"
|
||||||
if not mkdir -p "$vault/global/agy/knowledge"
|
if test -d "$knowledge"
|
||||||
echo "$c_err""agents-vault: could not create $vault/global/agy/knowledge$c_reset" >&2
|
# The allowlist has to hold *inside* knowledge/ too, not just at the
|
||||||
else if not command cp -r "$agy_root/knowledge/." "$vault/global/agy/knowledge/"
|
# agy root. A recursive copy of the directory is a denylist wearing
|
||||||
echo "$c_warn""agents-vault: could not copy the agy knowledge store$c_reset" >&2
|
# an allowlist's clothes: it promises that nothing new upstream adds
|
||||||
else
|
# can leak in while copying, verbatim, whatever upstream chooses to
|
||||||
set agy_copied 1
|
# 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
|
||||||
end
|
end
|
||||||
if test -f "$agy_root/settings.json"
|
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
|
echo "$c_err"" Resolve with: agents-vault --adopt=SLUG$c_reset" >&2
|
||||||
return 1
|
return 1
|
||||||
end
|
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
|
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
|
end
|
||||||
printf 'renamed: %s → %s (%s)\n' "$prev_slug" "$slug" (date -I) \
|
printf 'renamed: %s → %s (%s)\n' "$prev_slug" "$slug" (date -I) \
|
||||||
>>"$vault/projects/$slug/origin"
|
>>"$entry/origin"
|
||||||
rm -f "$live"
|
rm -f "$live"
|
||||||
set changed 1
|
set changed 1
|
||||||
test $verbose -eq 1; and echo "$c_ok→ Migrated vault entry $prev_slug → $slug$c_reset"
|
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_out (_agents_repo_sync "$vault" "$msg")
|
||||||
set -l sync_rc $status
|
set -l sync_rc $status
|
||||||
if test $sync_rc -eq 2
|
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
|
set failed 1
|
||||||
else if test $sync_rc -ne 0
|
else if test $sync_rc -ne 0
|
||||||
echo "$c_err""agents-vault: the vault commit failed; nothing recorded$c_reset" >&2
|
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
|
end
|
||||||
if test $do_push -eq 1
|
if test $do_push -eq 1
|
||||||
if git -C "$vault" remote get-url origin >/dev/null 2>&1
|
if git -C "$vault" remote get-url origin >/dev/null 2>&1
|
||||||
if git -C "$vault" push -q origin HEAD
|
# The pull belongs here and nowhere earlier. Fetching is only
|
||||||
test $verbose -eq 1; and echo "$c_ok→ Pushed the vault to origin$c_reset"
|
# ever needed in order to push; committing needs no remote at
|
||||||
else
|
# all. Keeping it on the commit path put a network round trip
|
||||||
# The commit above did happen, so the memory is safe
|
# in front of every agent launch, where an unreachable remote
|
||||||
# locally and the next push will carry it -- but nothing
|
# blocks the launch until it times out and a credential prompt
|
||||||
# left this machine, which is the whole point of pushing,
|
# has nobody to answer it -- and, worse, a failed fetch there
|
||||||
# so this is a failure and not a warning to walk past.
|
# took the local commit down with it, so an offline laptop
|
||||||
echo "$c_err""agents-vault: push failed; the vault is committed locally but not backed up off this machine$c_reset" >&2
|
# silently stopped being backed up at all.
|
||||||
set failed 1
|
#
|
||||||
|
# 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
|
end
|
||||||
else if set -q _flag_push
|
else if set -q _flag_push
|
||||||
# An explicit --push that pushed nowhere must not read as a
|
# An explicit --push that pushed nowhere must not read as a
|
||||||
|
|||||||
+307
-33
@@ -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, no new commit" 1 (git -C $s rev-list --count HEAD)
|
||||||
check "idempotent, silent" "" "$out"
|
check "idempotent, silent" "" "$out"
|
||||||
|
|
||||||
# Conflict: two clones diverge with conflicting commits on the same line.
|
# A diverged upstream is no longer this function's business. It commits
|
||||||
# (Merely leaving "ours" uncommitted in the worktree isn't enough to force
|
# locally and never fetches, so neither divergence nor an unreachable
|
||||||
# a rebase conflict -- with nothing local to replay, --autostash's rebase
|
# remote may stop the commit -- that is the entire offline-backup
|
||||||
# step fast-forwards cleanly and only the stash *pop* would conflict,
|
# guarantee, and the old pull-first shape broke it: a failed fetch took
|
||||||
# leaving "theirs" committed on HEAD with "ours" stranded in the stash.
|
# the local commit down with it. The divergence is still built here so
|
||||||
# Committing "ours" locally first means the rebase itself must replay a
|
# that guarantee is tested against the case that used to fail.
|
||||||
# real commit over "theirs" on the same line, which is where the intended
|
|
||||||
# conflict-and-abort path actually lives.)
|
|
||||||
set -l origin (mktemp -d); set -ga TMPDIRS $origin
|
set -l origin (mktemp -d); set -ga TMPDIRS $origin
|
||||||
git -C $origin init -q --bare
|
git -C $origin init -q --bare
|
||||||
git -C $s remote add origin $origin
|
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
|
git -C $clone push -q origin HEAD:main
|
||||||
|
|
||||||
echo ours >$s/a.md
|
echo ours >$s/a.md
|
||||||
git -C $s commit -qam ours
|
|
||||||
set -l before_count (git -C $s rev-list --count HEAD)
|
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)
|
set -l rc (_agents_repo_sync $s "chore: test" >/dev/null 2>/dev/null; echo $status)
|
||||||
check "conflict returns 2" 2 "$rc"
|
check "diverged upstream still commits" 0 "$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 "diverged upstream recorded the commit" (math $before_count + 1) (git -C $s rev-list --count HEAD)
|
||||||
check "conflict commits nothing" $before_count (git -C $s rev-list --count HEAD)
|
check "diverged upstream kept our content" ours (cat $s/a.md)
|
||||||
check "conflict content survives" ours (cat $s/a.md)
|
check "diverged upstream left no markers" false (grep -q '<<<<<<<' $s/a.md; and echo true; or echo false)
|
||||||
check "conflict 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
|
# Commit-hook rejection: the commit call itself fails (e.g. a secret
|
||||||
# scanner in a pre-commit hook), distinct from "not a git repository" --
|
# 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
|
git -C $emp remote add origin https://git.rootiest.dev/rootiest/emptycase.git
|
||||||
set -l enew_slug git.rootiest.dev-rootiest-emptycase
|
set -l enew_slug git.rootiest.dev-rootiest-emptycase
|
||||||
# Pre-create the destination entry as an empty directory -- present, not
|
# Built the way git itself would leave it, which is the only shape that
|
||||||
# absent -- before migration runs.
|
# matters here: git cannot track an empty directory, so an entry committed
|
||||||
mkdir -p $vroot2/agent-vault/projects/$enew_slug/claude/memory
|
# 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
|
pushd $emp >/dev/null
|
||||||
set -l erc (agents-vault --silent 2>/dev/null; echo $status)
|
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 migration succeeds" 0 "$erc"
|
||||||
check "empty-current migrated content" precious2 (cat $vroot2/agent-vault/projects/$enew_slug/claude/memory/keep.md)
|
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 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
|
# Remote-URL-rewrite transition: origin changes from one forge URL to
|
||||||
# another (distinct from adding a remote where none existed).
|
# 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_dir
|
||||||
set -e __fish_agent_vault_claude_root
|
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 ─────────────────────────────
|
# ──────────────────────────── global state ─────────────────────────────
|
||||||
# State that belongs to no project: agy's knowledge store and settings.json
|
# 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
|
# (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
|
echo secret >$agy5/history.jsonl
|
||||||
: >$agy5/conversations/c.db-wal
|
: >$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.
|
# 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:
|
# __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*
|
# 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 "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)
|
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 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 claude memory is now a link" true (test -L $chome5/memory; and echo true; or echo false)
|
||||||
check "global link points into the vault" (path resolve $vroot5/agent-vault/global/claude/memory) (path resolve $chome5/memory)
|
check "global 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_claude_home $HERMETIC_HOME/claude
|
||||||
set -g __fish_agent_vault_agy_root $HERMETIC_HOME/agy
|
set -g __fish_agent_vault_agy_root $HERMETIC_HOME/agy
|
||||||
|
|
||||||
# The other way a sync fails: a rebase conflict in the vault, which
|
# The other way a backup fails is a diverged remote -- but that is a
|
||||||
# _agents_repo_sync aborts (exit 2) rather than committing conflict markers
|
# push-time problem, not a launch-time one. The ordinary run must commit
|
||||||
# under a routine-looking message. That is a backup that did not happen
|
# regardless, because a backup that stops working the moment the remote
|
||||||
# too, and must not exit 0 either.
|
# 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 vroot14 (mktemp -d); set -ga TMPDIRS $vroot14
|
||||||
set -l croot14 (mktemp -d); set -ga TMPDIRS $croot14
|
set -l croot14 (mktemp -d); set -ga TMPDIRS $croot14
|
||||||
set -l chome14 (mktemp -d); set -ga TMPDIRS $chome14
|
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 commit -qam theirs
|
||||||
git -C $cclone14 push -q origin HEAD:main
|
git -C $cclone14 push -q origin HEAD:main
|
||||||
|
|
||||||
# ... while this one has a conflicting commit of its own waiting to be
|
# ... while this one writes conflicting memory of its own on the same
|
||||||
# replayed on top. It has to be committed: an uncommitted change is merely
|
# line. The ordinary run has to commit it: it never fetches, so the
|
||||||
# autostashed, and the rebase then fast-forwards instead of conflicting.
|
# divergence is invisible to it and irrelevant.
|
||||||
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
|
|
||||||
|
|
||||||
set -l cerr14 (mktemp); set -ga TMPDIRS $cerr14
|
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
|
pushd $cp14 >/dev/null
|
||||||
set -l crc14 (agents-vault --silent 2>$cerr14; echo $status)
|
set -l crc14 (agents-vault --silent 2>$cerr14; echo $status)
|
||||||
popd >/dev/null
|
popd >/dev/null
|
||||||
check "vault rebase conflict returns non-zero" 1 "$crc14"
|
check "diverged vault: ordinary run returns 0" 0 "$crc14"
|
||||||
check "vault rebase conflict says nothing was committed" true (string match -q '*nothing committed*' -- (cat $cerr14); and echo true; or echo false)
|
check "diverged vault: ordinary run said nothing" "" (cat $cerr14)
|
||||||
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 "diverged vault: ordinary run committed" (math $chead14 + 1) (git -C $vroot14/agent-vault rev-list --count HEAD)
|
||||||
check "vault rebase conflict kept the local memory" ours (cat $croot14/$cmang14/memory/keep.md)
|
|
||||||
|
# --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_dir
|
||||||
set -e __fish_agent_vault_claude_root
|
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_claude_home $HERMETIC_HOME/claude
|
||||||
set -g __fish_agent_vault_agy_root $HERMETIC_HOME/agy
|
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 ────────────────────────
|
# ──────────────────────── hermeticity assertion ────────────────────────
|
||||||
# The whole suite must never have touched the real global agent state. The
|
# 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
|
# failure this guards is specific: a global-memory sync with no test
|
||||||
|
|||||||
Reference in New Issue
Block a user