diff --git a/completions/agents-vault.fish b/completions/agents-vault.fish new file mode 100644 index 0000000..a24106c --- /dev/null +++ b/completions/agents-vault.fish @@ -0,0 +1,18 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Completions for agents-vault. + +complete -c agents-vault -f +complete -c agents-vault -s h -l help -d 'Show help message' +complete -c agents-vault -l link -d "Ensure this project's memory link only" +complete -c agents-vault -l push -d 'Commit and push to the vault remote' +complete -c agents-vault -l restore -d 'Relink everything possible, report the rest' +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' +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' +complete -c agents-vault -s s -l silent -d 'Suppress all output; errors only' diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish index baebb21..9da3d4e 100644 --- a/functions/agents-vault.fish +++ b/functions/agents-vault.fish @@ -55,6 +55,29 @@ # If both the old and new entries already hold content the migration is # ambiguous and is refused; resolve it with --adopt=SLUG. # +# Run with no flags, the command scaffolds the vault, syncs global state, +# links the current project, and commits. The other modes are exclusive +# and each returns as soon as it is done: +# +# --status is a report and mutates nothing at all. It is answered before +# the vault is even scaffolded, so asking what the vault looks like never +# creates it, never copies agy state into it, and never claims +# ~/.claude/memory. A missing vault is reported rather than built. +# +# --restore walks every vault entry and relinks the live memory directory +# of each one whose recorded origin path still exists, naming the rest so +# they can be rebound by hand. It is a convenience: the ordinary per- +# project run restores a cloned vault's memory on its own. +# +# --adopt=SLUG rebinds the current project's entry to SLUG, which is how +# a machine-specific local-* key or an ambiguous migration is resolved. +# SLUG must match [a-z0-9._-]+ with no slash and no leading dot -- the +# charset the slug formula itself emits -- since it is interpolated into +# a vault path and handed to git mv. +# +# --remote=URL points the vault at a remote; --push commits and then +# pushes there. +# # ARGUMENTS # --link Scaffold the vault and link this project's memory; skip # the final commit @@ -70,13 +93,16 @@ # # EXIT STATUS # 0 Completed successfully -# 1 Fatal error (vault unavailable, git failure, ambiguous migration) +# 1 Fatal error (vault unavailable, git failure, ambiguous migration, +# invalid --adopt slug, or --push with no remote configured) # # EXAMPLE # agents-vault # agents-vault --status # agents-vault --remote=https://git.rootiest.dev/rootiest/agent-vault.git # agents-vault --push +# agents-vault --adopt=git.rootiest.dev-rootiest-fish-config +# agents-vault --restore # # NOTES # Set __fish_agent_vault_dir to relocate the vault. Set @@ -84,6 +110,12 @@ # it defaults to off so a backgrounded push can never hang or prompt # invisibly underneath a starting agent. # +# 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 +# it as unplaceable rather than relinking the wrong directory. Rebind +# such an entry from the project itself with --adopt=SLUG. +# # The agy knowledge copy is merge-only. Files are copied into the vault # but are never removed from it, so a fact deleted upstream from agy's # knowledge store persists in the vault indefinitely, and a restore or a @@ -112,6 +144,7 @@ function agents-vault --description 'track curated agent memory in a host-scoped set -l c_ok (set_color green) set -l c_warn (set_color yellow) set -l c_err (set_color red) + set -l c_dim (set_color brblack) set -l c_reset (set_color normal) argparse h/help link push restore status 'adopt=' 'remote=' \ @@ -155,6 +188,69 @@ function agents-vault --description 'track curated agent memory in a host-scoped set -l changed 0 set -l did_init 0 + # ─────────────────────────── --status ────────────────────────────── + # A report, and nothing but a report. This is dispatched here -- ahead + # of the scaffold, the tool install, and the global-state sync -- on + # purpose: asking what the vault looks like must never be the thing + # that creates it, copies the agy knowledge store into it, or claims + # ~/.claude/memory. A status command that mutates cannot be trusted to + # diagnose the thing it just changed. + if set -q _flag_status + echo "$c_head""Vault:$c_reset $vault" + if not test -d "$vault" + echo " $c_warn""no vault yet — run agents-vault inside a project to create one$c_reset" + return 0 + end + + set -l url (git -C "$vault" remote get-url origin 2>/dev/null) + if test -z "$url" + echo "$c_head""Remote:$c_reset $c_warn""no remote configured — nothing is backed up off this machine$c_reset" + else + echo "$c_head""Remote:$c_reset $url" + if git -C "$vault" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1 + set -l ahead (git -C "$vault" rev-list --count '@{u}..HEAD' 2>/dev/null) + if test -n "$ahead"; and test "$ahead" != 0 + echo " $c_warn$ahead commit(s) not yet pushed$c_reset" + end + else + echo " $c_warn""no upstream branch — never pushed$c_reset" + end + end + + if test -d "$vault/.git/rebase-merge"; or test -d "$vault/.git/rebase-apply" + echo " $c_err""unresolved rebase in progress — resolve it before syncing$c_reset" + end + + set -l claude_root $__fish_agent_vault_claude_root + test -n "$claude_root"; or set claude_root "$HOME/.claude/projects" + + echo "" + echo "$c_head""Entries:$c_reset" + set -l seen 0 + for entry in "$vault"/projects/* + test -d "$entry"; or continue + set seen 1 + set -l eslug (path basename "$entry") + 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 + for cand in "$claude_root"/*/memory + test -L "$cand"; or continue + if test (path resolve "$cand") = "$want" + set linked 1 + break + end + end + if test $linked -eq 1 + echo " $c_ok""linked$c_reset $eslug $c_dim($count file(s))$c_reset" + else + echo " $c_warn""orphan$c_reset $eslug $c_dim($count file(s)) — no live project links here$c_reset" + end + end + test $seen -eq 0; and echo " $c_dim(none)$c_reset" + return 0 + end + # ────────────────────── ensure the vault repo ────────────────────── if not test -d "$vault" if not mkdir -p "$vault" @@ -229,6 +325,132 @@ function agents-vault --description 'track curated agent memory in a host-scoped set changed 1 end + # ─────────────────────────── --remote ────────────────────────────── + # Mutating modes are dispatched here: after the vault repo exists (they + # all need one) but before the global-state sync below, which belongs + # to a default backup run and has no business running as a side effect + # of rebinding an entry or setting a URL. + if set -q _flag_remote + if test -z "$_flag_remote" + echo "$c_err""agents-vault: --remote needs a URL$c_reset" >&2 + return 1 + end + # The result is captured explicitly rather than chained off the + # block terminator with `or`. `end` does carry the taken branch's + # status in fish, but only when a branch was taken at all: the same + # construct one `else` away silently reports success, which is + # exactly how a hook-rejected commit once passed for a good one. + set -l rc 0 + if git -C "$vault" remote get-url origin >/dev/null 2>&1 + git -C "$vault" remote set-url origin "$_flag_remote" + set rc $status + else + git -C "$vault" remote add origin "$_flag_remote" + set rc $status + end + if test $rc -ne 0 + echo "$c_err""agents-vault: could not set the vault remote to $_flag_remote$c_reset" >&2 + return 1 + end + test $verbose -eq 1; and echo "$c_ok→ Vault remote set to $_flag_remote$c_reset" + return 0 + end + + # ─────────────────────────── --adopt ─────────────────────────────── + if set -q _flag_adopt + # The requested slug is interpolated into a vault path and handed + # to `git mv`, so it is validated before it is used anywhere: + # --adopt=../../../etc would otherwise walk straight out of the + # vault. Only the charset the slug formula itself emits is + # accepted, and a leading dot is refused as well, which also rules + # out the bare "." and ".." entries. + if not string match -qr '^[a-z0-9_-][a-z0-9._-]*$' -- "$_flag_adopt" + echo "$c_err""agents-vault: invalid slug '$_flag_adopt'$c_reset" >&2 + echo "$c_err"" A slug is [a-z0-9._-]+ with no slash and no leading dot.$c_reset" >&2 + return 1 + end + + set -l root (git rev-parse --show-toplevel 2>/dev/null) + if test -z "$root" + echo "$c_err""agents-vault: --adopt must run inside a project$c_reset" >&2 + return 1 + end + set -l cur (_agents_repo_slug "$root") + set -l from "$vault/projects/$cur" + set -l to "$vault/projects/$_flag_adopt" + if test "$cur" = "$_flag_adopt" + test $verbose -eq 1; and echo "$c_ok→ Already bound to $_flag_adopt$c_reset" + return 0 + end + if not test -d "$from" + echo "$c_err""agents-vault: no vault entry for this project ($cur)$c_reset" >&2 + return 1 + end + + set -l to_content + test -d "$to/claude/memory"; and set to_content (command ls -A "$to/claude/memory" 2>/dev/null) + if test (count $to_content) -gt 0 + echo "$c_err""agents-vault: $_flag_adopt already holds content; refusing to overwrite$c_reset" >&2 + return 1 + end + test -d "$to"; and rm -rf "$to" + if not git -C "$vault" mv "projects/$cur" "projects/$_flag_adopt" 2>/dev/null + command mv "$from" "$to"; or return 1 + end + printf 'adopted: %s → %s (%s)\n' "$cur" "$_flag_adopt" (date -I) >>"$to/origin" + + set -l claude_root $__fish_agent_vault_claude_root + test -n "$claude_root"; or set claude_root "$HOME/.claude/projects" + set -l mangled (string replace -a '/' '-' -- "$root" | string replace -a '.' '-') + # The live path still points at the old entry, which no longer + # exists; drop it so ensure_symlink is not asked to resolve a + # broken link before repinning it. + test -L "$claude_root/$mangled/memory"; and rm -f "$claude_root/$mangled/memory" + if not _agents_repo_ensure_symlink "$claude_root/$mangled/memory" "$to/claude/memory" >/dev/null + echo "$c_err""agents-vault: adopted $_flag_adopt but could not relink $claude_root/$mangled/memory$c_reset" >&2 + return 1 + end + + _agents_repo_sync "$vault" "chore: adopt $cur as $_flag_adopt" >/dev/null + test $verbose -eq 1; and echo "$c_ok→ Adopted $cur as $_flag_adopt$c_reset" + return 0 + end + + # ────────────────────────── --restore ────────────────────────────── + # The batch counterpart of the emergent per-project restore. Each entry + # records the path it was created at; where that path still exists the + # live memory directory is relinked, and where it does not the entry is + # named so it can be rebound with --adopt. The origin file is written + # once and never refreshed, so a project that has since moved on disk + # simply degrades to "cannot place" rather than relinking the wrong + # directory. + if set -q _flag_restore + set -l claude_root $__fish_agent_vault_claude_root + test -n "$claude_root"; or set claude_root "$HOME/.claude/projects" + for entry in "$vault"/projects/* + test -d "$entry/claude/memory"; or continue + set -l eslug (path basename "$entry") + set -l opath "" + if test -f "$entry/origin" + set -l line (command grep -m1 '^path:' "$entry/origin" 2>/dev/null) + test -n "$line"; and set opath (string replace -r '^path:\s+' '' -- "$line") + end + if test -n "$opath"; and test -d "$opath" + set -l m (string replace -a '/' '-' -- "$opath" | string replace -a '.' '-') + set -l msg (_agents_repo_ensure_symlink "$claude_root/$m/memory" "$entry/claude/memory") + if test $status -ne 0 + echo "$c_err""agents-vault: could not relink $eslug$c_reset" >&2 + else if test -n "$msg" + test $verbose -eq 1; and echo "$c_ok→ Restored $eslug$c_reset" + end + else + test $verbose -eq 1 + and echo "$c_warn→ Cannot place $eslug: no live project found; use --adopt from the project$c_reset" + end + end + return 0 + end + # ────────────────────────── global state ─────────────────────────── # Allowlist, never a denylist. The agy root and ~/.claude also hold # .credentials.json, history.jsonl, sessions/, session-env/, @@ -327,14 +549,6 @@ function agents-vault --description 'track curated agent memory in a host-scoped end end - # ─────────────────────── unimplemented modes ─────────────────────── - for f in _flag_push _flag_restore _flag_status _flag_adopt _flag_remote - if set -q $f - echo "$c_err""agents-vault: that mode is not implemented yet$c_reset" >&2 - return 1 - end - end - # ──────────────────── link the current project ───────────────────── set -l root (git rev-parse --show-toplevel 2>/dev/null) if test -n "$root" @@ -441,6 +655,33 @@ function agents-vault --description 'track curated agent memory in a host-scoped end end + # ────────────────────────────── push ─────────────────────────────── + # Pushing is explicit. Autopush exists but is opt-in, because this runs + # on every agent launch and a network operation there can hang or + # prompt for credentials invisibly underneath a starting agent. + set -l do_push 0 + set -q _flag_push; and set do_push 1 + if set -q __fish_agent_vault_autopush; and test "$__fish_agent_vault_autopush" = 1 + set do_push 1 + 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 + # Not fatal: the commit above already happened, so the + # memory is safe locally and the next push will carry it. + echo "$c_warn""agents-vault: push failed; the vault is committed locally$c_reset" >&2 + end + else if set -q _flag_push + # An explicit --push that pushed nowhere must not read as a + # successful backup. Autopush stays quiet: it is a background + # convenience on a vault that may deliberately have no remote. + echo "$c_err""agents-vault: no remote configured; set one with --remote=URL$c_reset" >&2 + return 1 + end + end + if test $quiet -eq 1; and test $changed -eq 1 if test $did_init -eq 1 echo "$c_ok→ Initialized agent memory vault$c_reset" diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index 33fbdec..475df2f 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -679,6 +679,272 @@ 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 +# ──────────────────── status / adopt / remote / push ─────────────────── +# The four report-and-rebind modes. --status is a *report*: the checks +# below pin that it never mutates, because it is dispatched ahead of the +# scaffold rather than behind it. +echo "" +echo "== agents-vault (status, remote, adopt) ==" + +set -l vroot9 (mktemp -d); set -ga TMPDIRS $vroot9 +set -l croot9 (mktemp -d); set -ga TMPDIRS $croot9 +set -l chome9 (mktemp -d); set -ga TMPDIRS $chome9 +set -l agy9 (mktemp -d); set -ga TMPDIRS $agy9 +set -g __fish_agent_vault_dir $vroot9/agent-vault +set -g __fish_agent_vault_claude_root $croot9 +set -g __fish_agent_vault_claude_home $chome9 +set -g __fish_agent_vault_agy_root $agy9 + +# A report asked for before the vault exists must say so, not scaffold one. +set -l s0out (mktemp); set -ga TMPDIRS $s0out +set -l s0rc (agents-vault --status >$s0out; echo $status) +check "status without a vault exits 0" 0 "$s0rc" +check "status without a vault says so" true (string match -q '*no vault*' -- (cat $s0out); and echo true; or echo false) +check "status without a vault scaffolds nothing" false (test -e $vroot9/agent-vault; and echo true; or echo false) + +set -l sp (new_repo https://git.rootiest.dev/rootiest/statusrepo.git) +set -l smangled (string replace -a '/' '-' -- $sp | string replace -a '.' '-') +mkdir -p $croot9/$smangled/memory +echo m >$croot9/$smangled/memory/m.md +pushd $sp >/dev/null +agents-vault --silent +set -l report (agents-vault --status) +popd >/dev/null + +check "status names the slug" true (string match -q '*git.rootiest.dev-rootiest-statusrepo*' -- "$report"; and echo true; or echo false) +check "status reports no remote" true (string match -q '*no remote*' -- "$report"; and echo true; or echo false) +check "status reports the link as healthy" true (string match -q '*linked*' -- "$report"; and echo true; or echo false) + +# --status must not mutate. Global state that a default run *would* sync is +# staged here and must still be untouched afterwards: a report that first +# copies the agy knowledge store and claims ~/.claude/memory is not a +# report. This is what dispatching --status ahead of the scaffold buys. +mkdir -p $agy9/knowledge +echo learned >$agy9/knowledge/fact.md +mkdir -p $chome9/memory +echo global >$chome9/memory/g.md +set -l head_before (git -C $vroot9/agent-vault rev-list --count HEAD) +set -l porcelain_before (git -C $vroot9/agent-vault status --porcelain | string join ',') +agents-vault --status >/dev/null +check "status did not copy agy state" false (test -e $vroot9/agent-vault/global/agy; and echo true; or echo false) +check "status did not claim the global memory path" false (test -L $chome9/memory; and echo true; or echo false) +check "status made no commit" "$head_before" (git -C $vroot9/agent-vault rev-list --count HEAD) +check "status left the vault worktree as it found it" "$porcelain_before" (git -C $vroot9/agent-vault status --porcelain | string join ',') + +# An entry no live project links to is surfaced as an orphan. +mkdir -p $vroot9/agent-vault/projects/ghost-entry/claude/memory +echo x >$vroot9/agent-vault/projects/ghost-entry/claude/memory/x.md +set -l oreport (agents-vault --status) +check "status lists the orphan" true (string match -q '*orphan*ghost-entry*' -- "$oreport"; and echo true; or echo false) +rm -rf $vroot9/agent-vault/projects/ghost-entry + +# --remote sets origin on the vault. +agents-vault --remote=https://git.rootiest.dev/rootiest/agent-vault.git --silent +check "remote set" https://git.rootiest.dev/rootiest/agent-vault.git (git -C $vroot9/agent-vault remote get-url origin) +check "status reports the remote" true (string match -q '*rootiest/agent-vault.git*' -- (agents-vault --status); and echo true; or echo false) + +# A *failed* remote update must return non-zero. Reporting success after a +# git command that did not run is the same silent-false-success shape that +# a hook-rejected commit produced earlier in this project. +set -l rerr (mktemp); set -ga TMPDIRS $rerr +chmod 500 $vroot9/agent-vault/.git +set -l rrc (agents-vault --remote=https://git.rootiest.dev/rootiest/other.git --silent 2>$rerr; echo $status) +chmod 700 $vroot9/agent-vault/.git +check "failing --remote returns 1" 1 "$rrc" +check "failing --remote reports on stderr" true (string match -q '*could not set*remote*' -- (cat $rerr); and echo true; or echo false) +check "failing --remote left the old remote in place" https://git.rootiest.dev/rootiest/agent-vault.git (git -C $vroot9/agent-vault remote get-url origin) + +# --adopt renames the current project's entry. +set -l ap (new_repo) +set -l amang (string replace -a '/' '-' -- $ap | string replace -a '.' '-') +mkdir -p $croot9/$amang/memory +echo adopted >$croot9/$amang/memory/a.md +pushd $ap >/dev/null +agents-vault --silent +set -l aslug (_agents_repo_slug $ap) +agents-vault --adopt=my-chosen-slug --silent +popd >/dev/null +check "adopt renamed the entry" adopted (cat $vroot9/agent-vault/projects/my-chosen-slug/claude/memory/a.md) +check "adopt repinned the link" (path resolve $vroot9/agent-vault/projects/my-chosen-slug/claude/memory) (path resolve $croot9/$amang/memory) +check "adopt removed the old entry" false (test -d $vroot9/agent-vault/projects/$aslug; and echo true; or echo false) +check "adopt recorded the rebind" true (string match -q "*$aslug*my-chosen-slug*" -- (cat $vroot9/agent-vault/projects/my-chosen-slug/origin); and echo true; or echo false) + +# An unvalidated --adopt slug is a path-traversal primitive: it lands in +# "$vault/projects/$slug" and in `git mv`. Only the charset the slug +# formula itself emits is accepted, and a leading dot is refused too. +set -l bp (new_repo) +set -l bmang (string replace -a '/' '-' -- $bp | string replace -a '.' '-') +mkdir -p $croot9/$bmang/memory +echo bad >$croot9/$bmang/memory/b.md +pushd $bp >/dev/null +agents-vault --silent +popd >/dev/null +set -l projects_before (command ls -A $vroot9/agent-vault/projects | sort | string join ',') + +set -l bad_slugs ../escape .hidden has/slash . .. 'UPPER' 'sp ace' '' +pushd $bp >/dev/null +for bad in $bad_slugs + set -l berr (mktemp); set -ga TMPDIRS $berr + set -l brc (agents-vault --adopt=$bad --silent 2>$berr; echo $status) + check "adopt refuses '$bad'" 1 "$brc" + check "adopt refuses '$bad' out loud" true (string match -q '*invalid*' -- (cat $berr); and echo true; or echo false) +end +popd >/dev/null + +check "refused adopts moved nothing" "$projects_before" (command ls -A $vroot9/agent-vault/projects | sort | string join ',') +check "refused adopts escaped nothing above projects/" false (test -e $vroot9/agent-vault/escape; and echo true; or echo false) +check "refused adopts created no dotted entry" false (test -e $vroot9/agent-vault/projects/.hidden; and echo true; or echo false) + +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 + +# ─────────────────────────────── restore ─────────────────────────────── +# The batch counterpart of the emergent per-project restore: walk the vault +# and relink every entry whose recorded origin path still exists. +echo "" +echo "== agents-vault (restore) ==" + +set -l vroot10 (mktemp -d); set -ga TMPDIRS $vroot10 +set -l croot10 (mktemp -d); set -ga TMPDIRS $croot10 +set -l chome10 (mktemp -d); set -ga TMPDIRS $chome10 +set -l agy10 (mktemp -d); set -ga TMPDIRS $agy10 +set -g __fish_agent_vault_dir $vroot10/agent-vault +set -g __fish_agent_vault_claude_root $croot10 +set -g __fish_agent_vault_claude_home $chome10 +set -g __fish_agent_vault_agy_root $agy10 + +set -l rp (new_repo https://git.rootiest.dev/rootiest/restoreme.git) +set -l rmang (string replace -a '/' '-' -- $rp | string replace -a '.' '-') +mkdir -p $croot10/$rmang/memory +echo restore-precious >$croot10/$rmang/memory/keep.md +pushd $rp >/dev/null +agents-vault --silent +popd >/dev/null + +# Lose the live link the way a reinstalled machine would. +rm -f $croot10/$rmang/memory +check "restore: link gone to begin with" false (test -e $croot10/$rmang/memory; and echo true; or echo false) + +set -l rout (agents-vault --restore) +check "restore: relinked from the origin file" restore-precious (cat $croot10/$rmang/memory/keep.md 2>/dev/null) +check "restore: the live path is a link" true (test -L $croot10/$rmang/memory; and echo true; or echo false) +check "restore: names what it restored" true (string match -q '*restoreme*' -- "$rout"; and echo true; or echo false) + +# An entry whose recorded path is gone cannot be placed; the run still +# succeeds and says which entry needs --adopt. +mkdir -p $vroot10/agent-vault/projects/ghost-entry/claude/memory +echo x >$vroot10/agent-vault/projects/ghost-entry/claude/memory/x.md +printf 'remote: (none)\npath: %s\nhost: t\n' $vroot10/gone-forever \ + >$vroot10/agent-vault/projects/ghost-entry/origin +set -l r2out (mktemp); set -ga TMPDIRS $r2out +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) + +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 + +# ──────────────────────────────── push ───────────────────────────────── +echo "" +echo "== agents-vault (push) ==" + +set -l vroot11 (mktemp -d); set -ga TMPDIRS $vroot11 +set -l croot11 (mktemp -d); set -ga TMPDIRS $croot11 +set -l chome11 (mktemp -d); set -ga TMPDIRS $chome11 +set -l agy11 (mktemp -d); set -ga TMPDIRS $agy11 +set -l bare (mktemp -d); set -ga TMPDIRS $bare +git init -q --bare $bare +set -g __fish_agent_vault_dir $vroot11/agent-vault +set -g __fish_agent_vault_claude_root $croot11 +set -g __fish_agent_vault_claude_home $chome11 +set -g __fish_agent_vault_agy_root $agy11 + +set -l pp (new_repo https://git.rootiest.dev/rootiest/pushme.git) +set -l pslug git.rootiest.dev-rootiest-pushme +set -l pmang (string replace -a '/' '-' -- $pp | string replace -a '.' '-') +mkdir -p $croot11/$pmang/memory +echo pushed >$croot11/$pmang/memory/p.md + +# --push with no remote must fail loudly. The commit still happened, so +# silently returning 0 would read as "backed up off this machine". +set -l perr (mktemp); set -ga TMPDIRS $perr +pushd $pp >/dev/null +set -l prc0 (agents-vault --push --silent 2>$perr; echo $status) +popd >/dev/null +check "push without a remote returns 1" 1 "$prc0" +check "push without a remote says so" true (string match -q '*no remote*' -- (cat $perr); and echo true; or echo false) +check "push without a remote still committed locally" true (git -C $vroot11/agent-vault ls-files --error-unmatch projects/$pslug/claude/memory/p.md >/dev/null 2>&1; and echo true; or echo false) + +agents-vault --remote=$bare --silent +set -l vbranch (git -C $vroot11/agent-vault rev-parse --abbrev-ref HEAD) +pushd $pp >/dev/null +set -l prc (agents-vault --push --silent; echo $status) +popd >/dev/null +check "push exits 0" 0 "$prc" +check "push landed in the remote" pushed (git -C $bare show $vbranch:projects/$pslug/claude/memory/p.md 2>/dev/null) + +# Autopush is opt-in and off by default: a plain run must not push. +echo pushed-later >$croot11/$pmang/memory/p2.md +pushd $pp >/dev/null +agents-vault --silent +popd >/dev/null +check "no autopush by default" false (git -C $bare cat-file -e $vbranch:projects/$pslug/claude/memory/p2.md 2>/dev/null; and echo true; or echo false) + +set -g __fish_agent_vault_autopush 1 +echo pushed-auto >$croot11/$pmang/memory/p3.md +pushd $pp >/dev/null +agents-vault --silent +popd >/dev/null +set -e __fish_agent_vault_autopush +check "autopush pushes when enabled" pushed-auto (git -C $bare show $vbranch:projects/$pslug/claude/memory/p3.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 +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 +# the global-memory guard can notice it; the vault side is deliberately +# left empty so no other disjunct can stand in and pass this by accident. +echo "" +echo "== agents-vault (dangling global memory link) ==" + +set -l vroot12 (mktemp -d); set -ga TMPDIRS $vroot12 +set -l croot12 (mktemp -d); set -ga TMPDIRS $croot12 +set -l chome12 (mktemp -d); set -ga TMPDIRS $chome12 +set -l agy12 (mktemp -d); set -ga TMPDIRS $agy12 +set -g __fish_agent_vault_dir $vroot12/agent-vault +set -g __fish_agent_vault_claude_root $croot12 +set -g __fish_agent_vault_claude_home $chome12 +set -g __fish_agent_vault_agy_root $agy12 + +ln -s $vroot12/vanished-vault/global/claude/memory $chome12/memory +check "dangling: -e is false for the broken link" false (test -e $chome12/memory; and echo true; or echo false) +check "dangling: -L is the only signal" true (test -L $chome12/memory; and echo true; or echo false) +check "dangling: the vault side is empty" false (test -e $vroot12/agent-vault/global/claude/memory; and echo true; or echo false) + +set -l dp (new_repo https://git.rootiest.dev/rootiest/dangling.git) +set -l derr (mktemp); set -ga TMPDIRS $derr +pushd $dp >/dev/null +set -l drc (agents-vault --silent 2>$derr; echo $status) +popd >/dev/null + +check "dangling: exits 0" 0 "$drc" +check "dangling: warns about nothing" "" (cat $derr) +check "dangling: repinned into the vault" (path resolve $vroot12/agent-vault/global/claude/memory) (path resolve $chome12/memory) +check "dangling: the link resolves again" true (test -d $chome12/memory; and echo true; or echo false) + +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 + # ──────────────────────── 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