From 16ea31289d5beebb92c0234b35f37f89dd0b193f Mon Sep 17 00:00:00 2001 From: Rootiest Date: Thu, 3 Sep 2026 13:23:24 -0400 Subject: [PATCH] fix(agents-vault): repair slug migration, keep the network off the launch path Six findings from the whole-branch review, all of which end in the same place: a backup tool reporting success while nothing was backed up. Slug migration nested the old entry inside the new one. The clear before the rename was gated on the destination's claude/memory subdirectory rather than on the destination itself, so an entry that exists without one survived, `git mv A B` moved A *inside* B, and the mkdir below fabricated a fresh empty memory directory for the live link to point at. The real memory ended up one level deeper than --status and --restore ever look, and the run returned 0. That shape is not exotic: git cannot track an empty directory, so an entry committed while its memory was empty comes back from a clone as projects//origin and nothing else -- and cloning the vault is this feature's own recovery path. The destination is now moved aside the way --adopt already does it rather than deleted (widening the rm -rf would have destroyed the clone's origin log), its provenance is folded into the migrated entry, and every failure path rolls back and reports. The launch path pulled over the network. Both wrappers call agents-vault synchronously before starting an agent, and the pull in the shared sync helper was unguarded once an upstream existed: against a blackholed remote it blocked the launch indefinitely and then aborted the commit, so an offline laptop silently stopped being backed up at all. Committing never needed a remote, so the pull moved to the push path, which was already opt-in for exactly this reason. A failure there now distinguishes a real rebase conflict (rebase-merge/ or rebase-apply/ present) from an unreachable remote instead of calling both a conflict, and both network calls set GIT_TERMINAL_PROMPT=0 and GIT_ASKPASS so they fail fast rather than prompt with nobody watching. The helper still refuses to commit a rebase in progress, and leaves it standing rather than aborting one it did not start. This also restores agents-init's pre-refactor ability to commit while offline. The agy knowledge copy was unfiltered. The allowlist held at the agy root and nowhere below it, so a planted .credentials.json inside knowledge/ was committed verbatim while the documentation promised nothing new upstream added could leak in. Only *.md and *.json are copied now -- which is what the store actually holds -- so lock files, transcripts and conversation databases are excluded by having no business in a backup rather than by being known about. The scaffolded .gitignore also ignored only the SQLite sidecars and not the databases, which is worse than ignoring neither: a torn database landed in history with the write-ahead log that would have completed it deliberately excluded. Both changes are template-only, on a feature that has never shipped. agents-init reported success when nothing was committed. It ended on a branchless `if` with no arm for a failed commit, which fish resolves to 0 -- the same false zero already fixed in agents-vault, left in the function the refactor was rewriting. It now has the arm and an explicit final status. The --adopt forward-failure path with no stash left a raw coreutils `mv:` line and no statement that the adopt had been abandoned cleanly; it is branded like every other error exit in the function. Tests: the suite now clones a vault with git and runs agents-vault against the clone, instead of trusting hand-built fixtures to have shapes git can actually produce -- that blind spot shipped both of the merge blockers. The "present but empty" migration fixture is rebuilt as the origin-only directory a clone leaves behind, with the hand-built shape kept as a separate case. Reverting each fix drops the suite from 285 to 279 (migration), 261 (network), 275 (knowledge allowlist) and 283 (agents-init status). --- functions/_agents_repo_sync.fish | 37 ++-- functions/agents-init.fish | 41 +++- functions/agents-vault.fish | 224 +++++++++++++++++--- tests/test-agents-vault.fish | 340 ++++++++++++++++++++++++++++--- 4 files changed, 560 insertions(+), 82 deletions(-) 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