diff --git a/functions/_agents_repo_sync.fish b/functions/_agents_repo_sync.fish index 876ee33..eb2772b 100644 --- a/functions/_agents_repo_sync.fish +++ b/functions/_agents_repo_sync.fish @@ -5,13 +5,25 @@ # _agents_repo_sync # # DESCRIPTION -# Pulls (when an upstream is configured), stages everything, and commits -# with . Shared by agents-init and agents-vault. +# Stages everything in and commits it with . Shared by +# agents-init and agents-vault. # -# A failed rebase is aborted and nothing is committed. Committing blindly -# after a failed pull would stage conflict markers and record them under a -# routine-looking message, so the failure is surfaced instead: the repo is -# left clean at local HEAD for the user to resolve by hand. +# It never touches the network, and that is the point rather than an +# omission. Both callers run on every agent launch, synchronously, ahead +# of the agent itself, and a fetch there blocks the launch for as long as +# an unreachable remote takes to time out and can prompt for credentials +# invisibly underneath a starting agent. Committing needs no remote at +# all -- only pushing does -- so the pull lives on agents-vault's push +# path, which is already opt-in for exactly this reason. An offline +# laptop therefore still gets a complete local backup, which is the whole +# point of keeping one. +# +# A rebase already in progress is refused rather than committed: the +# worktree then holds conflict markers, and recording those under a +# routine-looking message buries the conflict in the history instead of +# reporting it. The rebase is left exactly as it stands -- this function +# did not start it, so it is not this function's to abort -- and the +# caller says so. # # Commits are made with commit.gpgsign=false so a pinentry prompt can # never block a shell or an agent launch. If a pre-commit or commit-msg @@ -26,7 +38,7 @@ # 0 Committed, or nothing needed committing # 1 is not a git repository, arguments were missing, or the commit # itself failed (e.g. a pre-commit/commit-msg hook rejected it) -# 2 Rebase conflict; aborted, nothing committed +# 2 A rebase is in progress; nothing committed, nothing touched # # RETURNS # A single "→ Committed () " line on stdout when it @@ -38,12 +50,11 @@ function _agents_repo_sync --argument-names dir msg test -n "$dir" -a -n "$msg"; or return 1 test -d "$dir/.git"; or return 1 - if git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1 - if not git -C "$dir" pull --rebase --autostash -q >/dev/null 2>/dev/null - git -C "$dir" rebase --abort >/dev/null 2>/dev/null - echo "_agents_repo_sync: rebase conflict in $dir; aborted, left at local HEAD" >&2 - return 2 - end + # The guard above proved .git is a directory, so these are the same two + # paths `agents-vault --status` reports an unresolved rebase from. + if test -d "$dir/.git/rebase-merge"; or test -d "$dir/.git/rebase-apply" + echo "_agents_repo_sync: unresolved rebase in $dir; nothing committed" >&2 + return 2 end git -C "$dir" add -A 2>/dev/null diff --git a/functions/agents-init.fish b/functions/agents-init.fish index b461f68..8c5ef93 100644 --- a/functions/agents-init.fish +++ b/functions/agents-init.fish @@ -53,11 +53,16 @@ # # With no flags, runs both --agents and --plugins setup; --agents re-runs # only the AGENTS.md / symlink step and --plugins only the plans/specs/ -# devlogs wiring step. Managed paths are added to .gitignore. The sub-repo -# is pulled first when it has an upstream, and at the end of every -# invocation any uncommitted changes inside it are auto-committed so -# agent-made edits are captured automatically. Fully idempotent: a second -# run produces no output and no new commits. +# devlogs wiring step. Managed paths are added to .gitignore. At the end +# of every invocation any uncommitted changes inside the sub-repo are +# auto-committed so agent-made edits are captured automatically. Fully +# idempotent: a second run produces no output and no new commits. +# +# The commit is local only. Nothing here fetches or pushes: the wrappers +# call this synchronously before starting an agent, and a network round +# trip there blocks the launch until an unreachable remote times out and +# can prompt for credentials with nobody watching. A sub-repo that has an +# upstream is pulled by hand, on the user's own schedule. # # Called automatically by the claude and agy wrappers on every invocation. # @@ -71,7 +76,8 @@ # # EXIT STATUS # 0 Setup completed successfully -# 1 Fatal error (git init failed, move failed, etc.) +# 1 Fatal error (git init failed, move failed, the AGENTS/ commit was +# rejected, or an unresolved rebase blocked it) # # EXAMPLE # agents-init @@ -452,14 +458,27 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi end # ──────────────────────── Auto-commit AGENTS/ ──────────────────────────── - # Pulls first when an upstream is configured (no-op for local-only repos) - # and refuses to commit a failed rebase's conflict markers. + # Purely local: no fetch, no push. This function runs synchronously on + # every agent launch, and a network round trip there blocks the launch + # for as long as an unreachable remote takes to time out. Committing + # never needed one -- see _agents_repo_sync. + # + # Every way the commit can fail is an arm of its own. A sync that did + # not commit means agent-made edits were not captured, so it is a + # failure rather than a line to walk past -- and the missing `-ne 0` + # arm was not a cosmetic gap: fish resolves a branchless `if` to 0, so + # a hook-rejected commit fell straight through to a reported success. set -l msg "chore: sync AGENTS repository" test $did_init -eq 1; and set msg "chore: initialize AGENTS repository" set -l sync_out (_agents_repo_sync "$agents_dir" "$msg") set -l sync_rc $status + set -l failed 0 if test $sync_rc -eq 2 - echo "$c_warn→ AGENTS/ has an unresolved rebase conflict; nothing committed$c_reset" >&2 + echo "$c_warn→ AGENTS/ has an unresolved rebase; nothing committed$c_reset" >&2 + set failed 1 + else if test $sync_rc -ne 0 + echo "$c_err""Error: the AGENTS/ commit failed; nothing recorded$c_reset" >&2 + set failed 1 else if test -n "$sync_out" set changed 1 test $verbose -eq 1; and echo "$c_ok$sync_out$c_reset" @@ -473,4 +492,8 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi echo "$c_ok→ Synced AGENTS scaffolding$c_reset" end end + + # Explicit, because the branchless `if` above resolves to 0 and would + # otherwise be this function's exit status. + test $failed -eq 0 end diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish index 5b41da0..393d8ae 100644 --- a/functions/agents-vault.fish +++ b/functions/agents-vault.fish @@ -34,7 +34,11 @@ # # Only curated memory is tracked. Session transcripts are excluded (tens # of megabytes per project, growing per session). Paths are allowlisted, -# never denylisted, so nothing new upstream adds can leak in. +# never denylisted, so nothing new upstream adds can leak in. The +# allowlist runs all the way down, not just at the top: inside agy's +# knowledge store only *.md and *.json files are copied, so a credential +# file or a conversation database appearing there is left behind by the +# same rule rather than by being known about in advance. # # Global state that belongs to no project is tracked as well. Claude's # global memory directory (~/.claude/memory) is symlinked into the vault @@ -53,7 +57,13 @@ # target (no guessing) and migrates that entry to the new slug before # relinking, so memory accumulated under the old key is never orphaned. # If both the old and new entries already hold content the migration is -# ambiguous and is refused; resolve it with --adopt=SLUG. +# ambiguous and is refused; resolve it with --adopt=SLUG. An entry that +# is already at the new key but holds no memory -- the shape a fresh +# clone always produces, since git cannot track an empty directory -- is +# moved aside, not deleted, and its origin log is folded into the +# migrated entry, so a clone's provenance survives the rename. The +# rename is atomic: a failure at any point leaves the vault exactly as +# it was and reports it. # # Run with no flags, the command scaffolds the vault, syncs global state, # links the current project, and commits. The other modes are exclusive @@ -77,13 +87,18 @@ # live memory directory cannot be repinned onto the new entry the rename # is rolled back, so an ordinary run still finds the original entry. # -# --remote=URL points the vault at a remote; --push commits and then -# pushes there. +# --remote=URL points the vault at a remote; --push commits, pulls, and +# then pushes there. The pull happens only on this path. Committing needs +# no remote at all, and both wrappers run this command synchronously +# before starting an agent, so a fetch on the ordinary run would block +# every launch for as long as an unreachable remote takes to time out -- +# and would take the local commit down with it, leaving an offline +# machine with no backup at all. # # ARGUMENTS # --link Scaffold the vault and link this project's memory; skip # the final commit -# --push Commit and push to the vault remote +# --push Commit, pull, then push to the vault remote # --restore Walk the vault, relink what is possible, report the rest # --status Show entries, link health, remote state, and orphans # --adopt=SLUG Bind the current project to an existing vault entry @@ -176,7 +191,7 @@ function agents-vault --description 'track curated agent memory in a host-scoped echo "$c_head""Options:$c_reset" echo " $c_flag-h$c_reset, $c_flag--help$c_reset Show this help message" echo " $c_flag--link$c_reset Scaffold + link this project; skip the commit" - echo " $c_flag--push$c_reset Commit and push to the vault remote" + echo " $c_flag--push$c_reset Commit, pull, then push to the remote" echo " $c_flag--restore$c_reset Relink everything possible, report the rest" echo " $c_flag--status$c_reset Show entries, link health, remote, orphans" echo " $c_flag--adopt$c_reset=SLUG Bind this project to an existing vault entry" @@ -306,7 +321,11 @@ function agents-vault --description 'track curated agent memory in a host-scoped if not test -f "$vault/.gitignore" printf '%s\n' \ - '# SQLite sidecars are never safe to commit mid-write.' \ + '# A SQLite database and its sidecars are never safe to commit' \ + '# mid-write. Ignoring only the sidecars is worse than ignoring' \ + '# none of them: a torn database then lands in the history with' \ + '# the write-ahead log that would have completed it excluded.' \ + '*.db' \ '*.db-wal' \ '*.db-shm' \ '' \ @@ -481,6 +500,13 @@ function agents-vault --description 'track curated agent memory in a host-scoped # index.lock, say -- after which the index stays # half-applied while nothing says so. git -C "$vault" add -A -- projects + # Without this the user is left holding a raw coreutils + # `mv:` line and no statement of what it cost them. Every + # other error exit in this function is branded and says + # what state it left behind; this one reaching the terminal + # bare made a clean rollback look like a crash. + echo "$c_err""agents-vault: could not rename $cur → $_flag_adopt$c_reset" >&2 + echo "$c_err"" The adopt was abandoned; $cur was left as it was.$c_reset" >&2 return 1 end end @@ -580,13 +606,45 @@ function agents-vault --description 'track curated agent memory in a host-scoped # leaves the agent fully working, unlike a broken memory symlink, and # this runs on every agent launch. set -l agy_copied 0 - if test -d "$agy_root/knowledge" - if not mkdir -p "$vault/global/agy/knowledge" - echo "$c_err""agents-vault: could not create $vault/global/agy/knowledge$c_reset" >&2 - else if not command cp -r "$agy_root/knowledge/." "$vault/global/agy/knowledge/" - echo "$c_warn""agents-vault: could not copy the agy knowledge store$c_reset" >&2 - else - set agy_copied 1 + set -l knowledge "$agy_root/knowledge" + if test -d "$knowledge" + # The allowlist has to hold *inside* knowledge/ too, not just at the + # agy root. A recursive copy of the directory is a denylist wearing + # an allowlist's clothes: it promises that nothing new upstream adds + # can leak in while copying, verbatim, whatever upstream chooses to + # put one level down. A .credentials.json dropped in there went + # straight into a commit. + # + # Extensions, because that is what the store actually is: agy's + # knowledge is written as Markdown notes with JSON metadata beside + # them. Everything else there is machinery, not knowledge -- + # knowledge.lock is a live lock file whose committed copy is at best + # meaningless and at worst confusing on restore, and the SQLite + # databases must never be captured mid-write. Both are excluded by + # having no business in a backup, not by being individually known + # about, which is the property that survives upstream adding a file. + # + # Fish wildcards skip dot-led names at every path component, so + # dotfiles and hidden subdirectories are already out; the extension + # allowlist is what keeps them out on purpose rather than by luck. + set -l kfiles $knowledge/**.md $knowledge/**.json + set -l kfailed 0 + for f in $kfiles + test -f "$f"; or continue + set -l rel (string replace -- "$knowledge/" "" "$f") + set -l dest "$vault/global/agy/knowledge/$rel" + if not mkdir -p (path dirname "$dest") + set kfailed 1 + continue + end + if command cp "$f" "$dest" + set agy_copied 1 + else + set kfailed 1 + end + end + if test $kfailed -eq 1 + echo "$c_warn""agents-vault: could not copy part of the agy knowledge store$c_reset" >&2 end end if test -f "$agy_root/settings.json" @@ -701,12 +759,83 @@ function agents-vault --description 'track curated agent memory in a host-scoped echo "$c_err"" Resolve with: agents-vault --adopt=SLUG$c_reset" >&2 return 1 end - test -d "$vmem"; and rm -rf "$vault/projects/$slug" + # What matters here is whether the destination *entry* exists, + # not whether it has a memory subdirectory. Gating on the + # subdirectory looks equivalent and is not: an entry can exist + # with no claude/ subtree at all, and then `git mv A B` moves A + # *inside* B, the mkdir below fabricates a fresh empty memory + # directory, the live link is pinned to that, and the real + # memory is stranded one level deeper than --status and + # --restore ever look. The run reports success while the + # backup is gone. + # + # That shape is not exotic; it is what git hands back. Git + # cannot track an empty directory, so an entry committed while + # its memory was empty materialises after a clone as + # projects//origin and nothing else -- and cloning the + # vault onto a new machine is this feature's own advertised + # recovery path. + # + # So the destination is moved aside rather than deleted, for + # the same reason --adopt does it: widening the old rm -rf + # would throw away the destination's origin log, which is real + # provenance and which the clone case always has. The stash + # lives inside .git/ -- same filesystem, so the move stays a + # rename; outside projects/, so a crash cannot leave something + # that reads as an entry; and never descended into by + # `git add -A`, so a crash cannot leave junk to be committed + # either. See --adopt above for the .git-is-not-a-directory + # fallback. + set -l stash "" + if test -d "$entry" + if test -d "$vault/.git" + set stash "$vault/.git/agents-vault-migrate-stash" + else + set stash "$vault/.migrate-stash" + end + rm -rf "$stash" + if not command mv "$entry" "$stash" + echo "$c_err""agents-vault: could not set aside the existing $slug entry$c_reset" >&2 + echo "$c_err"" The migration was abandoned; $prev_slug was left as it was.$c_reset" >&2 + return 1 + end + end if not git -C "$vault" mv "projects/$prev_slug" "projects/$slug" 2>/dev/null - command mv "$vault/projects/$prev_slug" "$vault/projects/$slug"; or return 1 + if not command mv "$vault/projects/$prev_slug" "$entry" + test -n "$stash"; and command mv "$stash" "$entry" + # Every move here is a plain rename as far as git is + # concerned, so the index still describes the + # half-applied state even once the worktree is whole + # again. projects/ is re-read whole rather than the two + # entries named, and its stderr is left visible, for + # the reasons spelled out at --adopt. + git -C "$vault" add -A -- projects + echo "$c_err""agents-vault: could not migrate $prev_slug → $slug$c_reset" >&2 + echo "$c_err"" The vault was left exactly as it was.$c_reset" >&2 + return 1 + end + end + # The set-aside entry is folded back in rather than dropped. + # -n keeps everything the migrated entry already has, so this + # only ever adds what the destination held and the migrated + # entry lacks; origin is the one file both sides always have, + # so its history is appended by hand instead. Neither failing + # is fatal -- the memory and the rename have already landed, + # and losing a provenance note is not worth undoing that. + # + # Appended, not prepended, and that order is load-bearing: + # --restore reads the *first* "path:" line out of origin, and + # that has to stay this project's own. The set-aside entry's + # path came from whichever machine created it and would send a + # restore at a directory that is not this one. + if test -n "$stash" + test -f "$stash/origin" + and command cat "$stash/origin" >>"$entry/origin" 2>/dev/null + command cp -rn "$stash/." "$entry/" 2>/dev/null + rm -rf "$stash" end printf 'renamed: %s → %s (%s)\n' "$prev_slug" "$slug" (date -I) \ - >>"$vault/projects/$slug/origin" + >>"$entry/origin" rm -f "$live" set changed 1 test $verbose -eq 1; and echo "$c_ok→ Migrated vault entry $prev_slug → $slug$c_reset" @@ -765,7 +894,7 @@ function agents-vault --description 'track curated agent memory in a host-scoped set -l sync_out (_agents_repo_sync "$vault" "$msg") set -l sync_rc $status if test $sync_rc -eq 2 - echo "$c_warn""agents-vault: unresolved rebase conflict in the vault; nothing committed$c_reset" >&2 + echo "$c_err""agents-vault: unresolved rebase in the vault; nothing committed$c_reset" >&2 set failed 1 else if test $sync_rc -ne 0 echo "$c_err""agents-vault: the vault commit failed; nothing recorded$c_reset" >&2 @@ -787,15 +916,56 @@ function agents-vault --description 'track curated agent memory in a host-scoped end if test $do_push -eq 1 if git -C "$vault" remote get-url origin >/dev/null 2>&1 - if git -C "$vault" push -q origin HEAD - test $verbose -eq 1; and echo "$c_ok→ Pushed the vault to origin$c_reset" - else - # The commit above did happen, so the memory is safe - # locally and the next push will carry it -- but nothing - # left this machine, which is the whole point of pushing, - # so this is a failure and not a warning to walk past. - echo "$c_err""agents-vault: push failed; the vault is committed locally but not backed up off this machine$c_reset" >&2 - set failed 1 + # The pull belongs here and nowhere earlier. Fetching is only + # ever needed in order to push; committing needs no remote at + # all. Keeping it on the commit path put a network round trip + # in front of every agent launch, where an unreachable remote + # blocks the launch until it times out and a credential prompt + # has nobody to answer it -- and, worse, a failed fetch there + # took the local commit down with it, so an offline laptop + # silently stopped being backed up at all. + # + # GIT_TERMINAL_PROMPT=0 and GIT_ASKPASS make git fail fast + # rather than ask. Neither disturbs a configured credential + # helper, which git consults before it ever falls back to + # prompting; they only close off the interactive last resort, + # which under a starting agent is indistinguishable from a hang. + set -l reached 1 + if git -C "$vault" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1 + if not GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=true \ + git -C "$vault" pull --rebase --autostash -q >/dev/null 2>/dev/null + # Two unrelated failures land here and reporting them as + # one sends the user hunting for a conflict that never + # existed. A rebase that genuinely started and stopped + # on a conflict leaves rebase-merge/ or rebase-apply/ + # behind; that rebase is ours, so it is aborted and the + # vault is left at local HEAD. Everything else -- an + # unreachable remote being far and away the common case + # -- never began a rebase at all. + if test -d "$vault/.git/rebase-merge"; or test -d "$vault/.git/rebase-apply" + git -C "$vault" rebase --abort >/dev/null 2>/dev/null + echo "$c_err""agents-vault: rebase conflict in the vault; aborted at local HEAD, nothing pushed$c_reset" >&2 + else + echo "$c_err""agents-vault: could not reach the vault remote; the vault is committed locally but not backed up off this machine$c_reset" >&2 + end + set reached 0 + set failed 1 + end + end + # A remote we could not read from is not worth pushing to: the + # push would only fail a second time, more confusingly, and the + # pull has already said exactly what went wrong. + if test $reached -eq 1 + if GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=true git -C "$vault" push -q origin HEAD + test $verbose -eq 1; and echo "$c_ok→ Pushed the vault to origin$c_reset" + else + # The commit above did happen, so the memory is safe + # locally and the next push will carry it -- but nothing + # left this machine, which is the whole point of pushing, + # so this is a failure and not a warning to walk past. + echo "$c_err""agents-vault: push failed; the vault is committed locally but not backed up off this machine$c_reset" >&2 + set failed 1 + end end else if set -q _flag_push # An explicit --push that pushed nowhere must not read as a diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index 51b6efe..eee5415 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -185,14 +185,12 @@ set -l out (_agents_repo_sync $s "chore: test") check "idempotent, no new commit" 1 (git -C $s rev-list --count HEAD) check "idempotent, silent" "" "$out" -# Conflict: two clones diverge with conflicting commits on the same line. -# (Merely leaving "ours" uncommitted in the worktree isn't enough to force -# a rebase conflict -- with nothing local to replay, --autostash's rebase -# step fast-forwards cleanly and only the stash *pop* would conflict, -# leaving "theirs" committed on HEAD with "ours" stranded in the stash. -# Committing "ours" locally first means the rebase itself must replay a -# real commit over "theirs" on the same line, which is where the intended -# conflict-and-abort path actually lives.) +# A diverged upstream is no longer this function's business. It commits +# locally and never fetches, so neither divergence nor an unreachable +# remote may stop the commit -- that is the entire offline-backup +# guarantee, and the old pull-first shape broke it: a failed fetch took +# the local commit down with it. The divergence is still built here so +# that guarantee is tested against the case that used to fail. set -l origin (mktemp -d); set -ga TMPDIRS $origin git -C $origin init -q --bare git -C $s remote add origin $origin @@ -209,14 +207,39 @@ git -C $clone commit -qam theirs git -C $clone push -q origin HEAD:main echo ours >$s/a.md -git -C $s commit -qam ours set -l before_count (git -C $s rev-list --count HEAD) -set -l rc (_agents_repo_sync $s "chore: test" 2>/dev/null; echo $status) -check "conflict returns 2" 2 "$rc" -check "conflict leaves no rebase in progress" false (test -d $s/.git/rebase-merge -o -d $s/.git/rebase-apply; and echo true; or echo false) -check "conflict commits nothing" $before_count (git -C $s rev-list --count HEAD) -check "conflict content survives" ours (cat $s/a.md) -check "conflict left no markers" false (grep -q '<<<<<<<' $s/a.md; and echo true; or echo false) +set -l rc (_agents_repo_sync $s "chore: test" >/dev/null 2>/dev/null; echo $status) +check "diverged upstream still commits" 0 "$rc" +check "diverged upstream recorded the commit" (math $before_count + 1) (git -C $s rev-list --count HEAD) +check "diverged upstream kept our content" ours (cat $s/a.md) +check "diverged upstream left no markers" false (grep -q '<<<<<<<' $s/a.md; and echo true; or echo false) +check "diverged upstream started no rebase" false (test -d $s/.git/rebase-merge -o -d $s/.git/rebase-apply; and echo true; or echo false) + +# An unreachable remote is a non-event for the same reason. A bogus local +# path is used rather than a real unroutable host: it fails instantly +# instead of waiting out a DNS timeout, and the code path being asserted +# is that there is no network code path at all. +git -C $s remote set-url origin /nonexistent/unreachable.git +echo offline >$s/b.md +set -l ocount (git -C $s rev-list --count HEAD) +set -l orc (_agents_repo_sync $s "chore: offline" >/dev/null 2>/dev/null; echo $status) +check "unreachable upstream still commits" 0 "$orc" +check "unreachable upstream recorded the commit" (math $ocount + 1) (git -C $s rev-list --count HEAD) +check "unreachable upstream captured the new file" offline (git -C $s show HEAD:b.md 2>/dev/null) + +# The one case that must still refuse: a rebase genuinely in progress. The +# worktree then holds conflict markers and committing them under a routine +# message buries the conflict instead of reporting it. It is left standing +# rather than aborted -- this function did not start it, so it is not its +# to throw away. +git -C $s remote set-url origin $origin +git -C $s -c core.hooksPath=/dev/null pull --rebase -q >/dev/null 2>&1 +check "fixture really left a rebase in progress" true (test -d $s/.git/rebase-merge -o -d $s/.git/rebase-apply; and echo true; or echo false) +set -l rrc (_agents_repo_sync $s "chore: blocked" 2>/dev/null; echo $status) +check "in-progress rebase returns 2" 2 "$rrc" +check "in-progress rebase recorded no commit" false (git -C $s log --all --pretty=%s 2>/dev/null | grep -qx 'chore: blocked'; and echo true; or echo false) +check "in-progress rebase is left standing" true (test -d $s/.git/rebase-merge -o -d $s/.git/rebase-apply; and echo true; or echo false) +git -C $s rebase --abort >/dev/null 2>&1 # Commit-hook rejection: the commit call itself fails (e.g. a secret # scanner in a pre-commit hook), distinct from "not a git repository" -- @@ -414,9 +437,18 @@ popd >/dev/null git -C $emp remote add origin https://git.rootiest.dev/rootiest/emptycase.git set -l enew_slug git.rootiest.dev-rootiest-emptycase -# Pre-create the destination entry as an empty directory -- present, not -# absent -- before migration runs. -mkdir -p $vroot2/agent-vault/projects/$enew_slug/claude/memory +# Built the way git itself would leave it, which is the only shape that +# matters here: git cannot track an empty directory, so an entry committed +# while its memory was empty comes back from a clone as projects// +# origin and nothing else -- no claude/ subtree at all. Hand-building it +# with claude/memory/ instead (as this fixture used to) tests a shape the +# recovery path never produces, and hid a migration that moved the old +# entry *inside* the new one while still returning 0. The equivalent +# hand-built shape is covered separately just below. +mkdir -p $vroot2/agent-vault/projects/$enew_slug +printf 'remote: %s\npath: %s\nhost: %s\n' \ + https://git.rootiest.dev/rootiest/emptycase.git /gone/elsewhere othermachine \ + >$vroot2/agent-vault/projects/$enew_slug/origin pushd $emp >/dev/null set -l erc (agents-vault --silent 2>/dev/null; echo $status) @@ -424,6 +456,35 @@ popd >/dev/null check "empty-current migration succeeds" 0 "$erc" check "empty-current migrated content" precious2 (cat $vroot2/agent-vault/projects/$enew_slug/claude/memory/keep.md) check "empty-current old entry removed" false (test -d $vroot2/agent-vault/projects/$eslug; and echo true; or echo false) +check "empty-current did not nest the old entry" false (test -d $vroot2/agent-vault/projects/$enew_slug/$eslug; and echo true; or echo false) +check "empty-current memory reachable live" precious2 (cat $croot2/$emangled/memory/keep.md) +# The destination's origin log is real provenance -- a clone always has +# one -- so it is folded in rather than deleted along with the directory. +check "empty-current kept the destination provenance" true (grep -q othermachine $vroot2/agent-vault/projects/$enew_slug/origin; and echo true; or echo false) + +# The other "present but empty" shape, for completeness: claude/memory/ +# exists and is empty. Only a hand-built vault looks like this, but the +# guard has to cover it too. +set -l em2 (new_repo) +set -l em2mangled (string replace -a '/' '-' -- $em2 | string replace -a '.' '-') +mkdir -p $croot2/$em2mangled/memory +echo precious3 >$croot2/$em2mangled/memory/keep.md +pushd $em2 >/dev/null +agents-vault --silent +set -l em2slug (_agents_repo_slug $em2) +popd >/dev/null + +git -C $em2 remote add origin https://git.rootiest.dev/rootiest/emptydir.git +set -l em2new git.rootiest.dev-rootiest-emptydir +mkdir -p $vroot2/agent-vault/projects/$em2new/claude/memory + +pushd $em2 >/dev/null +set -l em2rc (agents-vault --silent 2>/dev/null; echo $status) +popd >/dev/null +check "empty-memory-dir migration succeeds" 0 "$em2rc" +check "empty-memory-dir migrated content" precious3 (cat $vroot2/agent-vault/projects/$em2new/claude/memory/keep.md) +check "empty-memory-dir did not nest the old entry" false (test -d $vroot2/agent-vault/projects/$em2new/$em2slug; and echo true; or echo false) +check "empty-memory-dir memory reachable live" precious3 (cat $croot2/$em2mangled/memory/keep.md) # Remote-URL-rewrite transition: origin changes from one forge URL to # another (distinct from adding a remote where none existed). @@ -505,6 +566,98 @@ check "fallback old entry removed" false (test -d $vroot2/agent-vault/projects/$ set -e __fish_agent_vault_dir set -e __fish_agent_vault_claude_root +# ───────────────────────── a real git clone ──────────────────────────── +# Every other fixture in this file is hand-built, and a hand-built +# directory can have a shape git itself would never produce. That blind +# spot has now shipped two bugs. So this section builds its vault the way +# the feature's own advertised recovery path does -- run the tool, let it +# commit, then clone the result with git -- and runs agents-vault against +# the clone. +echo "" +echo "== agents-vault (a real git clone) ==" + +# Machine A: populate a vault and let agents-vault commit it. +set -l cl_vroot (mktemp -d); set -ga TMPDIRS $cl_vroot +set -l cl_croot (mktemp -d); set -ga TMPDIRS $cl_croot +set -g __fish_agent_vault_dir $cl_vroot/agent-vault +set -g __fish_agent_vault_claude_root $cl_croot + +# Two projects: one whose memory holds a file at commit time, one whose +# memory is empty. The empty one is the interesting case -- git cannot +# track an empty directory, so its entry survives the clone as origin and +# nothing else. +set -l cl_full (new_repo https://git.rootiest.dev/rootiest/clone-full.git) +set -l cl_full_slug git.rootiest.dev-rootiest-clone-full +set -l cl_full_mangled (string replace -a '/' '-' -- $cl_full | string replace -a '.' '-') +mkdir -p $cl_croot/$cl_full_mangled/memory +echo cloned-memory >$cl_croot/$cl_full_mangled/memory/keep.md +pushd $cl_full >/dev/null +agents-vault --silent +popd >/dev/null + +set -l cl_empty (new_repo https://git.rootiest.dev/rootiest/clone-empty.git) +set -l cl_empty_slug git.rootiest.dev-rootiest-clone-empty +pushd $cl_empty >/dev/null +agents-vault --silent +popd >/dev/null + +# The clone, exactly as the README tells a user to make it. +set -l cl_new (mktemp -d); set -ga TMPDIRS $cl_new +git clone -q $cl_vroot/agent-vault $cl_new/agent-vault +git -C $cl_new/agent-vault config user.email t@t +git -C $cl_new/agent-vault config user.name t +git -C $cl_new/agent-vault config commit.gpgsign false + +check "clone: the populated entry came back whole" cloned-memory (cat $cl_new/agent-vault/projects/$cl_full_slug/claude/memory/keep.md 2>/dev/null) +check "clone: the empty entry has no claude/ subtree" false (test -d $cl_new/agent-vault/projects/$cl_empty_slug/claude; and echo true; or echo false) +check "clone: the empty entry is its origin file alone" true (test -f $cl_new/agent-vault/projects/$cl_empty_slug/origin; and echo true; or echo false) + +# Machine B, case 1: an ordinary run against the clone restores memory. +set -l cl_croot2 (mktemp -d); set -ga TMPDIRS $cl_croot2 +set -g __fish_agent_vault_dir $cl_new/agent-vault +set -g __fish_agent_vault_claude_root $cl_croot2 + +set -l cl_proj (new_repo https://git.rootiest.dev/rootiest/clone-full.git) +set -l cl_proj_mangled (string replace -a '/' '-' -- $cl_proj | string replace -a '.' '-') +pushd $cl_proj >/dev/null +set -l cl_rc (agents-vault --silent 2>/dev/null; echo $status) +popd >/dev/null +check "clone: an ordinary run against the clone returns 0" 0 "$cl_rc" +check "clone: the live memory became a link" true (test -L $cl_croot2/$cl_proj_mangled/memory; and echo true; or echo false) +check "clone: memory is reachable through the live link" cloned-memory (cat $cl_croot2/$cl_proj_mangled/memory/keep.md 2>/dev/null) + +# Machine B, case 2: migrating onto the clone-shaped entry. The project +# starts with no remote (keyed local-*); adding the remote the clone's +# empty entry belongs to points the migration straight at the origin-only +# directory git produced. This is the case that used to move the old entry +# *inside* the new one, fabricate a fresh empty memory directory over it, +# pin the live link to that, and return 0 -- stranding the real memory +# one level below where --status and --restore ever look. +set -l cl_mig (new_repo) +set -l cl_mig_mangled (string replace -a '/' '-' -- $cl_mig | string replace -a '.' '-') +mkdir -p $cl_croot2/$cl_mig_mangled/memory +echo clone-precious >$cl_croot2/$cl_mig_mangled/memory/keep.md +pushd $cl_mig >/dev/null +agents-vault --silent +set -l cl_mig_slug (_agents_repo_slug $cl_mig) +popd >/dev/null +check "clone: the local entry was populated first" clone-precious (cat $cl_new/agent-vault/projects/$cl_mig_slug/claude/memory/keep.md 2>/dev/null) + +git -C $cl_mig remote add origin https://git.rootiest.dev/rootiest/clone-empty.git +pushd $cl_mig >/dev/null +set -l cl_mrc (agents-vault --silent 2>/dev/null; echo $status) +popd >/dev/null +check "clone: migration onto a cloned entry returns 0" 0 "$cl_mrc" +check "clone: migrated memory is reachable through the live link" clone-precious (cat $cl_croot2/$cl_mig_mangled/memory/keep.md 2>/dev/null) +check "clone: migrated memory landed in the new entry" clone-precious (cat $cl_new/agent-vault/projects/$cl_empty_slug/claude/memory/keep.md 2>/dev/null) +check "clone: the old entry was not nested inside the new one" false (test -d $cl_new/agent-vault/projects/$cl_empty_slug/$cl_mig_slug; and echo true; or echo false) +check "clone: the old entry is gone" false (test -d $cl_new/agent-vault/projects/$cl_mig_slug; and echo true; or echo false) +check "clone: the cloned entry's provenance survived" true (grep -q clone-empty.git $cl_new/agent-vault/projects/$cl_empty_slug/origin; and echo true; or echo false) +check "clone: the live link points at the migrated entry" (path resolve $cl_new/agent-vault/projects/$cl_empty_slug/claude/memory) (path resolve $cl_croot2/$cl_mig_mangled/memory) + +set -e __fish_agent_vault_dir +set -e __fish_agent_vault_claude_root + # ──────────────────────────── global state ───────────────────────────── # State that belongs to no project: agy's knowledge store and settings.json # (copied, because agy keys by conversation UUID and its store sits beside @@ -530,6 +683,22 @@ echo '{"model":"x"}' >$agy5/settings.json echo secret >$agy5/history.jsonl : >$agy5/conversations/c.db-wal +# The allowlist has to hold *inside* knowledge/ too, not only at the agy +# root. These are the same hostile shapes planted above, one level down -- +# where a recursive copy of the directory took them verbatim into a commit +# while the documentation promised nothing new upstream added could leak. +mkdir -p $agy5/knowledge/notes $agy5/knowledge/.hidden +echo nested >$agy5/knowledge/notes/deep.md +echo '{"k":"v"}' >$agy5/knowledge/meta.json +echo SECRET-INSIDE-KNOWLEDGE >$agy5/knowledge/.credentials.json +echo SECRET-INSIDE-KNOWLEDGE >$agy5/knowledge/.hidden/leak.json +echo SECRET-INSIDE-KNOWLEDGE >$agy5/knowledge/history.jsonl +printf 'transcript\n' >$agy5/knowledge/session.jsonl +: >$agy5/knowledge/knowledge.lock +: >$agy5/knowledge/conversations.db +: >$agy5/knowledge/conversations.db-wal +: >$agy5/knowledge/conversations.db-shm + # A global (non-per-project) Claude memory directory with a sentinel file. # __fish_agent_vault_claude_home is what keeps this off the real ~/.claude: # if agents-vault ignored the override, these checks would fail here *and* @@ -548,6 +717,19 @@ check "agy knowledge is a copy not a link" false (test -L $vroot5/agent-vault/gl check "history.jsonl not copied" false (test -e $vroot5/agent-vault/global/agy/history.jsonl; and echo true; or echo false) check "conversations not copied" false (test -e $vroot5/agent-vault/global/agy/conversations; and echo true; or echo false) +# Inside knowledge/: the notes come through, everything else stays out. +check "knowledge: nested markdown copied" nested (cat $vroot5/agent-vault/global/agy/knowledge/notes/deep.md 2>/dev/null) +check "knowledge: json metadata copied" '{"k":"v"}' (cat $vroot5/agent-vault/global/agy/knowledge/meta.json 2>/dev/null) +for decoy in .credentials.json .hidden history.jsonl session.jsonl knowledge.lock conversations.db conversations.db-wal conversations.db-shm + check "knowledge: $decoy stayed out" false (test -e $vroot5/agent-vault/global/agy/knowledge/$decoy; and echo true; or echo false) +end +# Not merely absent from the worktree: absent from the history, which is +# what actually leaves the machine on a push. +check "knowledge: no secret reached a commit" false (git -C $vroot5/agent-vault grep -q SECRET-INSIDE-KNOWLEDGE HEAD -- global 2>/dev/null; and echo true; or echo false) +# A torn database with its completing write-ahead log deliberately excluded +# is worse than no database at all, so the scaffold ignores all three. +check "scaffolded .gitignore excludes *.db" true (grep -qxF '*.db' $vroot5/agent-vault/.gitignore; and echo true; or echo false) + check "global claude memory in the vault" global-memory (cat $vroot5/agent-vault/global/claude/memory/g.md) check "global claude memory is now a link" true (test -L $chome5/memory; and echo true; or echo false) check "global link points into the vault" (path resolve $vroot5/agent-vault/global/claude/memory) (path resolve $chome5/memory) @@ -1241,10 +1423,12 @@ set -e __fish_agent_vault_claude_root set -g __fish_agent_vault_claude_home $HERMETIC_HOME/claude set -g __fish_agent_vault_agy_root $HERMETIC_HOME/agy -# The other way a sync fails: a rebase conflict in the vault, which -# _agents_repo_sync aborts (exit 2) rather than committing conflict markers -# under a routine-looking message. That is a backup that did not happen -# too, and must not exit 0 either. +# The other way a backup fails is a diverged remote -- but that is a +# push-time problem, not a launch-time one. The ordinary run must commit +# regardless, because a backup that stops working the moment the remote +# moves ahead (or goes out of reach) is not a backup; --push is where the +# divergence has to be reckoned with, and where the two ways it can fail +# have to be told apart. set -l vroot14 (mktemp -d); set -ga TMPDIRS $vroot14 set -l croot14 (mktemp -d); set -ga TMPDIRS $croot14 set -l chome14 (mktemp -d); set -ga TMPDIRS $chome14 @@ -1279,21 +1463,61 @@ echo theirs >$cclone14/projects/$cslug14/claude/memory/keep.md git -C $cclone14 commit -qam theirs git -C $cclone14 push -q origin HEAD:main -# ... while this one has a conflicting commit of its own waiting to be -# replayed on top. It has to be committed: an uncommitted change is merely -# autostashed, and the rebase then fast-forwards instead of conflicting. -echo ours >$croot14/$cmang14/memory/keep.md -git -C $vroot14/agent-vault -c user.email=t@t -c user.name=t \ - -c commit.gpgsign=false -c core.hooksPath=/dev/null commit -qam ours - +# ... while this one writes conflicting memory of its own on the same +# line. The ordinary run has to commit it: it never fetches, so the +# divergence is invisible to it and irrelevant. set -l cerr14 (mktemp); set -ga TMPDIRS $cerr14 +echo ours >$croot14/$cmang14/memory/keep.md +set -l chead14 (git -C $vroot14/agent-vault rev-list --count HEAD) pushd $cp14 >/dev/null set -l crc14 (agents-vault --silent 2>$cerr14; echo $status) popd >/dev/null -check "vault rebase conflict returns non-zero" 1 "$crc14" -check "vault rebase conflict says nothing was committed" true (string match -q '*nothing committed*' -- (cat $cerr14); and echo true; or echo false) -check "vault rebase conflict left no rebase in progress" false (test -d $vroot14/agent-vault/.git/rebase-merge -o -d $vroot14/agent-vault/.git/rebase-apply; and echo true; or echo false) -check "vault rebase conflict kept the local memory" ours (cat $croot14/$cmang14/memory/keep.md) +check "diverged vault: ordinary run returns 0" 0 "$crc14" +check "diverged vault: ordinary run said nothing" "" (cat $cerr14) +check "diverged vault: ordinary run committed" (math $chead14 + 1) (git -C $vroot14/agent-vault rev-list --count HEAD) + +# --push is where it is reckoned with: the pre-push pull replays that +# commit onto theirs, conflicts, aborts back to local HEAD, and reports a +# conflict. Nothing is pushed and the local memory survives untouched. +pushd $cp14 >/dev/null +set -l prc14 (agents-vault --push --silent 2>$cerr14; echo $status) +popd >/dev/null +check "vault push rebase conflict returns non-zero" 1 "$prc14" +check "vault push rebase conflict is named as one" true (string match -q '*rebase conflict*' -- (cat $cerr14); and echo true; or echo false) +check "vault push rebase conflict left no rebase in progress" false (test -d $vroot14/agent-vault/.git/rebase-merge -o -d $vroot14/agent-vault/.git/rebase-apply; and echo true; or echo false) +check "vault push rebase conflict kept the local memory" ours (cat $croot14/$cmang14/memory/keep.md) + +# The other push-time failure is the remote being unreachable, and it must +# not be reported as the one above: no rebase ever starts, so calling it a +# rebase conflict sends the user hunting for a conflict that does not +# exist. (The old code said exactly that.) A bogus local path stands in +# for an unroutable host so the check costs nothing; the branch under test +# is the same one. +git -C $vroot14/agent-vault remote set-url origin /nonexistent/unreachable.git +echo more >$croot14/$cmang14/memory/keep2.md +set -l uhead14 (git -C $vroot14/agent-vault rev-list --count HEAD) +pushd $cp14 >/dev/null +set -l urc14 (agents-vault --push --silent 2>$cerr14; echo $status) +popd >/dev/null +check "unreachable remote: push returns non-zero" 1 "$urc14" +check "unreachable remote: not called a rebase conflict" false (string match -q '*rebase conflict*' -- (cat $cerr14); and echo true; or echo false) +check "unreachable remote: says it could not be reached" true (string match -q '*could not reach*' -- (cat $cerr14); and echo true; or echo false) +check "unreachable remote: the memory was still committed" (math $uhead14 + 1) (git -C $vroot14/agent-vault rev-list --count HEAD) +check "unreachable remote: left no rebase in progress" false (test -d $vroot14/agent-vault/.git/rebase-merge -o -d $vroot14/agent-vault/.git/rebase-apply; and echo true; or echo false) + +# And the launch path itself -- the ordinary run both wrappers make -- is +# entirely unaffected by the unreachable remote. This is the regression +# that mattered most: with the pull on the commit path, a laptop off the +# network stopped being backed up at all while reporting nothing wrong. +echo offline-precious >$croot14/$cmang14/memory/keep3.md +set -l ohead14 (git -C $vroot14/agent-vault rev-list --count HEAD) +pushd $cp14 >/dev/null +set -l orc14 (agents-vault --silent 2>$cerr14; echo $status) +popd >/dev/null +check "offline launch run returns 0" 0 "$orc14" +check "offline launch run stayed silent" "" (cat $cerr14) +check "offline launch run committed the memory" (math $ohead14 + 1) (git -C $vroot14/agent-vault rev-list --count HEAD) +check "offline launch run really recorded it" offline-precious (git -C $vroot14/agent-vault show HEAD:projects/$cslug14/claude/memory/keep3.md 2>/dev/null) set -e __fish_agent_vault_dir set -e __fish_agent_vault_claude_root @@ -1338,6 +1562,56 @@ set -e __fish_agent_vault_claude_root set -g __fish_agent_vault_claude_home $HERMETIC_HOME/claude set -g __fish_agent_vault_agy_root $HERMETIC_HOME/agy +# ──────────────── agents-init reports what really happened ───────────── +# agents-init shares _agents_repo_sync with agents-vault and shared its +# false zero too: it ended on a branchless `if` with no arm for a failed +# commit, so fish resolved the function to 0 and a rejected commit was +# reported as a successful sync. It also runs on every agent launch, so it +# has to keep committing with the remote out of reach. +echo "" +echo "== agents-init (commit reporting) ==" + +set -l ip (new_repo) +pushd $ip >/dev/null +set -l irc (agents-init --silent 2>/dev/null; echo $status) +popd >/dev/null +check "agents-init: scaffolds and returns 0" 0 "$irc" +check "agents-init: committed the AGENTS repo" true (test (git -C $ip/AGENTS rev-list --count HEAD) -ge 1; and echo true; or echo false) + +# Offline. The pull that used to run here blocked the launch until the +# remote timed out and then took the commit down with it, so an agent's +# edits went unrecorded on every launch away from the network. +set -l ibare (mktemp -d); set -ga TMPDIRS $ibare +git init -q --bare $ibare +git -C $ip/AGENTS remote add origin $ibare +git -C $ip/AGENTS push -q -u origin HEAD 2>/dev/null +git -C $ip/AGENTS remote set-url origin /nonexistent/unreachable.git +echo note >$ip/AGENTS/devlogs/offline.md +set -l ihead (git -C $ip/AGENTS rev-list --count HEAD) +pushd $ip >/dev/null +set -l iorc (agents-init --silent 2>/dev/null; echo $status) +popd >/dev/null +check "agents-init: offline run returns 0" 0 "$iorc" +check "agents-init: offline run still committed" (math $ihead + 1) (git -C $ip/AGENTS rev-list --count HEAD) +check "agents-init: the offline commit holds the file" note (git -C $ip/AGENTS show HEAD:devlogs/offline.md 2>/dev/null) + +# A rejected commit records nothing, so reporting success tells the user +# their agent's edits were captured when they were not. agents-init points +# core.hooksPath at .agents-tools/hooks itself, which is where a real +# secret scanner would sit, and the shims are only refreshed when their +# version marker moves -- so this replacement survives the run under test. +printf '#!/bin/sh\nexit 1\n' >$ip/AGENTS/.agents-tools/hooks/pre-commit +chmod +x $ip/AGENTS/.agents-tools/hooks/pre-commit +echo blocked >$ip/AGENTS/devlogs/blocked.md +set -l ibhead (git -C $ip/AGENTS rev-list --count HEAD) +set -l ierr (mktemp); set -ga TMPDIRS $ierr +pushd $ip >/dev/null +set -l ibrc (agents-init --silent 2>$ierr; echo $status) +popd >/dev/null +check "agents-init: a rejected commit returns non-zero" 1 "$ibrc" +check "agents-init: a rejected commit says nothing was recorded" true (string match -q '*nothing recorded*' -- (cat $ierr); and echo true; or echo false) +check "agents-init: a rejected commit really recorded nothing" $ibhead (git -C $ip/AGENTS rev-list --count HEAD) + # ──────────────────────── hermeticity assertion ──────────────────────── # The whole suite must never have touched the real global agent state. The # failure this guards is specific: a global-memory sync with no test