diff --git a/completions/agents-vault.fish b/completions/agents-vault.fish index a24106c..e0b6cbc 100644 --- a/completions/agents-vault.fish +++ b/completions/agents-vault.fish @@ -11,7 +11,10 @@ complete -c agents-vault -l restore -d 'Relink everything possible, report the r complete -c agents-vault -l status -d 'Show entries, link health, remote, orphans' # --adopt takes an existing vault slug, so offer the entries that are # actually there; the vault may not exist yet, in which case this is empty. -complete -c agents-vault -l adopt -r -a '(command ls -1 (_agents_vault_dir)/projects 2>/dev/null)' -d 'Bind this project to an existing vault entry' +# -A because a dot-led slug is legitimate (a relative-path remote keys as +# ..-mirror), and an entry that cannot be completed reads as one that is +# not there. +complete -c agents-vault -l adopt -r -a '(command ls -1A (_agents_vault_dir)/projects 2>/dev/null)' -d 'Bind this project to an existing vault entry' complete -c agents-vault -l remote -r -d 'Set the vault remote URL' complete -c agents-vault -s v -l verbose -d 'Print all per-step output (default)' complete -c agents-vault -s q -l quiet -d 'Print one summary line only if changed' diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish index ae50dcc..066d01d 100644 --- a/functions/agents-vault.fish +++ b/functions/agents-vault.fish @@ -120,6 +120,13 @@ # it defaults to off so a backgrounded push can never hang or prompt # invisibly underneath a starting agent. # +# --adopt rebinds an entry; it does not pin its name. The slug is +# re-derived from the project on every run, so the next ordinary run +# migrates the adopted entry straight back to the canonical key, carrying +# the memory and the live link with it. That is the point rather than a +# wart: adopting is how a mismatched or ambiguous binding is repaired, not +# how an entry is given a permanent name of its own. +# # An entry's origin file records the project path once, when the entry is # created, and is never refreshed. A project that later moves on disk # therefore keeps a stale path there and --restore degrades to reporting @@ -237,10 +244,21 @@ function agents-vault --description 'track curated agent memory in a host-scoped echo "" echo "$c_head""Entries:$c_reset" set -l seen 0 - for entry in "$vault"/projects/* + # Listed rather than globbed: fish's * skips dot-led names, and a + # dot-led slug is both reachable and sanctioned. A relative-path + # remote -- the sibling-bare-mirror idiom, `git remote add origin + # ../mirror.git` -- keys as ..-mirror, a dot-led host keys as + # .hidden.example.com-o-r, and --adopt accepts a leading dot on + # purpose. Such an entry is scaffolded, linked and committed like any + # other, so a glob here would under-report the vault without ever + # saying that it had. The 2>/dev/null is load-bearing: a vault with + # no projects/ is ordinary (running agents-vault outside any git + # repo makes exactly that one) and a bare ls would spew. + for name in (command ls -A "$vault/projects" 2>/dev/null) + set -l entry "$vault/projects/$name" test -d "$entry"; or continue set seen 1 - set -l eslug (path basename "$entry") + set -l eslug $name set -l count (command ls -A "$entry/claude/memory" 2>/dev/null | count) set -l want (path resolve "$entry/claude/memory") set -l linked 0 @@ -290,7 +308,12 @@ function agents-vault --description 'track curated agent memory in a host-scoped printf '%s\n' \ '# SQLite sidecars are never safe to commit mid-write.' \ '*.db-wal' \ - '*.db-shm' >"$vault/.gitignore" + '*.db-shm' \ + '' \ + '# --adopt stashes the entry it is about to overwrite inside .git/,' \ + '# out of reach of `git add -A`. This covers the fallback location' \ + '# it uses when .git is not a directory.' \ + '/.adopt-stash' >"$vault/.gitignore" set changed 1 end @@ -423,17 +446,37 @@ function agents-vault --description 'track curated agent memory in a host-scoped # So a contentless target entry is moved aside rather than deleted, # and the origin note is appended only once the relink has # succeeded. Both are undone below if it does not. The stash lives - # at the vault root, not under projects/, so a crash between the - # two renames cannot leave something that reads as an entry. + # inside .git/: it is on the same filesystem, so the rename stays a + # rename; it is not under projects/, so a crash between the two moves + # cannot leave something that reads as an entry; and `git add -A` + # never descends there, so a crash cannot leave junk that the next + # ordinary run commits either. A vault whose .git is not a directory + # is not one this tool made, so that case falls back to the vault + # root, which the scaffolded .gitignore covers. set -l stash "" if test -d "$to" - set stash "$vault/.adopt-stash" + if test -d "$vault/.git" + set stash "$vault/.git/agents-vault-adopt-stash" + else + set stash "$vault/.adopt-stash" + end rm -rf "$stash" command mv "$to" "$stash"; or return 1 end if not git -C "$vault" mv "projects/$cur" "projects/$_flag_adopt" 2>/dev/null if not command mv "$from" "$to" test -n "$stash"; and command mv "$stash" "$to" + # Putting the worktree back is only half a rollback. Every + # move here is a plain rename as far as git is concerned, so + # the index still describes the half-applied state, and a + # hand `git commit` in that window would record it -- the + # next ordinary run's `git add -A` heals it, but only later. + # projects/ is re-read whole rather than the two entries + # named: git add refuses a pathspec that matches nothing -- + # which one of the two always is, once it has been moved + # back -- and then stages none of them, not even the one + # that did match. + git -C "$vault" add -A -- projects 2>/dev/null return 1 end end @@ -450,6 +493,13 @@ function agents-vault --description 'track curated agent memory in a host-scoped command mv "$to" "$from" end test -n "$stash"; and command mv "$stash" "$to" + # Only now, with the worktree whole again -- the stash restore + # included -- is the index worth re-reading. Unconditional + # because even the git-native rollback leaves it out of line: + # the forward `git mv` overwrote the stashed entry's index + # entries, and moving the files back does not bring them back. + # See the same call above for why projects/ is named whole. + git -C "$vault" add -A -- projects 2>/dev/null echo "$c_err""agents-vault: could not relink $claude_root/$mangled/memory; $cur was left as it was$c_reset" >&2 return 1 end @@ -473,9 +523,14 @@ function agents-vault --description 'track curated agent memory in a host-scoped set -l claude_root $__fish_agent_vault_claude_root test -n "$claude_root"; or set claude_root "$HOME/.claude/projects" set -l restore_failed 0 - for entry in "$vault"/projects/* + # Listed rather than globbed, for the reason spelled out at --status: + # fish's * skips a dot-led slug, which is a legitimate key, and a + # batch restore that silently walks past an entry is worse here than + # in a report -- that project is simply left unlinked. + for name in (command ls -A "$vault/projects" 2>/dev/null) + set -l entry "$vault/projects/$name" test -d "$entry/claude/memory"; or continue - set -l eslug (path basename "$entry") + set -l eslug $name set -l opath "" if test -f "$entry/origin" set -l line (command grep -m1 '^path:' "$entry/origin" 2>/dev/null) @@ -734,7 +789,7 @@ function agents-vault --description 'track curated agent memory in a host-scoped # 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_warn""agents-vault: push failed; the vault is committed locally but not backed up off this machine$c_reset" >&2 + 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 else if set -q _flag_push diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index 411f480..ea208be 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -860,6 +860,104 @@ popd >/dev/null check "atomic adopt: retry succeeds" 0 "$t2rc" check "atomic adopt: retry moved the memory" atomic-precious (cat $vroot9/agent-vault/projects/atomic-target/claude/memory/keep.md 2>/dev/null) +# The cases above all adopt onto a slug with no entry at all. The other +# half of --adopt is a target that already exists but holds no memory -- a +# scaffolded entry, or one a departed project left behind. That one is +# stashed rather than deleted, so a failed relink can put it back exactly +# as it was. +set -l sa (new_repo https://git.rootiest.dev/rootiest/stash-src.git) +set -l saslug git.rootiest.dev-rootiest-stash-src +set -l samang (string replace -a '/' '-' -- $sa | string replace -a '.' '-') +mkdir -p $croot9/$samang/memory +echo stash-precious >$croot9/$samang/memory/keep.md +pushd $sa >/dev/null +agents-vault --silent +popd >/dev/null + +# The target must be *tracked* for this to bite: stashing it takes files +# git knows about out from under the index, which is the whole hazard. +mkdir -p $vroot9/agent-vault/projects/stash-target/claude/memory +printf 'remote: (none)\npath: %s\nhost: t\n' /nowhere \ + >$vroot9/agent-vault/projects/stash-target/origin +pushd $sa >/dev/null +agents-vault --silent +popd >/dev/null +check "stash adopt: target entry is tracked" true (git -C $vroot9/agent-vault ls-files --error-unmatch projects/stash-target/origin >/dev/null 2>&1; and echo true; or echo false) +check "stash adopt: vault clean before the adopt" "" (git -C $vroot9/agent-vault status --porcelain | string join ',') + +# Where the stash lives is load-bearing rather than cosmetic: at the vault +# root, a crash between the two moves leaves it for the next ordinary run's +# `git add -A` to commit as permanent junk. Inside .git/ it is out of reach +# of both the entry walk and `git add -A`. Pinned by making the vault root +# itself unwritable for the duration -- adopt writes nothing there, so only +# a stash at the root would need it. +chmod 500 $vroot9/agent-vault +pushd $sa >/dev/null +set -l sarc (agents-vault --adopt=stash-target --silent; echo $status) +popd >/dev/null +chmod 700 $vroot9/agent-vault +check "stash adopt: succeeds over a contentless target" 0 "$sarc" +check "stash adopt: memory moved to the target slug" stash-precious (cat $vroot9/agent-vault/projects/stash-target/claude/memory/keep.md 2>/dev/null) +check "stash adopt: source entry removed" false (test -d $vroot9/agent-vault/projects/$saslug; and echo true; or echo false) +check "stash adopt: link repinned onto the target" (path resolve $vroot9/agent-vault/projects/stash-target/claude/memory) (path resolve $croot9/$samang/memory) +check "stash adopt: stash cleaned up" false (test -e $vroot9/agent-vault/.git/agents-vault-adopt-stash; and echo true; or echo false) +check "stash adopt: nothing stashed at the vault root" false (test -e $vroot9/agent-vault/.adopt-stash; and echo true; or echo false) +check "stash adopt: the root fallback location is gitignored" true (git -C $vroot9/agent-vault check-ignore -q .adopt-stash; and echo true; or echo false) +check "stash adopt: vault clean afterwards" "" (git -C $vroot9/agent-vault status --porcelain | string join ',') + +# ... and when the relink fails, the stash must come back and the *index* +# must come back with it. Restoring the worktree alone leaves git +# describing a half-applied rename -- the files are right, git status is +# not -- and a hand commit in that window records it. +set -l sf (new_repo https://git.rootiest.dev/rootiest/stash-fail.git) +set -l sfslug git.rootiest.dev-rootiest-stash-fail +set -l sfmang (string replace -a '/' '-' -- $sf | string replace -a '.' '-') +mkdir -p $croot9/$sfmang/memory +echo stashfail-precious >$croot9/$sfmang/memory/keep.md +pushd $sf >/dev/null +agents-vault --silent +popd >/dev/null + +mkdir -p $vroot9/agent-vault/projects/stashfail-target/claude/memory +printf 'remote: (none)\npath: %s\nhost: t\n' /nowhere-else \ + >$vroot9/agent-vault/projects/stashfail-target/origin +pushd $sf >/dev/null +agents-vault --silent +popd >/dev/null + +set -l sf_entries (command ls -A $vroot9/agent-vault/projects | sort | string join ',') +set -l sf_head (git -C $vroot9/agent-vault rev-list --count HEAD) +set -l sf_porcelain (git -C $vroot9/agent-vault status --porcelain | string join ',') +set -l sf_link (path resolve $croot9/$sfmang/memory) +check "stash adopt rollback: vault clean before the adopt" "" "$sf_porcelain" + +set -l sferr (mktemp); set -ga TMPDIRS $sferr +chmod 500 $croot9/$sfmang +pushd $sf >/dev/null +set -l sfrc (agents-vault --adopt=stashfail-target --silent 2>$sferr; echo $status) +popd >/dev/null +chmod 700 $croot9/$sfmang + +check "stash adopt rollback: failed relink returns 1" 1 "$sfrc" +check "stash adopt rollback: says the entry was left alone" true (string match -q "*$sfslug*left as it was*" -- (cat $sferr); and echo true; or echo false) +check "stash adopt rollback: entries unchanged" "$sf_entries" (command ls -A $vroot9/agent-vault/projects | sort | string join ',') +check "stash adopt rollback: source entry intact" stashfail-precious (cat $vroot9/agent-vault/projects/$sfslug/claude/memory/keep.md 2>/dev/null) +check "stash adopt rollback: stashed target came back" true (string match -q '*nowhere-else*' -- (cat $vroot9/agent-vault/projects/stashfail-target/origin 2>/dev/null); and echo true; or echo false) +check "stash adopt rollback: target memory still contentless" 0 (command ls -A $vroot9/agent-vault/projects/stashfail-target/claude/memory 2>/dev/null | count) +check "stash adopt rollback: nothing committed" "$sf_head" (git -C $vroot9/agent-vault rev-list --count HEAD) +check "stash adopt rollback: index and worktree unchanged" "$sf_porcelain" (git -C $vroot9/agent-vault status --porcelain | string join ',') +check "stash adopt rollback: no stash left behind" false (test -e $vroot9/agent-vault/.git/agents-vault-adopt-stash -o -e $vroot9/agent-vault/.adopt-stash; and echo true; or echo false) +check "stash adopt rollback: live link never removed" "$sf_link" (path resolve $croot9/$sfmang/memory) +check "stash adopt rollback: memory still reachable live" stashfail-precious (cat $croot9/$sfmang/memory/keep.md 2>/dev/null) + +# The point of rolling back: the ordinary run afterwards finds the original +# entry instead of fabricating a fresh, history-less one. +pushd $sf >/dev/null +agents-vault --silent +popd >/dev/null +check "stash adopt rollback: ordinary run fabricated no entry" "$sf_entries" (command ls -A $vroot9/agent-vault/projects | sort | string join ',') +check "stash adopt rollback: ordinary run kept the memory reachable" stashfail-precious (cat $croot9/$sfmang/memory/keep.md 2>/dev/null) + set -e __fish_agent_vault_dir set -e __fish_agent_vault_claude_root set -g __fish_agent_vault_claude_home $HERMETIC_HOME/claude @@ -908,6 +1006,28 @@ set -l r2rc (agents-vault --restore >$r2out; echo $status) check "restore: exits 0 with an unplaceable entry" 0 "$r2rc" check "restore: reports the unplaceable entry" true (string match -q '*ghost-entry*' -- (cat $r2out); and echo true; or echo false) +# A dot-led slug is a real key, not a curiosity: the sibling-bare-mirror +# idiom (`git remote add origin ../mirror.git`) keys as ..-mirror, a +# dot-led host keys as .hidden.example.com-o-r, and --adopt accepts a +# leading dot on purpose. Fish's * skips such a name, so both walks list +# instead of globbing -- otherwise --status under-reports the entry and +# --restore leaves that project unlinked, both without saying a word. +set -l dotp (new_repo https://git.rootiest.dev/rootiest/dotted.git) +set -l dotmang (string replace -a '/' '-' -- $dotp | string replace -a '.' '-') +mkdir -p $vroot10/agent-vault/projects/.dot-entry/claude/memory +echo dot-precious >$vroot10/agent-vault/projects/.dot-entry/claude/memory/keep.md +printf 'remote: (none)\npath: %s\nhost: t\n' $dotp \ + >$vroot10/agent-vault/projects/.dot-entry/origin +check "dot-led entry: a glob really does skip it" false (string match -q '*.dot-entry*' -- (echo $vroot10/agent-vault/projects/*); and echo true; or echo false) + +set -l dotreport (agents-vault --status) +check "status lists a dot-led entry" true (string match -q '*.dot-entry*' -- "$dotreport"; and echo true; or echo false) + +set -l dotout (agents-vault --restore) +check "restore: relinks a dot-led entry" dot-precious (cat $croot10/$dotmang/memory/keep.md 2>/dev/null) +check "restore: the dot-led live path is a link" true (test -L $croot10/$dotmang/memory; and echo true; or echo false) +check "restore: names the dot-led entry it restored" true (string match -q '*.dot-entry*' -- "$dotout"; and echo true; or echo false) + # An entry that *could* be placed but could not be relinked is a failure, # not a note in passing -- the same branchless-`if` false zero as the # commit and push paths. An entry with no live project is not a failure. @@ -1010,6 +1130,114 @@ 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 +# ────────────── a failed vault commit is fatal, not a note ───────────── +# _agents_repo_sync's own rejection path is covered further up, but +# agents-vault has to *propagate* it. The function otherwise ends on a +# branchless `if`, which fish resolves to 0, and a backup tool that reports +# success while nothing was recorded recreates the exact loss the vault +# exists to prevent. That false zero has already bitten this project three +# times, so both ways a sync can fail are pinned here rather than one. +echo "" +echo "== agents-vault (a failed vault commit is fatal) ==" + +set -l vroot13 (mktemp -d); set -ga TMPDIRS $vroot13 +set -l croot13 (mktemp -d); set -ga TMPDIRS $croot13 +set -l chome13 (mktemp -d); set -ga TMPDIRS $chome13 +set -l agy13 (mktemp -d); set -ga TMPDIRS $agy13 +set -g __fish_agent_vault_dir $vroot13/agent-vault +set -g __fish_agent_vault_claude_root $croot13 +set -g __fish_agent_vault_claude_home $chome13 +set -g __fish_agent_vault_agy_root $agy13 + +set -l hp13 (new_repo https://git.rootiest.dev/rootiest/hookfail.git) +set -l hmang13 (string replace -a '/' '-' -- $hp13 | string replace -a '.' '-') +mkdir -p $croot13/$hmang13/memory +echo hook-precious >$croot13/$hmang13/memory/keep.md +pushd $hp13 >/dev/null +agents-vault --silent +popd >/dev/null + +# The vault runs its own hooks out of .agents-tools/hooks -- agents-vault +# points core.hooksPath there itself -- so a rejecting pre-commit in that +# directory is exactly the shape of a real secret scanner blocking the +# vault commit. _agents_repo_install_tools only refreshes the shims when +# the version marker moves, so the replacement survives the run under test. +printf '#!/bin/sh\nexit 1\n' >$vroot13/agent-vault/.agents-tools/hooks/pre-commit +chmod +x $vroot13/agent-vault/.agents-tools/hooks/pre-commit +echo hook-more >$croot13/$hmang13/memory/keep2.md +set -l hhead13 (git -C $vroot13/agent-vault rev-list --count HEAD) +set -l herr13 (mktemp); set -ga TMPDIRS $herr13 +pushd $hp13 >/dev/null +set -l hrc13 (agents-vault --silent 2>$herr13; echo $status) +popd >/dev/null +check "rejected vault commit returns non-zero" 1 "$hrc13" +check "rejected vault commit says nothing was recorded" true (string match -q '*nothing recorded*' -- (cat $herr13); and echo true; or echo false) +check "rejected vault commit really recorded nothing" "$hhead13" (git -C $vroot13/agent-vault rev-list --count HEAD) + +set -e __fish_agent_vault_dir +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. +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 +set -l agy14 (mktemp -d); set -ga TMPDIRS $agy14 +set -g __fish_agent_vault_dir $vroot14/agent-vault +set -g __fish_agent_vault_claude_root $croot14 +set -g __fish_agent_vault_claude_home $chome14 +set -g __fish_agent_vault_agy_root $agy14 + +set -l bare14 (mktemp -d); set -ga TMPDIRS $bare14 +git init -q --bare $bare14 + +set -l cp14 (new_repo https://git.rootiest.dev/rootiest/conflict.git) +set -l cslug14 git.rootiest.dev-rootiest-conflict +set -l cmang14 (string replace -a '/' '-' -- $cp14 | string replace -a '.' '-') +mkdir -p $croot14/$cmang14/memory +echo base >$croot14/$cmang14/memory/keep.md +pushd $cp14 >/dev/null +agents-vault --silent +popd >/dev/null +agents-vault --remote=$bare14 --silent +git -C $vroot14/agent-vault push -q -u origin HEAD:refs/heads/main + +# Another machine records a conflicting change to the same line... +set -l cclone14 (mktemp -d); set -ga TMPDIRS $cclone14 +git clone -q $bare14 $cclone14 +git -C $cclone14 config user.email t@t +git -C $cclone14 config user.name t +git -C $cclone14 config commit.gpgsign false +git -C $cclone14 config core.hooksPath /dev/null +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 + +set -l cerr14 (mktemp); set -ga TMPDIRS $cerr14 +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) + +set -e __fish_agent_vault_dir +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 + # ────────────── a dangling global memory link is repinned ────────────── # The one state from the real incident the suite never pinned down. For a # *broken* symlink both -d and -e are false, so only the `-L` disjunct in