From 1dc0e5293deb5e074d9ba394da53d901cbe3122a Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 2 Sep 2026 21:01:56 -0400 Subject: [PATCH 01/19] feat(agents-vault): derive vault slugs from normalized remote URLs Keys a project by its remote rather than its path so the key survives a machine change or a directory rename. Falls back to a path-derived local-* key when no remote exists. Adds a hermetic test harness that builds throwaway repos under mktemp. --- functions/_agents_repo_slug.fish | 69 +++++++++++++++++++++++++ tests/test-agents-vault.fish | 88 ++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 functions/_agents_repo_slug.fish create mode 100644 tests/test-agents-vault.fish diff --git a/functions/_agents_repo_slug.fish b/functions/_agents_repo_slug.fish new file mode 100644 index 0000000..6825d2a --- /dev/null +++ b/functions/_agents_repo_slug.fish @@ -0,0 +1,69 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# SYNOPSIS +# _agents_repo_slug +# +# DESCRIPTION +# Derives the vault slug for a project directory. Prefers the normalized +# git remote URL so the same project keys identically from any clone on +# any machine; falls back to a path-derived key when no remote exists. +# +# Normalization strips the scheme, userinfo, and a numeric port, rewrites +# scp-form host:path to host/path, drops a trailing .git, lowercases, and +# maps every character outside [a-z0-9._-] to a dash. These all yield +# git.rootiest.dev-rootiest-fish-config: +# +# https://git.rootiest.dev/rootiest/fish-config.git +# git@git.rootiest.dev:rootiest/fish-config.git +# ssh://git@git.rootiest.dev:22/rootiest/fish-config.git +# +# With no remote the slug is local--<8 hex of sha256(realpath)>. +# That key is machine-dependent by construction and is best-effort only; +# agents-vault --adopt rebinds such an entry by hand. +# +# ARGUMENTS +# dir Absolute path to the project directory +# +# EXIT STATUS +# 0 Slug printed +# 1 No directory argument given +# +# RETURNS +# The slug, one line on stdout. +# +# EXAMPLE +# set -l slug (_agents_repo_slug /home/user/myproject) +function _agents_repo_slug --argument-names dir + test -n "$dir"; or return 1 + + set -l url (git -C "$dir" remote get-url origin 2>/dev/null) + if test -z "$url" + set -l remotes (git -C "$dir" remote 2>/dev/null) + if test (count $remotes) -gt 0 + set url (git -C "$dir" remote get-url $remotes[1] 2>/dev/null) + end + end + + if test -n "$url" + set -l s $url + # Order matters: the port must go before the scp-form rewrite, or + # ssh://host:22/a/b becomes host/22/a/b and diverges from the + # https slug for the same repository. + set s (string replace -r '^[A-Za-z][A-Za-z0-9+.-]*://' '' -- $s) + set s (string replace -r '^[^@/]+@' '' -- $s) + set s (string replace -r '^([^/:]+):[0-9]+/' '$1/' -- $s) + set s (string replace -r '^([^/:]+):' '$1/' -- $s) + set s (string replace -r '\.git$' '' -- $s) + set s (string replace -r '/+$' '' -- $s) + set s (string lower -- $s) + set s (string replace -ra '[^a-z0-9._-]' '-' -- $s) + printf '%s\n' $s + return 0 + end + + set -l rp (path resolve "$dir") + set -l base (string lower -- (path basename "$rp")) + set -l digest (printf '%s' "$rp" | sha256sum | string split -f1 ' ') + printf 'local-%s-%s\n' "$base" (string sub -l 8 -- "$digest") +end diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish new file mode 100644 index 0000000..8de37ba --- /dev/null +++ b/tests/test-agents-vault.fish @@ -0,0 +1,88 @@ +#!/usr/bin/env fish +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Hermetic tests for the agent memory vault helpers. Every test builds its +# own throwaway git repos and directories under mktemp; nothing touches the +# live vault, ~/.claude, or this checkout. +# +# Usage: fish tests/test-agents-vault.fish + +set -l here (realpath (dirname (status filename))) +set -g repo_root (realpath $here/..) +set -p fish_function_path $repo_root/functions + +set -g TESTS_RUN 0 +set -g TESTS_FAILED 0 +set -g TMPDIRS + +function check --argument-names label want got + set -g TESTS_RUN (math $TESTS_RUN + 1) + if test "$want" = "$got" + echo " PASS $label" + else + echo " FAIL $label" + echo " want: $want" + echo " got: $got" + set -g TESTS_FAILED (math $TESTS_FAILED + 1) + end +end + +# Create a throwaway git repo, optionally with an origin remote. +function new_repo --argument-names url + set -l d (mktemp -d) + set -ga TMPDIRS $d + git -C $d init -q + git -C $d config user.email t@t + git -C $d config user.name t + git -C $d config commit.gpgsign false + git -C $d config core.hooksPath /dev/null + test -n "$url"; and git -C $d remote add origin $url + printf '%s\n' $d +end + +function cleanup + for d in $TMPDIRS + test -n "$d"; and rm -rf $d + end +end + +# ─────────────────────────── slug derivation ─────────────────────────── +echo "== _agents_repo_slug ==" + +set -l want git.rootiest.dev-rootiest-fish-config + +set -l r (new_repo https://git.rootiest.dev/rootiest/fish-config.git) +check "https form" $want (_agents_repo_slug $r) + +set r (new_repo git@git.rootiest.dev:rootiest/fish-config.git) +check "scp form" $want (_agents_repo_slug $r) + +set r (new_repo ssh://git@git.rootiest.dev:22/rootiest/fish-config.git) +check "ssh form with port" $want (_agents_repo_slug $r) + +set r (new_repo https://git.rootiest.dev/rootiest/fish-config) +check "no .git suffix" $want (_agents_repo_slug $r) + +set r (new_repo HTTPS://Git.Rootiest.DEV/Rootiest/Fish-Config.git) +check "case folded" $want (_agents_repo_slug $r) + +# Remote-less: local- prefix, stable across runs, distinct per path. +set -l n1 (new_repo) +set -l s1 (_agents_repo_slug $n1) +set -l s2 (_agents_repo_slug $n1) +check "local slug is stable" $s1 $s2 +check "local slug is prefixed" true (string match -q 'local-*' -- $s1; and echo true; or echo false) + +set -l n2 (new_repo) +check "local slugs differ by path" false (test "$s1" = (_agents_repo_slug $n2); and echo true; or echo false) + +# A non-origin remote is used when origin is absent. +set -l r3 (new_repo) +git -C $r3 remote add upstream https://git.rootiest.dev/rootiest/fish-config.git +check "falls back to first remote" $want (_agents_repo_slug $r3) + +cleanup +echo "" +echo (math $TESTS_RUN - $TESTS_FAILED)"/$TESTS_RUN passed" +exit $TESTS_FAILED -- 2.54.0 From 3fd9476fbc717cd7d1c805424a092375cd5b7b7a Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 2 Sep 2026 21:07:39 -0400 Subject: [PATCH 02/19] feat(agents-vault): add directory-only symlink helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforces the rails the vault depends on: only directories are linked (agent editing tools refuse to write through a symlinked file), a missing target is refused rather than turned into a dangling link, and adopting a populated live directory copies without clobbering. Also fixes slug sanitization in _agents_repo_slug to apply the same [^a-z0-9._-] → - mapping to the fallback (no-remote) branch, ensuring local project slugs are filesystem-safe and won't leak special chars like spaces or exclamation marks. --- functions/_agents_repo_ensure_symlink.fish | 71 ++++++++++++++++++++++ functions/_agents_repo_slug.fish | 5 +- tests/test-agents-vault.fish | 54 ++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 functions/_agents_repo_ensure_symlink.fish diff --git a/functions/_agents_repo_ensure_symlink.fish b/functions/_agents_repo_ensure_symlink.fish new file mode 100644 index 0000000..82d6ac3 --- /dev/null +++ b/functions/_agents_repo_ensure_symlink.fish @@ -0,0 +1,71 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# SYNOPSIS +# _agents_repo_ensure_symlink +# +# DESCRIPTION +# Idempotently makes a symlink pointing at the directory . +# +# Only directories are ever linked. The agent file-editing tools resolve a +# symlinked directory transparently but refuse to write through a +# symlinked file, so linking a file would silently break every later edit; +# a non-directory target is refused outright. +# +# A missing target is refused rather than linked, because a dangling +# memory/ symlink makes agent memory writes fail -- strictly worse than +# having no backup at all. +# +# When is an existing real directory, its contents are copied into +# without clobbering (cp -n) before the directory is replaced by +# the link, so adopting a populated live directory never overwrites the +# copy already in the vault. +# +# ARGUMENTS +# link Path that should become the symlink +# target Existing directory the link should point at +# +# EXIT STATUS +# 0 Link is correct (created, repinned, or already right) +# 1 Refused (non-directory target, missing target, non-directory link) or +# a copy, remove, or link operation failed +# +# RETURNS +# A single "→ ..." progress line on stdout when something changed; +# nothing at all when the link was already correct. +# +# EXAMPLE +# _agents_repo_ensure_symlink ~/.claude/projects/-home-u-proj/memory \ +# ~/.local/share/agent-vault/projects/host-user-proj/claude/memory +function _agents_repo_ensure_symlink --argument-names link target + test -n "$link" -a -n "$target"; or return 1 + + if test -e "$target"; and not test -d "$target" + echo "_agents_repo_ensure_symlink: refusing non-directory target: $target" >&2 + return 1 + end + if not test -d "$target" + echo "_agents_repo_ensure_symlink: target does not exist: $target" >&2 + return 1 + end + + if test -L "$link" + set -l cur (path resolve "$link") + set -l want (path resolve "$target") + test "$cur" = "$want"; and return 0 + rm -f "$link"; or return 1 + else if test -d "$link" + set -l contents (command ls -A "$link" 2>/dev/null) + if test (count $contents) -gt 0 + command cp -rn "$link/." "$target/"; or return 1 + end + rm -rf "$link"; or return 1 + else if test -e "$link" + echo "_agents_repo_ensure_symlink: refusing to replace non-directory: $link" >&2 + return 1 + end + + mkdir -p (path dirname "$link"); or return 1 + ln -s "$target" "$link"; or return 1 + echo "→ Linked "(path basename "$link")" → $target" +end diff --git a/functions/_agents_repo_slug.fish b/functions/_agents_repo_slug.fish index 6825d2a..57a4724 100644 --- a/functions/_agents_repo_slug.fish +++ b/functions/_agents_repo_slug.fish @@ -18,7 +18,8 @@ # git@git.rootiest.dev:rootiest/fish-config.git # ssh://git@git.rootiest.dev:22/rootiest/fish-config.git # -# With no remote the slug is local--<8 hex of sha256(realpath)>. +# With no remote the slug is local--<8 hex of sha256(realpath)>, +# where the basename is lowercased and mapped the same way as the remote form. # That key is machine-dependent by construction and is best-effort only; # agents-vault --adopt rebinds such an entry by hand. # @@ -63,7 +64,7 @@ function _agents_repo_slug --argument-names dir end set -l rp (path resolve "$dir") - set -l base (string lower -- (path basename "$rp")) + set -l base (string lower -- (path basename "$rp") | string replace -ra '[^a-z0-9._-]' '-') set -l digest (printf '%s' "$rp" | sha256sum | string split -f1 ' ') printf 'local-%s-%s\n' "$base" (string sub -l 8 -- "$digest") end diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index 8de37ba..c59589b 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -82,6 +82,60 @@ set -l r3 (new_repo) git -C $r3 remote add upstream https://git.rootiest.dev/rootiest/fish-config.git check "falls back to first remote" $want (_agents_repo_slug $r3) +# Slug sanitization test: special chars in fallback (local-) branch. +set -l dirt (mktemp -d); set -ga TMPDIRS $dirt +mkdir -p "$dirt/projects/My Project!" +git -C "$dirt/projects/My Project!" init -q +git -C "$dirt/projects/My Project!" config user.email t@t +git -C "$dirt/projects/My Project!" config user.name t +set -l slug_dirty (_agents_repo_slug "$dirt/projects/My Project!") +set -l has_bad_chars (string match -q '*[ !]*' -- "$slug_dirty"; and echo true; or echo false) +check "sanitizes special chars in local slug" false "$has_bad_chars" + +# ──────────────────────── ensure_symlink rails ───────────────────────── +echo "" +echo "== _agents_repo_ensure_symlink ==" + +set -l w (mktemp -d); set -ga TMPDIRS $w +mkdir -p $w/target $w/live + +# Fresh link onto an empty live parent. +_agents_repo_ensure_symlink $w/live/memory $w/target >/dev/null +check "creates the link" true (test -L $w/live/memory; and echo true; or echo false) +check "link resolves to target" (path resolve $w/target) (path resolve $w/live/memory) + +# Idempotent: a second run prints nothing. +set -l second (_agents_repo_ensure_symlink $w/live/memory $w/target) +check "idempotent, silent" "" "$second" + +# Refuses a non-directory target (a symlinked FILE breaks agent edits). +touch $w/afile +check "refuses file target" 1 (_agents_repo_ensure_symlink $w/live/f2 $w/afile 2>/dev/null; echo $status) + +# Refuses to create a dangling link when the target is missing. +check "refuses missing target" 1 (_agents_repo_ensure_symlink $w/live/f3 $w/nope 2>/dev/null; echo $status) +check "no dangling link left" false (test -L $w/live/f3; and echo true; or echo false) + +# Non-destructive adoption: content on both sides, nothing overwritten. +set -l a (mktemp -d); set -ga TMPDIRS $a +mkdir -p $a/vault $a/live/memory +echo vault-version >$a/vault/shared.md +echo vault-only >$a/vault/vaultonly.md +echo live-version >$a/live/memory/shared.md +echo live-only >$a/live/memory/liveonly.md +_agents_repo_ensure_symlink $a/live/memory $a/vault >/dev/null +check "adoption keeps vault copy" vault-version (cat $a/vault/shared.md) +check "adoption imports live-only file" live-only (cat $a/vault/liveonly.md) +check "adoption keeps vault-only file" vault-only (cat $a/vault/vaultonly.md) +check "adoption replaced dir with link" true (test -L $a/live/memory; and echo true; or echo false) + +# Repins a link that points somewhere else. +set -l p (mktemp -d); set -ga TMPDIRS $p +mkdir -p $p/one $p/two +ln -s $p/one $p/link +_agents_repo_ensure_symlink $p/link $p/two >/dev/null +check "repins a wrong link" (path resolve $p/two) (path resolve $p/link) + cleanup echo "" echo (math $TESTS_RUN - $TESTS_FAILED)"/$TESTS_RUN passed" -- 2.54.0 From 51543cb7cac113c231e23f0240b7fe020fcd13da Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 2 Sep 2026 21:15:15 -0400 Subject: [PATCH 03/19] feat(agents-vault): add sync helper that refuses to commit conflicts agents-init currently swallows a failed rebase and then stages and commits whatever is in the tree, which records conflict markers under a routine message. No AGENTS repo has a remote today so the pull never runs, but the vault gives these repos remotes and arms it. The shared helper aborts the rebase, commits nothing, and returns 2. It also redirects git's own stdout during the pull/abort: git prints "CONFLICT (content): ..." to stdout, not stderr, so without this the message would leak into the helper's own stdout instead of staying diagnostic-only. The conflict fixture commits "ours" locally before diverging, since an uncommitted worktree change has nothing for --autostash's rebase step to replay -- it fast-forwards cleanly and only the stash pop would conflict. --- functions/_agents_repo_sync.fish | 55 ++++++++++++++++++++++++++++++++ tests/test-agents-vault.fish | 47 +++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 functions/_agents_repo_sync.fish diff --git a/functions/_agents_repo_sync.fish b/functions/_agents_repo_sync.fish new file mode 100644 index 0000000..513d888 --- /dev/null +++ b/functions/_agents_repo_sync.fish @@ -0,0 +1,55 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# SYNOPSIS +# _agents_repo_sync +# +# DESCRIPTION +# Pulls (when an upstream is configured), stages everything, and commits +# 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. +# +# Commits are made with commit.gpgsign=false so a pinentry prompt can +# never block a shell or an agent launch. +# +# ARGUMENTS +# dir Absolute path to the git repository +# message Commit subject used when there is something to commit +# +# EXIT STATUS +# 0 Committed, or nothing needed committing +# 1 is not a git repository +# 2 Rebase conflict; aborted, nothing committed +# +# RETURNS +# A single "→ Committed () " line on stdout when it +# commits; nothing when there was nothing to do. +# +# EXAMPLE +# _agents_repo_sync /path/to/AGENTS "chore: sync AGENTS repository" +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 + end + + git -C "$dir" add -A 2>/dev/null + set -l status_out (git -C "$dir" status --porcelain 2>/dev/null) + test -n "$status_out"; or return 0 + + if git -C "$dir" -c commit.gpgsign=false commit -q -m "$msg" 2>/dev/null + set -l sha (git -C "$dir" rev-parse --short HEAD 2>/dev/null) + set -l subject (git -C "$dir" log -1 --pretty=%s 2>/dev/null) + echo "→ Committed ($sha) $subject" + end +end diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index c59589b..0c50b02 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -136,6 +136,53 @@ ln -s $p/one $p/link _agents_repo_ensure_symlink $p/link $p/two >/dev/null check "repins a wrong link" (path resolve $p/two) (path resolve $p/link) +# ─────────────────────────── sync policy ─────────────────────────────── +echo "" +echo "== _agents_repo_sync ==" + +set -l s (new_repo) +echo hello >$s/a.md +_agents_repo_sync $s "chore: test" >/dev/null +check "commits new content" 1 (git -C $s rev-list --count HEAD) + +# Nothing to do: no second commit, no output. +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.) +set -l origin (mktemp -d); set -ga TMPDIRS $origin +git -C $origin init -q --bare +git -C $s remote add origin $origin +git -C $s push -q -u origin HEAD:refs/heads/main 2>/dev/null + +set -l clone (mktemp -d); set -ga TMPDIRS $clone +git clone -q $origin $clone +git -C $clone config user.email t@t +git -C $clone config user.name t +git -C $clone config commit.gpgsign false +git -C $clone config core.hooksPath /dev/null +echo theirs >$clone/a.md +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) + cleanup echo "" echo (math $TESTS_RUN - $TESTS_FAILED)"/$TESTS_RUN passed" -- 2.54.0 From b5d2c9ba87207a4d10cb5d6a7ce52b22228ab313 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 2 Sep 2026 21:21:39 -0400 Subject: [PATCH 04/19] fix(agents-vault): surface commit-hook rejection as exit 1 _agents_repo_sync fell off the end of its final if-block when git commit failed, e.g. a pre-commit or commit-msg hook rejecting it (this repo runs ggshield and Git-LFS hooks). fish's if construct sets status 0 when the condition is false and there is no else branch, so a rejected commit was being reported as success rather than as the documented "not a git repository" exit 1 it was assumed to fall through to. Add an explicit else branch that emits a stderr diagnostic and returns 1, and widen the EXIT STATUS/DESCRIPTION docs to cover this path under the existing code 1 rather than adding a fourth code, since later tasks already consume the 0/1/2 contract. --- functions/_agents_repo_sync.fish | 11 +++++++++-- tests/test-agents-vault.fish | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/functions/_agents_repo_sync.fish b/functions/_agents_repo_sync.fish index 513d888..876ee33 100644 --- a/functions/_agents_repo_sync.fish +++ b/functions/_agents_repo_sync.fish @@ -14,7 +14,9 @@ # left clean at local HEAD for the user to resolve by hand. # # Commits are made with commit.gpgsign=false so a pinentry prompt can -# never block a shell or an agent launch. +# never block a shell or an agent launch. If a pre-commit or commit-msg +# hook rejects the commit (e.g. a secret scanner), that failure is +# surfaced too: nothing is committed and a diagnostic goes to stderr. # # ARGUMENTS # dir Absolute path to the git repository @@ -22,7 +24,8 @@ # # EXIT STATUS # 0 Committed, or nothing needed committing -# 1 is not a git repository +# 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 # # RETURNS @@ -51,5 +54,9 @@ function _agents_repo_sync --argument-names dir msg set -l sha (git -C "$dir" rev-parse --short HEAD 2>/dev/null) set -l subject (git -C "$dir" log -1 --pretty=%s 2>/dev/null) echo "→ Committed ($sha) $subject" + return 0 + else + echo "_agents_repo_sync: commit failed in $dir (hook rejected it?); nothing committed" >&2 + return 1 end end diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index 0c50b02..d1827dc 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -183,6 +183,25 @@ 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) +# Commit-hook rejection: the commit call itself fails (e.g. a secret +# scanner in a pre-commit hook), distinct from "not a git repository" -- +# both currently map to exit 1, so this must not fall through silently or +# report success. +set -l h (new_repo) +echo first >$h/a.md +_agents_repo_sync $h "chore: init" >/dev/null + +set -l hooks (mktemp -d); set -ga TMPDIRS $hooks +printf '#!/bin/sh\nexit 1\n' >$hooks/pre-commit +chmod +x $hooks/pre-commit +git -C $h config core.hooksPath $hooks + +echo second >$h/a.md +set -l before_hook_count (git -C $h rev-list --count HEAD) +set -l hrc (_agents_repo_sync $h "chore: blocked" 2>/dev/null; echo $status) +check "commit hook rejection returns 1" 1 "$hrc" +check "commit hook rejection commits nothing" $before_hook_count (git -C $h rev-list --count HEAD) + cleanup echo "" echo (math $TESTS_RUN - $TESTS_FAILED)"/$TESTS_RUN passed" -- 2.54.0 From b0585d00adc424f95b0fbb6969594fc6d13a2806 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 2 Sep 2026 21:27:21 -0400 Subject: [PATCH 05/19] refactor(agents-init): use the shared repo helpers Renames _agents_init_install_tools to _agents_repo_install_tools now that the vault shares it, collapses the two duplicated root-symlink blocks into one loop, and routes the auto-commit through _agents_repo_sync so a failed rebase can no longer be committed as conflict markers. --- ...s.fish => _agents_repo_install_tools.fish} | 17 ++-- functions/agents-init.fish | 82 ++++++++----------- 2 files changed, 42 insertions(+), 57 deletions(-) rename functions/{_agents_init_install_tools.fish => _agents_repo_install_tools.fish} (77%) diff --git a/functions/_agents_init_install_tools.fish b/functions/_agents_repo_install_tools.fish similarity index 77% rename from functions/_agents_init_install_tools.fish rename to functions/_agents_repo_install_tools.fish index 5491971..70f7e77 100644 --- a/functions/_agents_init_install_tools.fish +++ b/functions/_agents_repo_install_tools.fish @@ -2,31 +2,32 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # SYNOPSIS -# _agents_init_install_tools +# _agents_repo_install_tools # # DESCRIPTION # Copies the canonical version-bump script and git hook shims from -# fish-config's scripts/agents-tools/ into /.agents-tools/, +# fish-config's scripts/agents-tools/ into /.agents-tools/, # refreshing them when the shipped agents-tools-version: marker is newer # than the installed copy. Files are made executable. Idempotent: prints # nothing when the installed tooling is already current, or a short summary -# line when it installed or updated the tooling. +# line when it installed or updated the tooling. Shared by agents-init and +# agents-vault. # # ARGUMENTS -# agents_dir Absolute path to the AGENTS/ sub-repo root +# repo_dir Absolute path to the git repo root to install tooling into # # EXIT STATUS # 0 Tooling is current or was installed/updated successfully # 1 Canonical source missing or a copy failed # # EXAMPLE -# set -l msg (_agents_init_install_tools /path/to/AGENTS) +# set -l msg (_agents_repo_install_tools /path/to/AGENTS) # test -n "$msg"; and echo $msg -function _agents_init_install_tools --argument-names agents_dir - test -n "$agents_dir"; or return 1 +function _agents_repo_install_tools --argument-names repo_dir + test -n "$repo_dir"; or return 1 set -l src (path resolve (status dirname)/../scripts/agents-tools) test -f "$src/version-bump"; or return 1 - set -l dest "$agents_dir/.agents-tools" + set -l dest "$repo_dir/.agents-tools" set -l want (command grep -m1 -oE 'agents-tools-version: *[0-9]+' "$src/version-bump" 2>/dev/null | command grep -oE '[0-9]+$') set -l have "" diff --git a/functions/agents-init.fish b/functions/agents-init.fish index 5814379..b461f68 100644 --- a/functions/agents-init.fish +++ b/functions/agents-init.fish @@ -4,6 +4,9 @@ # CATEGORY # 12-ai-and-developer-tools # +# DEPENDENCIES +# _agents_repo_install_tools, _agents_repo_sync, _agents_init_ensure_gitignore +# # SYNOPSIS # agents-init [-a | --agents] [-p | --plugins] [-v | --verbose] # [-q | --quiet] [-s | --silent] [-h | --help] @@ -165,7 +168,7 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi test $verbose -eq 1; and echo "$c_ok→ Created AGENTS/.version (1.0.0)$c_reset" end - set -l _tools (_agents_init_install_tools "$agents_dir") + set -l _tools (_agents_repo_install_tools "$agents_dir") if test -n "$_tools" set changed 1 test $verbose -eq 1; and echo "$c_ok$_tools$c_reset" @@ -259,38 +262,26 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi test $verbose -eq 1; and echo "$c_ok→ Linked AGENTS/CLAUDE.md → AGENTS/AGENTS.md$c_reset" end - # ── Root symlink: AGENTS.md → AGENTS/AGENTS.md ─────────────────────── - set -l _need_link 0 - if not test -L "$root/AGENTS.md" - set _need_link 1 - else if test (readlink "$root/AGENTS.md") != AGENTS/AGENTS.md - rm -f "$root/AGENTS.md" - set _need_link 1 - end - if test $_need_link -eq 1 - if not ln -s AGENTS/AGENTS.md "$root/AGENTS.md" - echo "$c_err""Error: could not create AGENTS.md symlink$c_reset" >&2 - return 1 + # Root symlinks point at files, not directories, so they cannot use + # _agents_repo_ensure_symlink (which is directory-only by design). + for pair in "AGENTS.md:AGENTS/AGENTS.md" "CLAUDE.md:AGENTS/CLAUDE.md" + set -l name (string split -f1 ':' -- $pair) + set -l want (string split -f2 ':' -- $pair) + set -l need 0 + if not test -L "$root/$name" + set need 1 + else if test (readlink "$root/$name") != "$want" + rm -f "$root/$name" + set need 1 end - set changed 1 - test $verbose -eq 1; and echo "$c_ok→ Linked AGENTS.md → AGENTS/AGENTS.md$c_reset" - end - - # ── Root symlink: CLAUDE.md → AGENTS/CLAUDE.md ─────────────────────── - set -l _need_link 0 - if not test -L "$root/CLAUDE.md" - set _need_link 1 - else if test (readlink "$root/CLAUDE.md") != AGENTS/CLAUDE.md - rm -f "$root/CLAUDE.md" - set _need_link 1 - end - if test $_need_link -eq 1 - if not ln -s AGENTS/CLAUDE.md "$root/CLAUDE.md" - echo "$c_err""Error: could not create CLAUDE.md symlink$c_reset" >&2 - return 1 + if test $need -eq 1 + if not ln -s "$want" "$root/$name" + echo "$c_err""Error: could not create $name symlink$c_reset" >&2 + return 1 + end + set changed 1 + test $verbose -eq 1; and echo "$c_ok→ Linked $name → $want$c_reset" end - set changed 1 - test $verbose -eq 1; and echo "$c_ok→ Linked CLAUDE.md → AGENTS/CLAUDE.md$c_reset" end # ── .gitignore ──────────────────────────────────────────────────────── @@ -461,24 +452,17 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi end # ──────────────────────── Auto-commit AGENTS/ ──────────────────────────── - # Pull first when an upstream is configured so the local .version reflects - # any remote bumps before we add to it (no-op for local-only repos). - if git -C "$agents_dir" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1 - git -C "$agents_dir" pull --rebase --autostash -q 2>/dev/null - end - git -C "$agents_dir" add -A 2>/dev/null - set -l status_out (git -C "$agents_dir" status --porcelain 2>/dev/null) - if test -n "$status_out" - set -l msg "chore: sync AGENTS repository" - test $did_init -eq 1; and set msg "chore: initialize AGENTS repository" - if git -C "$agents_dir" -c commit.gpgsign=false commit -q -m "$msg" 2>/dev/null - set changed 1 - if test $verbose -eq 1 - set -l sha (git -C "$agents_dir" rev-parse --short HEAD 2>/dev/null) - set -l realmsg (git -C "$agents_dir" log -1 --pretty=%s 2>/dev/null) - echo "$c_ok→ Committed AGENTS/ ($sha) $c_dim$realmsg$c_reset" - end - end + # Pulls first when an upstream is configured (no-op for local-only repos) + # and refuses to commit a failed rebase's conflict markers. + 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 + if test $sync_rc -eq 2 + echo "$c_warn→ AGENTS/ has an unresolved rebase conflict; nothing committed$c_reset" >&2 + else if test -n "$sync_out" + set changed 1 + test $verbose -eq 1; and echo "$c_ok$sync_out$c_reset" end # Quiet summary: one line at the end, only if something actually changed -- 2.54.0 From 2c185f7e23dcb2691fe28d26e71555d1b90bbc1a Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 2 Sep 2026 21:37:43 -0400 Subject: [PATCH 06/19] feat(agents-vault): scaffold the vault and link project memory Creates the vault repo on demand, reusing the AGENTS version bumper and hook shims, then links the current project's live memory directory into its slug-keyed entry and commits. Because the live directory becomes a symlink into the vault, backup and restore are the same operation: a cloned vault relinks itself on the next run in each project, with no manifest and no batch restore step. Also fixes _agents_repo_install_tools' progress messages, which hardcoded the literal "AGENTS/.agents-tools/" even for callers writing elsewhere: they now name repo_dir's own basename, so agents-vault reports its own directory instead of a false AGENTS/ path. --- docs/manual/07-customization.md | 19 ++ functions/_agents_repo_install_tools.fish | 11 +- functions/_agents_vault_dir.fish | 32 +++ functions/agents-vault.fish | 242 ++++++++++++++++++++++ tests/test-agents-vault.fish | 41 ++++ 5 files changed, 341 insertions(+), 4 deletions(-) create mode 100644 functions/_agents_vault_dir.fish create mode 100644 functions/agents-vault.fish diff --git a/docs/manual/07-customization.md b/docs/manual/07-customization.md index abfd609..f787f27 100644 --- a/docs/manual/07-customization.md +++ b/docs/manual/07-customization.md @@ -54,6 +54,25 @@ Example: to increase the scrollback history limit: set -gx SCROLLBACK_HISTORY_MAX_FILES 200 +## Agent Memory Vault + + __fish_agent_vault_dir + + Overrides the agent memory vault location. Defaults to + $XDG_DATA_HOME/agent-vault (or ~/.local/share/agent-vault). + + __fish_agent_vault_autopush + + When set to 1, agents-vault also pushes on wrapper launch. Defaults to + off: the vault commits locally on every launch and pushes from the + Claude Code SessionEnd hook or an explicit agents-vault --push. + + NOTE: + With autopush off and no SessionEnd hook installed, backups accumulate + locally and never reach the remote. Run agents-vault --status to check + how far ahead the vault is. + + ## Fish Universal Variables Some settings (fzf colors, theme) are stored in fish_variables via diff --git a/functions/_agents_repo_install_tools.fish b/functions/_agents_repo_install_tools.fish index 70f7e77..95aeb83 100644 --- a/functions/_agents_repo_install_tools.fish +++ b/functions/_agents_repo_install_tools.fish @@ -10,8 +10,10 @@ # refreshing them when the shipped agents-tools-version: marker is newer # than the installed copy. Files are made executable. Idempotent: prints # nothing when the installed tooling is already current, or a short summary -# line when it installed or updated the tooling. Shared by agents-init and -# agents-vault. +# line when it installed or updated the tooling, naming 's own +# basename rather than a hardcoded caller (e.g. "AGENTS/.agents-tools/" for +# agents-init, "agent-vault/.agents-tools/" for agents-vault). Shared by +# agents-init and agents-vault. # # ARGUMENTS # repo_dir Absolute path to the git repo root to install tooling into @@ -41,9 +43,10 @@ function _agents_repo_install_tools --argument-names repo_dir command cp "$src/hooks/prepare-commit-msg" "$dest/hooks/prepare-commit-msg"; or return 1 chmod +x "$dest/version-bump" "$dest/hooks/pre-commit" "$dest/hooks/prepare-commit-msg"; or return 1 + set -l label (path basename -- "$repo_dir") if test -z "$have" - echo "→ Installed AGENTS/.agents-tools/ (version-bump v$want)" + echo "→ Installed $label/.agents-tools/ (version-bump v$want)" else - echo "→ Updated AGENTS/.agents-tools/ (v$have → v$want)" + echo "→ Updated $label/.agents-tools/ (v$have → v$want)" end end diff --git a/functions/_agents_vault_dir.fish b/functions/_agents_vault_dir.fish new file mode 100644 index 0000000..e2de353 --- /dev/null +++ b/functions/_agents_vault_dir.fish @@ -0,0 +1,32 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# SYNOPSIS +# _agents_vault_dir +# +# DESCRIPTION +# Prints the agent memory vault root. Honors the universal variable +# __fish_agent_vault_dir when set, otherwise +# ${XDG_DATA_HOME:-$HOME/.local/share}/agent-vault. +# +# The vault holds agy state as well as Claude state, so it is not nested +# under either tool's directory; it is backed-up state rather than +# configuration, hence XDG_DATA_HOME rather than XDG_CONFIG_HOME. +# +# EXIT STATUS +# 0 Always +# +# RETURNS +# The vault root path, one line on stdout. +# +# EXAMPLE +# set -l vault (_agents_vault_dir) +function _agents_vault_dir + if set -q __fish_agent_vault_dir; and test -n "$__fish_agent_vault_dir" + printf '%s\n' "$__fish_agent_vault_dir" + return 0 + end + set -l base $XDG_DATA_HOME + test -n "$base"; or set base "$HOME/.local/share" + printf '%s\n' "$base/agent-vault" +end diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish new file mode 100644 index 0000000..99f6638 --- /dev/null +++ b/functions/agents-vault.fish @@ -0,0 +1,242 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# CATEGORY +# 12-ai-and-developer-tools +# +# DEPENDENCIES +# _agents_vault_dir, _agents_repo_slug, _agents_repo_ensure_symlink, +# _agents_repo_sync, _agents_repo_install_tools, git +# +# SYNOPSIS +# agents-vault [--link] [--push] [--restore] [--status] +# [--adopt=SLUG] [--remote=URL] +# [-v | --verbose] [-q | --quiet] [-s | --silent] +# [-h | --help] +# +# DESCRIPTION +# Tracks curated agent memory in a host-scoped git repository so it +# survives losing a machine. Complements agents-init, which scaffolds the +# per-project AGENTS/ repo: that holds the shareable agent specification, +# while this holds the personal memory an agent accumulates. +# +# Memory does not live in any project tree. Claude keeps it under +# ~/.claude/projects//memory/ and agy keeps its knowledge +# store under ~/.gemini/antigravity-cli/, both outside every repository. +# +# Entries are keyed by normalized git remote URL rather than by path, so +# the key survives a machine change or a directory rename. The live +# memory directory becomes a symlink into the vault, which makes backup +# and restore the same operation: on a new machine, clone the vault once +# and the first agents-vault run in any project relinks its memory +# automatically. No manifest and no batch restore step are involved. +# +# 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. +# +# ARGUMENTS +# --link Ensure this project's memory link only; do not commit +# --push Commit and 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 +# --remote=URL Set the vault remote +# -v, --verbose Print all per-step output (default) +# -q, --quiet Print one summary line only if changes were made +# -s, --silent Suppress all output; errors only +# -h, --help Show this help message and exit +# +# EXIT STATUS +# 0 Completed successfully +# 1 Fatal error (vault unavailable, git failure, ambiguous migration) +# +# EXAMPLE +# agents-vault +# agents-vault --status +# agents-vault --remote=https://git.rootiest.dev/rootiest/agent-vault.git +# agents-vault --push +# +# NOTES +# Set __fish_agent_vault_dir to relocate the vault. Set +# __fish_agent_vault_autopush to 1 to also push on wrapper launch; +# it defaults to off so a backgrounded push can never hang or prompt +# invisibly underneath a starting agent. +function agents-vault --description 'track curated agent memory in a host-scoped vault repo' + set -l c_head (set_color --bold cyan) + set -l c_cmd (set_color --bold) + set -l c_flag (set_color yellow) + set -l c_ok (set_color green) + set -l c_warn (set_color yellow) + set -l c_dim (set_color brblack) + set -l c_err (set_color red) + set -l c_reset (set_color normal) + + argparse h/help link push restore status 'adopt=' 'remote=' \ + v/verbose q/quiet s/silent -- $argv + or return 1 + + if set -q _flag_help + echo "$c_head""Usage:$c_reset $c_cmd""agents-vault$c_reset $c_flag""[--link] [--push] [--restore] [--status] [--adopt=SLUG] [--remote=URL] [-v] [-q] [-s] [-h]$c_reset" + echo + echo " Track curated agent memory in a host-scoped vault repository." + echo + 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 Ensure this project's memory link only" + echo " $c_flag--push$c_reset Commit and push to the vault 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" + echo " $c_flag--remote$c_reset=URL Set the vault remote" + echo " $c_flag-v$c_reset, $c_flag--verbose$c_reset Print all per-step output (default)" + echo " $c_flag-q$c_reset, $c_flag--quiet$c_reset Print one summary line only if changed" + echo " $c_flag-s$c_reset, $c_flag--silent$c_reset Suppress all output; errors only" + return 0 + end + + set -l verbose 1 + set -l quiet 0 + if set -q _flag_silent + set verbose 0 + else if set -q _flag_quiet + set verbose 0 + set quiet 1 + end + + if not type -q git + echo "$c_err""agents-vault: git is required$c_reset" >&2 + return 1 + end + + set -l vault (_agents_vault_dir) + set -l changed 0 + set -l did_init 0 + + # ────────────────────── ensure the vault repo ────────────────────── + if not test -d "$vault" + if not mkdir -p "$vault" + echo "$c_err""agents-vault: could not create $vault$c_reset" >&2 + return 1 + end + set changed 1 + set did_init 1 + end + if not test -d "$vault/.git" + git -C "$vault" init -q + or begin + echo "$c_err""agents-vault: git init failed in $vault$c_reset" >&2 + return 1 + end + set changed 1 + set did_init 1 + test $verbose -eq 1; and echo "$c_ok→ Initialized vault repo at $vault$c_reset" + end + + test -f "$vault/.version"; or echo 1.0.0 >"$vault/.version" + + if not test -f "$vault/.gitignore" + printf '%s\n' \ + '# SQLite sidecars are never safe to commit mid-write.' \ + '*.db-wal' \ + '*.db-shm' >"$vault/.gitignore" + set changed 1 + end + + if not test -f "$vault/README.md" + printf '%s\n' \ + '# Agent Memory Vault' \ + '' \ + 'Curated agent memory, tracked so it survives losing a machine.' \ + 'Managed by `agents-vault` from rootiest/fish-config.' \ + '' \ + '## Restore' \ + '' \ + 'Clone this repository to the path `agents-vault` resolves to' \ + '(`$XDG_DATA_HOME/agent-vault`, or `$__fish_agent_vault_dir`).' \ + 'Then simply run `claude` in any project: the wrapper derives that' \ + "project's slug, finds its entry here, and relinks the live memory" \ + 'directory automatically. There is no separate restore step.' \ + '' \ + 'Entries are keyed by normalized git remote URL. A `local-*` key' \ + 'belongs to a project with no remote and is machine-specific;' \ + 'rebind one with `agents-vault --adopt=SLUG`.' >"$vault/README.md" + set changed 1 + end + + set -l tools_msg (_agents_repo_install_tools "$vault") + if test -n "$tools_msg" + set changed 1 + test $verbose -eq 1; and echo "$c_ok$tools_msg$c_reset" + end + + set -l hp (git -C "$vault" config --local core.hooksPath 2>/dev/null) + if test "$hp" != .agents-tools/hooks + git -C "$vault" config --local core.hooksPath .agents-tools/hooks + set changed 1 + 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" + set -l slug (_agents_repo_slug "$root") + set -l entry "$vault/projects/$slug" + set -l vmem "$entry/claude/memory" + + if not test -d "$vmem" + mkdir -p "$vmem"; or return 1 + set changed 1 + end + + 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 '.' '-') + set -l live "$claude_root/$mangled/memory" + + if test -d "$claude_root/$mangled"; or test -d "$live" + set -l link_msg (_agents_repo_ensure_symlink "$live" "$vmem") + if test -n "$link_msg" + set changed 1 + test $verbose -eq 1; and echo "$c_ok$link_msg$c_reset" + end + end + + if not test -f "$entry/origin" + set -l url (git -C "$root" remote get-url origin 2>/dev/null) + test -n "$url"; or set url "(none)" + printf 'remote: %s\npath: %s\nhost: %s\n' \ + "$url" "$root" (hostname 2>/dev/null) >"$entry/origin" + set changed 1 + end + end + + # ───────────────────────────── commit ────────────────────────────── + if not set -q _flag_link + set -l msg "chore: sync agent memory vault" + test $did_init -eq 1; and set msg "chore: initialize agent memory vault" + 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 + else if test -n "$sync_out" + set changed 1 + test $verbose -eq 1; and echo "$c_ok$sync_out$c_reset" + 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" + else + echo "$c_ok→ Synced agent memory vault$c_reset" + end + end +end diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index d1827dc..15f5753 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -202,6 +202,47 @@ set -l hrc (_agents_repo_sync $h "chore: blocked" 2>/dev/null; echo $status) check "commit hook rejection returns 1" 1 "$hrc" check "commit hook rejection commits nothing" $before_hook_count (git -C $h rev-list --count HEAD) +# ──────────────────────── vault scaffold + link ──────────────────────── +echo "" +echo "== agents-vault (scaffold + link) ==" + +set -l vroot (mktemp -d); set -ga TMPDIRS $vroot +set -l croot (mktemp -d); set -ga TMPDIRS $croot +set -g __fish_agent_vault_dir $vroot/agent-vault +set -g __fish_agent_vault_claude_root $croot + +set -l proj (new_repo https://git.rootiest.dev/rootiest/fish-config.git) +set -l pslug git.rootiest.dev-rootiest-fish-config + +# Seed a live memory directory the way Claude would. +set -l mangled (string replace -a '/' '-' -- $proj | string replace -a '.' '-') +mkdir -p $croot/$mangled/memory +echo "a memory" >$croot/$mangled/memory/thing.md + +pushd $proj >/dev/null +agents-vault --silent +popd >/dev/null + +check "vault repo created" true (test -d $vroot/agent-vault/.git; and echo true; or echo false) +check "vault version seeded" 1.0.0 (cat $vroot/agent-vault/.version 2>/dev/null) +check "entry created for slug" true (test -d $vroot/agent-vault/projects/$pslug/claude/memory; and echo true; or echo false) +check "live memory is now a link" true (test -L $croot/$mangled/memory; and echo true; or echo false) +check "memory content preserved" "a memory" (cat $croot/$mangled/memory/thing.md) +check "content lives in the vault" "a memory" (cat $vroot/agent-vault/projects/$pslug/claude/memory/thing.md) +check "origin file written" true (test -f $vroot/agent-vault/projects/$pslug/origin; and echo true; or echo false) +check "vault committed" true (test (git -C $vroot/agent-vault rev-list --count HEAD) -ge 1; and echo true; or echo false) + +# Idempotence: a second run prints nothing and adds no commit. +set -l before (git -C $vroot/agent-vault rev-list --count HEAD) +pushd $proj >/dev/null +set -l again (agents-vault --verbose) +popd >/dev/null +check "second run is silent" "" "$again" +check "second run adds no commit" $before (git -C $vroot/agent-vault rev-list --count HEAD) + +set -e __fish_agent_vault_dir +set -e __fish_agent_vault_claude_root + cleanup echo "" echo (math $TESTS_RUN - $TESTS_FAILED)"/$TESTS_RUN passed" -- 2.54.0 From 19126316a7f2609f5b733048b5bdc491e061476f Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 2 Sep 2026 21:55:24 -0400 Subject: [PATCH 07/19] fix(agents-vault): always link the current project's memory The guard around the symlink step only linked when the live Claude project directory already existed, which is exactly backwards for the clone-onto-a-new-machine restore case: a freshly cloned vault entry would be silently left unlinked and a starting agent would write fresh, history-less memory instead. _agents_repo_ensure_symlink already makes its own parent directories and is idempotent, so nothing depended on the guard; it is removed and the link is now attempted unconditionally. Also stop swallowing a refused or failed link as success: the helper's exit status is now checked, and agents-vault reports its own error and exits 1 instead of silently continuing with no link in place. Smaller fixes from the same review pass: - check the exit status of _agents_repo_install_tools and the core.hooksPath git config write, instead of discarding both - give the vmem mkdir failure a stderr message like every other fatal in the function - guard hostname with type -q and add it to DEPENDENCIES - .version creation now sets changed, so --link (which skips the commit step) reports it in --quiet mode - reword --link's help/doc text: it still scaffolds the vault and links memory, it only skips the final commit - drop the unused c_dim color variable - move the __fish_agent_vault_dir / __fish_agent_vault_autopush documentation below Opinionated Components so its NOTE: callout (now flush-left so it actually renders as a Starlight Aside, per review) doesn't become the first Note aside in the page and steal the existing test's assertions about the original 4-bullet one Adds two tests: pre-seeded vault entry with no live directory at all (the restore path the guard was breaking), and a forced link failure asserting agents-vault now exits 1 instead of 0. --- docs/manual/07-customization.md | 38 +++++++++---------- functions/agents-vault.fish | 55 ++++++++++++++++++++------- tests/test-agents-vault.fish | 67 +++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 32 deletions(-) diff --git a/docs/manual/07-customization.md b/docs/manual/07-customization.md index f787f27..e5f1a1a 100644 --- a/docs/manual/07-customization.md +++ b/docs/manual/07-customization.md @@ -54,25 +54,6 @@ Example: to increase the scrollback history limit: set -gx SCROLLBACK_HISTORY_MAX_FILES 200 -## Agent Memory Vault - - __fish_agent_vault_dir - - Overrides the agent memory vault location. Defaults to - $XDG_DATA_HOME/agent-vault (or ~/.local/share/agent-vault). - - __fish_agent_vault_autopush - - When set to 1, agents-vault also pushes on wrapper launch. Defaults to - off: the vault commits locally on every launch and pushes from the - Claude Code SessionEnd hook or an explicit agents-vault --push. - - NOTE: - With autopush off and no SessionEnd hook installed, backups accumulate - locally and never reach the remote. Run agents-vault --status to check - how far ahead the vault is. - - ## Fish Universal Variables Some settings (fzf colors, theme) are stored in fish_variables via @@ -178,6 +159,25 @@ interactively. See [Components Reference](/08-components-reference/) for the full sub-category breakdown of every category. +## Agent Memory Vault + + __fish_agent_vault_dir + + Overrides the agent memory vault location. Defaults to + $XDG_DATA_HOME/agent-vault (or ~/.local/share/agent-vault). + + __fish_agent_vault_autopush + + When set to 1, agents-vault also pushes on wrapper launch. Defaults to + off: the vault commits locally on every launch and pushes from the + Claude Code SessionEnd hook or an explicit agents-vault --push. + +NOTE: +With autopush off and no SessionEnd hook installed, backups accumulate +locally and never reach the remote. Run agents-vault --status to check +how far ahead the vault is. + + ## Prompt and Theme ### Starship diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish index 99f6638..1971682 100644 --- a/functions/agents-vault.fish +++ b/functions/agents-vault.fish @@ -6,7 +6,7 @@ # # DEPENDENCIES # _agents_vault_dir, _agents_repo_slug, _agents_repo_ensure_symlink, -# _agents_repo_sync, _agents_repo_install_tools, git +# _agents_repo_sync, _agents_repo_install_tools, git, hostname # # SYNOPSIS # agents-vault [--link] [--push] [--restore] [--status] @@ -36,7 +36,8 @@ # never denylisted, so nothing new upstream adds can leak in. # # ARGUMENTS -# --link Ensure this project's memory link only; do not commit +# --link Scaffold the vault and link this project's memory; skip +# the final commit # --push Commit and 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 @@ -68,7 +69,6 @@ function agents-vault --description 'track curated agent memory in a host-scoped set -l c_flag (set_color yellow) set -l c_ok (set_color green) set -l c_warn (set_color yellow) - set -l c_dim (set_color brblack) set -l c_err (set_color red) set -l c_reset (set_color normal) @@ -83,7 +83,7 @@ function agents-vault --description 'track curated agent memory in a host-scoped echo 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 Ensure this project's memory link only" + 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--restore$c_reset Relink everything possible, report the rest" echo " $c_flag--status$c_reset Show entries, link health, remote, orphans" @@ -133,7 +133,10 @@ function agents-vault --description 'track curated agent memory in a host-scoped test $verbose -eq 1; and echo "$c_ok→ Initialized vault repo at $vault$c_reset" end - test -f "$vault/.version"; or echo 1.0.0 >"$vault/.version" + if not test -f "$vault/.version" + echo 1.0.0 >"$vault/.version" + set changed 1 + end if not test -f "$vault/.gitignore" printf '%s\n' \ @@ -165,6 +168,10 @@ function agents-vault --description 'track curated agent memory in a host-scoped end set -l tools_msg (_agents_repo_install_tools "$vault") + or begin + echo "$c_err""agents-vault: could not install .agents-tools/ into $vault$c_reset" >&2 + return 1 + end if test -n "$tools_msg" set changed 1 test $verbose -eq 1; and echo "$c_ok$tools_msg$c_reset" @@ -173,6 +180,10 @@ function agents-vault --description 'track curated agent memory in a host-scoped set -l hp (git -C "$vault" config --local core.hooksPath 2>/dev/null) if test "$hp" != .agents-tools/hooks git -C "$vault" config --local core.hooksPath .agents-tools/hooks + or begin + echo "$c_err""agents-vault: could not set core.hooksPath in $vault$c_reset" >&2 + return 1 + end set changed 1 end @@ -192,7 +203,10 @@ function agents-vault --description 'track curated agent memory in a host-scoped set -l vmem "$entry/claude/memory" if not test -d "$vmem" - mkdir -p "$vmem"; or return 1 + if not mkdir -p "$vmem" + echo "$c_err""agents-vault: could not create $vmem$c_reset" >&2 + return 1 + end set changed 1 end @@ -201,19 +215,34 @@ function agents-vault --description 'track curated agent memory in a host-scoped set -l mangled (string replace -a '/' '-' -- "$root" | string replace -a '.' '-') set -l live "$claude_root/$mangled/memory" - if test -d "$claude_root/$mangled"; or test -d "$live" - set -l link_msg (_agents_repo_ensure_symlink "$live" "$vmem") - if test -n "$link_msg" - set changed 1 - test $verbose -eq 1; and echo "$c_ok$link_msg$c_reset" - end + # Unconditional: this is the emergent-restore path. On a freshly + # cloned vault, $vmem already exists (populated from the clone) and + # $live does not exist yet -- skipping the link here would silently + # leave the clone's memory unlinked and let a starting agent write + # fresh, history-less memory instead. _agents_repo_ensure_symlink is + # idempotent and makes its own parent directories, so there is + # nothing this guard would protect that the helper does not already + # handle on its own. + set -l link_msg (_agents_repo_ensure_symlink "$live" "$vmem") + set -l link_rc $status + if test $link_rc -ne 0 + echo "$c_err""agents-vault: could not link $live$c_reset" >&2 + return 1 + end + if test -n "$link_msg" + set changed 1 + test $verbose -eq 1; and echo "$c_ok$link_msg$c_reset" end if not test -f "$entry/origin" set -l url (git -C "$root" remote get-url origin 2>/dev/null) test -n "$url"; or set url "(none)" + set -l host "" + if type -q hostname + set host (hostname 2>/dev/null) + end printf 'remote: %s\npath: %s\nhost: %s\n' \ - "$url" "$root" (hostname 2>/dev/null) >"$entry/origin" + "$url" "$root" "$host" >"$entry/origin" set changed 1 end end diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index 15f5753..289c587 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -243,6 +243,73 @@ check "second run adds no commit" $before (git -C $vroot/agent-vault rev-list -- set -e __fish_agent_vault_dir set -e __fish_agent_vault_claude_root +# ─────────────────── emergent restore (vault -> live) ────────────────── +# The reverse of the adoption case above: a vault entry already has curated +# memory (as if cloned from a remote onto a fresh machine) but the live +# Claude project directory does not exist at all yet. agents-vault must +# still create the link so the pre-existing memory is what a starting +# agent sees, rather than silently skipping the link because there was +# nothing local to notice yet. +echo "" +echo "== agents-vault (emergent restore) ==" + +set -l vroot2 (mktemp -d); set -ga TMPDIRS $vroot2 +set -l croot2 (mktemp -d); set -ga TMPDIRS $croot2 +set -g __fish_agent_vault_dir $vroot2/agent-vault +set -g __fish_agent_vault_claude_root $croot2 + +set -l proj2 (new_repo https://git.rootiest.dev/rootiest/agent-vault.git) +set -l pslug2 git.rootiest.dev-rootiest-agent-vault +set -l mangled2 (string replace -a '/' '-' -- $proj2 | string replace -a '.' '-') + +# Pre-seed the vault entry the way a cloned vault would already have it. +# Deliberately no $croot2/$mangled2 directory at all -- not even the +# project's own entry, let alone a memory/ subdirectory -- so the fix under +# test is exercised: linking must not depend on the live side existing. +mkdir -p $vroot2/agent-vault/projects/$pslug2/claude/memory +echo "restored memory" >$vroot2/agent-vault/projects/$pslug2/claude/memory/old.md + +pushd $proj2 >/dev/null +agents-vault --silent +popd >/dev/null + +check "restore: live memory link created with no prior live dir" true (test -L $croot2/$mangled2/memory; and echo true; or echo false) +check "restore: pre-existing vault content readable through the link" "restored memory" (cat $croot2/$mangled2/memory/old.md 2>/dev/null) + +set -e __fish_agent_vault_dir +set -e __fish_agent_vault_claude_root + +# ─────────────────── a refused link is reported as failure ───────────── +# _agents_repo_ensure_symlink refuses (exit 1, no stdout) when it cannot +# make the link -- e.g. its mkdir -p of the link's parent directory fails. +# agents-vault must surface that as its own exit 1, not swallow it and +# report success because stdout happened to be empty either way. +echo "" +echo "== agents-vault (link failure surfaces as exit 1) ==" + +set -l vroot3 (mktemp -d); set -ga TMPDIRS $vroot3 +set -l croot3 (mktemp -d); set -ga TMPDIRS $croot3 +set -g __fish_agent_vault_dir $vroot3/agent-vault +set -g __fish_agent_vault_claude_root $croot3 + +set -l proj3 (new_repo https://git.rootiest.dev/rootiest/link-fail-test.git) +set -l mangled3 (string replace -a '/' '-' -- $proj3 | string replace -a '.' '-') + +# Make the mangled project path a plain FILE. _agents_repo_ensure_symlink's +# own `mkdir -p (path dirname $link)` then fails outright (ENOTDIR), which +# is exactly the class of failure (permissions, ENOSPC, ...) this guards. +touch $croot3/$mangled3 + +pushd $proj3 >/dev/null +set -l rc (agents-vault --silent 2>/dev/null; echo $status) +popd >/dev/null + +check "link failure: agents-vault exits 1" 1 "$rc" +check "link failure: no link was left behind" false (test -L $croot3/$mangled3/memory; and echo true; or echo false) + +set -e __fish_agent_vault_dir +set -e __fish_agent_vault_claude_root + cleanup echo "" echo (math $TESTS_RUN - $TESTS_FAILED)"/$TESTS_RUN passed" -- 2.54.0 From 2d8db42b12c0084366eb5951a901d3feaec757da Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 2 Sep 2026 21:58:03 -0400 Subject: [PATCH 08/19] docs(fish-config.index): index the agent memory vault variables config-help resolves keywords through this hand-maintained index; the new "Agent Memory Vault" section in 07-customization.md had no entries here yet. Adds agent-vault, __fish_agent_vault_dir, and __fish_agent_vault_autopush, following the __fish_scrollback_history_dir precedent (bare variable names as keys). --- docs/fish-config.index | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/fish-config.index b/docs/fish-config.index index 5f9adb8..678a663 100644 --- a/docs/fish-config.index +++ b/docs/fish-config.index @@ -276,6 +276,9 @@ minimal=## Opinionated Components (Minimal Mode) minimal-mode=## Opinionated Components (Minimal Mode) opt-out=## Opinionated Components (Minimal Mode) toggles=## Opinionated Components (Minimal Mode) +agent-vault=## Agent Memory Vault +__fish_agent_vault_dir=## Agent Memory Vault +__fish_agent_vault_autopush=## Agent Memory Vault component-reference=# 8. COMPONENTS REFERENCE components=# 8. COMPONENTS REFERENCE c0=## Per-function overrides: `C0`/`always` -- 2.54.0 From b7ff4e0981a4fce710fee621c200446f3f88c9fc Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 2 Sep 2026 22:05:48 -0400 Subject: [PATCH 09/19] feat(agents-vault): migrate entries when a project's slug changes Adding a remote to a previously remote-less project changes its slug. Left unhandled, the link step repinned the live memory symlink to a fresh empty entry and orphaned the real memory. The previous slug is read from the live symlink target rather than guessed, which covers a remote being added, rewritten, or removed. When both the old and new entries hold content the migration is ambiguous, so nothing moves and the user is directed to --adopt. --- functions/agents-vault.fish | 63 +++++++++++++++++++++++++++++++++--- tests/test-agents-vault.fish | 58 +++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish index 1971682..351e49a 100644 --- a/functions/agents-vault.fish +++ b/functions/agents-vault.fish @@ -35,6 +35,14 @@ # of megabytes per project, growing per session). Paths are allowlisted, # never denylisted, so nothing new upstream adds can leak in. # +# Because the slug is derived from the remote, gaining, losing, or +# rewriting a project's origin changes it. Each run detects this by +# reading the previous slug straight off the live memory symlink's +# 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. +# # ARGUMENTS # --link Scaffold the vault and link this project's memory; skip # the final commit @@ -202,6 +210,56 @@ function agents-vault --description 'track curated agent memory in a host-scoped set -l entry "$vault/projects/$slug" set -l vmem "$entry/claude/memory" + 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 '.' '-') + set -l live "$claude_root/$mangled/memory" + + # ── Slug migration (spec 4.5) ───────────────────────────────────── + # The previous slug never has to be guessed: the live symlink still + # points at the old entry. This covers a remote being added, a + # remote URL being rewritten, and a remote being removed. + set -l prev_slug "" + if test -L "$live" + set -l tgt (path resolve "$live") + set -l pdir (path resolve "$vault/projects") + if string match -q "$pdir/*" -- "$tgt" + set -l rest (string replace "$pdir/" "" -- "$tgt") + set prev_slug (string split -f1 '/' -- $rest) + end + end + if test -z "$prev_slug" + # No link yet (fresh machine): try the path-derived candidate. + set -l cand_rp (path resolve "$root") + set -l cand_base (string lower -- (path basename "$cand_rp")) + set -l cand_digest (printf '%s' "$cand_rp" | sha256sum | string split -f1 ' ') + set -l cand "local-$cand_base-"(string sub -l 8 -- "$cand_digest") + if test "$cand" != "$slug"; and test -d "$vault/projects/$cand" + set prev_slug $cand + end + end + + if test -n "$prev_slug"; and test "$prev_slug" != "$slug" + set -l prev_mem "$vault/projects/$prev_slug/claude/memory" + set -l cur_content + test -d "$vmem"; and set cur_content (command ls -A "$vmem" 2>/dev/null) + if test (count $cur_content) -gt 0 + echo "$c_err""agents-vault: cannot migrate $prev_slug → $slug; both entries hold content.$c_reset" >&2 + echo "$c_err"" Resolve with: agents-vault --adopt=SLUG$c_reset" >&2 + return 1 + end + test -d "$vmem"; and rm -rf "$vault/projects/$slug" + mkdir -p (path dirname "$vault/projects/$slug") + 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 + end + printf 'renamed: %s → %s (%s)\n' "$prev_slug" "$slug" (date -I) \ + >>"$vault/projects/$slug/origin" + rm -f "$live" + set changed 1 + test $verbose -eq 1; and echo "$c_ok→ Migrated vault entry $prev_slug → $slug$c_reset" + end + if not test -d "$vmem" if not mkdir -p "$vmem" echo "$c_err""agents-vault: could not create $vmem$c_reset" >&2 @@ -210,11 +268,6 @@ function agents-vault --description 'track curated agent memory in a host-scoped set changed 1 end - 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 '.' '-') - set -l live "$claude_root/$mangled/memory" - # Unconditional: this is the emergent-restore path. On a freshly # cloned vault, $vmem already exists (populated from the clone) and # $live does not exist yet -- skipping the link here would silently diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index 289c587..a443329 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -310,6 +310,64 @@ check "link failure: no link was left behind" false (test -L $croot3/$mangled3/m set -e __fish_agent_vault_dir set -e __fish_agent_vault_claude_root +# ────────────────────────── slug migration ───────────────────────────── +echo "" +echo "== agents-vault (slug migration) ==" + +set -l vroot2 (mktemp -d); set -ga TMPDIRS $vroot2 +set -l croot2 (mktemp -d); set -ga TMPDIRS $croot2 +set -g __fish_agent_vault_dir $vroot2/agent-vault +set -g __fish_agent_vault_claude_root $croot2 + +# Start with no remote, so the project is keyed local-*. +set -l mp (new_repo) +set -l mmangled (string replace -a '/' '-' -- $mp | string replace -a '.' '-') +mkdir -p $croot2/$mmangled/memory +echo "precious" >$croot2/$mmangled/memory/keep.md + +pushd $mp >/dev/null +agents-vault --silent +set -l local_slug (_agents_repo_slug $mp) +popd >/dev/null +check "local entry populated" precious (cat $vroot2/agent-vault/projects/$local_slug/claude/memory/keep.md) + +# Now add a remote: the slug changes and the entry must migrate. +git -C $mp remote add origin https://git.rootiest.dev/rootiest/later.git +pushd $mp >/dev/null +agents-vault --silent +popd >/dev/null +set -l new_slug git.rootiest.dev-rootiest-later + +check "migrated to remote slug" precious (cat $vroot2/agent-vault/projects/$new_slug/claude/memory/keep.md) +check "old entry removed" false (test -d $vroot2/agent-vault/projects/$local_slug; and echo true; or echo false) +check "memory still reachable live" precious (cat $croot2/$mmangled/memory/keep.md) +check "link repinned to new entry" (path resolve $vroot2/agent-vault/projects/$new_slug/claude/memory) (path resolve $croot2/$mmangled/memory) +check "rename recorded in origin" true (grep -q "$local_slug" $vroot2/agent-vault/projects/$new_slug/origin; and echo true; or echo false) + +# Ambiguous migration: both entries hold content. Nothing may move. +set -l amb (new_repo) +set -l amangled (string replace -a '/' '-' -- $amb | string replace -a '.' '-') +mkdir -p $croot2/$amangled/memory +echo old >$croot2/$amangled/memory/x.md +pushd $amb >/dev/null +agents-vault --silent +set -l aslug (_agents_repo_slug $amb) +popd >/dev/null + +git -C $amb remote add origin https://git.rootiest.dev/rootiest/clash.git +mkdir -p $vroot2/agent-vault/projects/git.rootiest.dev-rootiest-clash/claude/memory +echo new >$vroot2/agent-vault/projects/git.rootiest.dev-rootiest-clash/claude/memory/y.md + +pushd $amb >/dev/null +set -l arc (agents-vault --silent 2>/dev/null; echo $status) +popd >/dev/null +check "ambiguous migration fails" 1 "$arc" +check "ambiguous leaves old entry" old (cat $vroot2/agent-vault/projects/$aslug/claude/memory/x.md) +check "ambiguous leaves new entry" new (cat $vroot2/agent-vault/projects/git.rootiest.dev-rootiest-clash/claude/memory/y.md) + +set -e __fish_agent_vault_dir +set -e __fish_agent_vault_claude_root + cleanup echo "" echo (math $TESTS_RUN - $TESTS_FAILED)"/$TESTS_RUN passed" -- 2.54.0 From a018d7997cd67d41f8de8b8044d078de97b7615d Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 2 Sep 2026 22:14:31 -0400 Subject: [PATCH 10/19] fix(agents-vault): dedupe local-slug formula, drop dead code, widen migration coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slug-migration fallback (used when there is no live symlink to read the previous slug from) recomputed the local-* candidate by lowercasing the basename only, while _agents_repo_slug sanitizes it. The two formulas had drifted, so the fallback silently found nothing for any project directory whose basename needed sanitizing. Extract the formula into a single private helper, _agents_repo_local_slug, and have both _agents_repo_slug's no-remote branch and agents-vault's migration fallback call it, so there is one place left to drift. Also drop two dead lines the review flagged: an unused local, and an unreachable mkdir -p (path dirname ...) — slugs never contain a path separator, so dirname always resolves to a directory that already exists by that point. Widen migration test coverage: the current-entry-present-but-empty case, a remote URL rewrite, a remote removal, and a dirty-basename fallback test that fails without the sanitization fix and passes with it. --- functions/_agents_repo_local_slug.fish | 40 ++++++++++ functions/_agents_repo_slug.fish | 8 +- functions/agents-vault.fish | 12 +-- tests/test-agents-vault.fish | 102 +++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 12 deletions(-) create mode 100644 functions/_agents_repo_local_slug.fish diff --git a/functions/_agents_repo_local_slug.fish b/functions/_agents_repo_local_slug.fish new file mode 100644 index 0000000..71f5a21 --- /dev/null +++ b/functions/_agents_repo_local_slug.fish @@ -0,0 +1,40 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# SYNOPSIS +# _agents_repo_local_slug +# +# DESCRIPTION +# Builds the path-derived fallback slug used when a project has no git +# remote: local--<8 hex of sha256(realpath)>. The +# basename is lowercased and every character outside [a-z0-9._-] is +# mapped to a dash, matching the sanitization the remote-URL branch of +# _agents_repo_slug applies to hostnames and paths. +# +# This is the single source of truth for that formula. It exists so the +# rule is written once: _agents_repo_slug's no-remote branch calls it to +# produce the slug, and agents-vault's slug-migration fallback (used when +# there is no live symlink yet to read the previous slug from) calls it +# to recompute the same candidate. Duplicating the formula in both places +# let them drift once before; this closes that gap for good. +# +# ARGUMENTS +# dir Absolute or relative path to the project directory +# +# EXIT STATUS +# 0 Slug printed +# 1 No directory argument given +# +# RETURNS +# The local-* slug, one line on stdout. +# +# EXAMPLE +# set -l slug (_agents_repo_local_slug /home/user/myproject) +function _agents_repo_local_slug --argument-names dir + test -n "$dir"; or return 1 + + set -l rp (path resolve "$dir") + set -l base (string lower -- (path basename "$rp") | string replace -ra '[^a-z0-9._-]' '-') + set -l digest (printf '%s' "$rp" | sha256sum | string split -f1 ' ') + printf 'local-%s-%s\n' "$base" (string sub -l 8 -- "$digest") +end diff --git a/functions/_agents_repo_slug.fish b/functions/_agents_repo_slug.fish index 57a4724..d5672f8 100644 --- a/functions/_agents_repo_slug.fish +++ b/functions/_agents_repo_slug.fish @@ -1,6 +1,9 @@ # Copyright (C) 2026 Rootiest # SPDX-License-Identifier: AGPL-3.0-or-later +# DEPENDENCIES +# _agents_repo_local_slug +# # SYNOPSIS # _agents_repo_slug # @@ -63,8 +66,5 @@ function _agents_repo_slug --argument-names dir return 0 end - set -l rp (path resolve "$dir") - set -l base (string lower -- (path basename "$rp") | string replace -ra '[^a-z0-9._-]' '-') - set -l digest (printf '%s' "$rp" | sha256sum | string split -f1 ' ') - printf 'local-%s-%s\n' "$base" (string sub -l 8 -- "$digest") + _agents_repo_local_slug "$dir" end diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish index 351e49a..dd3253d 100644 --- a/functions/agents-vault.fish +++ b/functions/agents-vault.fish @@ -5,8 +5,9 @@ # 12-ai-and-developer-tools # # DEPENDENCIES -# _agents_vault_dir, _agents_repo_slug, _agents_repo_ensure_symlink, -# _agents_repo_sync, _agents_repo_install_tools, git, hostname +# _agents_vault_dir, _agents_repo_slug, _agents_repo_local_slug, +# _agents_repo_ensure_symlink, _agents_repo_sync, +# _agents_repo_install_tools, git, hostname # # SYNOPSIS # agents-vault [--link] [--push] [--restore] [--status] @@ -230,17 +231,13 @@ function agents-vault --description 'track curated agent memory in a host-scoped end if test -z "$prev_slug" # No link yet (fresh machine): try the path-derived candidate. - set -l cand_rp (path resolve "$root") - set -l cand_base (string lower -- (path basename "$cand_rp")) - set -l cand_digest (printf '%s' "$cand_rp" | sha256sum | string split -f1 ' ') - set -l cand "local-$cand_base-"(string sub -l 8 -- "$cand_digest") + set -l cand (_agents_repo_local_slug "$root") if test "$cand" != "$slug"; and test -d "$vault/projects/$cand" set prev_slug $cand end end if test -n "$prev_slug"; and test "$prev_slug" != "$slug" - set -l prev_mem "$vault/projects/$prev_slug/claude/memory" set -l cur_content test -d "$vmem"; and set cur_content (command ls -A "$vmem" 2>/dev/null) if test (count $cur_content) -gt 0 @@ -249,7 +246,6 @@ function agents-vault --description 'track curated agent memory in a host-scoped return 1 end test -d "$vmem"; and rm -rf "$vault/projects/$slug" - mkdir -p (path dirname "$vault/projects/$slug") 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 end diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index a443329..5de4b94 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -365,6 +365,108 @@ check "ambiguous migration fails" 1 "$arc" check "ambiguous leaves old entry" old (cat $vroot2/agent-vault/projects/$aslug/claude/memory/x.md) check "ambiguous leaves new entry" new (cat $vroot2/agent-vault/projects/git.rootiest.dev-rootiest-clash/claude/memory/y.md) +# Case 2 of the three required cases: the *current* (new-slug) entry is +# present but empty -- neither absent (handled above) nor holding content +# (the ambiguous case above). Migration must still proceed. +set -l emp (new_repo) +set -l emangled (string replace -a '/' '-' -- $emp | string replace -a '.' '-') +mkdir -p $croot2/$emangled/memory +echo precious2 >$croot2/$emangled/memory/keep.md +pushd $emp >/dev/null +agents-vault --silent +set -l eslug (_agents_repo_slug $emp) +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 + +pushd $emp >/dev/null +set -l erc (agents-vault --silent 2>/dev/null; echo $status) +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) + +# Remote-URL-rewrite transition: origin changes from one forge URL to +# another (distinct from adding a remote where none existed). +set -l rw (new_repo https://git.rootiest.dev/rootiest/rewrite-old.git) +set -l rwmangled (string replace -a '/' '-' -- $rw | string replace -a '.' '-') +mkdir -p $croot2/$rwmangled/memory +echo rewrite-precious >$croot2/$rwmangled/memory/keep.md +pushd $rw >/dev/null +agents-vault --silent +popd >/dev/null +set -l rw_old_slug git.rootiest.dev-rootiest-rewrite-old +check "rewrite: old entry populated" rewrite-precious (cat $vroot2/agent-vault/projects/$rw_old_slug/claude/memory/keep.md) + +git -C $rw remote set-url origin https://git.rootiest.dev/rootiest/rewrite-new.git +pushd $rw >/dev/null +agents-vault --silent +popd >/dev/null +set -l rw_new_slug git.rootiest.dev-rootiest-rewrite-new +check "rewrite: migrated to new remote slug" rewrite-precious (cat $vroot2/agent-vault/projects/$rw_new_slug/claude/memory/keep.md) +check "rewrite: old entry removed" false (test -d $vroot2/agent-vault/projects/$rw_old_slug; and echo true; or echo false) + +# Remote-removal transition: origin removed, slug reverts to local-*. +set -l rmv (new_repo https://git.rootiest.dev/rootiest/removeme.git) +set -l rmvmangled (string replace -a '/' '-' -- $rmv | string replace -a '.' '-') +mkdir -p $croot2/$rmvmangled/memory +echo removal-precious >$croot2/$rmvmangled/memory/keep.md +pushd $rmv >/dev/null +agents-vault --silent +popd >/dev/null +set -l rmv_remote_slug git.rootiest.dev-rootiest-removeme +check "removal: remote entry populated" removal-precious (cat $vroot2/agent-vault/projects/$rmv_remote_slug/claude/memory/keep.md) + +git -C $rmv remote remove origin +pushd $rmv >/dev/null +agents-vault --silent +set -l rmv_local_slug (_agents_repo_slug $rmv) +popd >/dev/null +check "removal: migrated to local slug" removal-precious (cat $vroot2/agent-vault/projects/$rmv_local_slug/claude/memory/keep.md) +check "removal: old remote entry removed" false (test -d $vroot2/agent-vault/projects/$rmv_remote_slug; and echo true; or echo false) + +# Fallback-candidate sanitization: a dirty basename (space, !) must produce +# the same local-* slug that _agents_repo_slug would derive, so that a +# lost-symlink recovery (no live link, but the vault entry survives) can +# still find and adopt it. Before the fix, the fallback only lowercased +# the basename instead of sanitizing it like _agents_repo_slug does, so it +# could never match the real entry directory for a name like this. +set -l dirty_root (mktemp -d); set -ga TMPDIRS $dirty_root +set -l dp "$dirty_root/My Project!" +mkdir -p "$dp" +git -C "$dp" init -q +git -C "$dp" config user.email t@t +git -C "$dp" config user.name t +git -C "$dp" config commit.gpgsign false +git -C "$dp" config core.hooksPath /dev/null + +set -l dmangled (string replace -a '/' '-' -- $dp | string replace -a '.' '-') +mkdir -p $croot2/$dmangled/memory +echo "dirty-precious" >$croot2/$dmangled/memory/keep.md + +pushd $dp >/dev/null +agents-vault --silent +set -l dirty_local_slug (_agents_repo_slug $dp) +popd >/dev/null +check "dirty local entry populated" dirty-precious (cat $vroot2/agent-vault/projects/$dirty_local_slug/claude/memory/keep.md) + +# Lose the live symlink, as a fresh-machine restore would, so migration +# must fall back to recomputing the candidate from the path instead of +# reading it off the (now-absent) live symlink. +rm -f $croot2/$dmangled/memory + +git -C $dp remote add origin https://git.rootiest.dev/rootiest/dirty.git +pushd $dp >/dev/null +agents-vault --silent +popd >/dev/null +set -l dirty_new_slug git.rootiest.dev-rootiest-dirty +check "fallback finds sanitized local entry" dirty-precious (cat $vroot2/agent-vault/projects/$dirty_new_slug/claude/memory/keep.md) +check "fallback old entry removed" false (test -d $vroot2/agent-vault/projects/$dirty_local_slug; and echo true; or echo false) + set -e __fish_agent_vault_dir set -e __fish_agent_vault_claude_root -- 2.54.0 From 1c9cedb8f32c747eea8b2faeef34b36b8d72bdc5 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Thu, 3 Sep 2026 01:03:25 -0400 Subject: [PATCH 11/19] feat(agents-vault): back up agy and global Claude state agy partitions by conversation UUID rather than by workspace, so it has no per-project slice and is tracked globally. Its knowledge store is copied rather than symlinked because it sits beside SQLite databases with WAL sidecars. Claude's global memory directory is symlinked into the vault the same way per-project memory is, including the emergent-restore direction. Paths are allowlisted so credentials, transcripts, and session state cannot be swept in. Two variables keep the tests off the real home: the new __fish_agent_vault_claude_home overrides ~/.claude (whose memory/ subdirectory is the global one), distinct from the existing __fish_agent_vault_claude_root, which overrides ~/.claude/projects. Without it a test run on a machine that has a real global memory directory would move it into a mktemp vault and leave a dangling symlink behind. The suite now points every run at a throwaway home by default and asserts the real paths are untouched. cp cannot report whether anything actually differed, so the copy is only counted as a change when it leaves the vault's global/agy/ subtree dirty. Marking it changed unconditionally would print a --quiet summary line on every agent launch and make the flag meaningless. --- functions/agents-vault.fish | 109 +++++++++++++++++++++++ tests/test-agents-vault.fish | 164 +++++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+) diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish index dd3253d..6cdd3ea 100644 --- a/functions/agents-vault.fish +++ b/functions/agents-vault.fish @@ -36,6 +36,17 @@ # of megabytes per project, growing per session). Paths are allowlisted, # never denylisted, so nothing new upstream adds can leak in. # +# Global state that belongs to no project is tracked as well. Claude's +# global memory directory (~/.claude/memory) is symlinked into the vault +# exactly like per-project memory, and is only linked when one side or +# the other already holds something, since that path does not exist by +# default. agy's knowledge store and settings.json are copied rather +# than symlinked: agy partitions by conversation UUID rather than by +# workspace, so it has no per-project slice, and its store sits beside +# SQLite databases whose WAL sidecars must never be live-tracked inside +# a git worktree. A failed copy is reported but is not fatal, because an +# incomplete backup still leaves the agent working. +# # Because the slug is derived from the remote, gaining, losing, or # rewriting a project's origin changes it. Each run detects this by # reading the previous slug straight off the live memory symlink's @@ -72,6 +83,22 @@ # __fish_agent_vault_autopush to 1 to also push on wrapper launch; # it defaults to off so a backgrounded push can never hang or prompt # invisibly underneath a starting agent. +# +# Three further variables exist only so the test suite can run against +# throwaway directories instead of the real home, and are not meant for +# everyday use. __fish_agent_vault_claude_root overrides Claude's +# per-project directory (~/.claude/projects), which is where the +# per-project memory directories live. __fish_agent_vault_claude_home +# overrides Claude's home directory (~/.claude), whose memory +# subdirectory holds the global memory. Those two name different paths +# and setting one has no effect on the other. +# __fish_agent_vault_agy_root overrides agy's state directory +# (~/.gemini/antigravity-cli), which is only ever read from. +# +# The last two are not optional niceties. Without them, a test run on a +# machine that has a real global memory directory would move it into a +# throwaway directory and leave a dangling symlink behind, which is +# strictly worse than having had no backup at all. function agents-vault --description 'track curated agent memory in a host-scoped vault repo' set -l c_head (set_color --bold cyan) set -l c_cmd (set_color --bold) @@ -196,6 +223,88 @@ function agents-vault --description 'track curated agent memory in a host-scoped set changed 1 end + # ────────────────────────── global state ─────────────────────────── + # Allowlist, never a denylist. The agy root and ~/.claude also hold + # .credentials.json, history.jsonl, sessions/, session-env/, + # shell-snapshots/, and the conversation databases, so only the paths + # named here are ever copied or linked; a "back up all but known junk" + # rule would leak secrets the first time upstream adds a file. + set -l agy_root $__fish_agent_vault_agy_root + test -n "$agy_root"; or set agy_root "$HOME/.gemini/antigravity-cli" + + # agy state is copied, never symlinked: agy partitions by conversation + # UUID rather than by workspace, so there is no per-project slice to + # link, and its store sits beside SQLite databases whose WAL sidecars + # must never be live-tracked inside a git worktree. + # + # A failed copy is reported but not fatal. An incomplete backup still + # 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 + end + end + if test -f "$agy_root/settings.json" + if not mkdir -p "$vault/global/agy" + echo "$c_err""agents-vault: could not create $vault/global/agy$c_reset" >&2 + else if not command cp "$agy_root/settings.json" "$vault/global/agy/settings.json" + echo "$c_warn""agents-vault: could not copy the agy settings file$c_reset" >&2 + else + set agy_copied 1 + end + end + + # cp cannot report whether anything actually differed, so treating the + # copy itself as a change would set $changed on every single run -- + # and agents-vault runs on every claude/agy launch, so --quiet would + # print a summary line every time and stop meaning anything. Ask git + # instead: the copy counts only when it left global/agy/ dirty. + if test $agy_copied -eq 1 + set -l agy_dirty (git -C "$vault" status --porcelain -- global/agy 2>/dev/null) + if test -n "$agy_dirty" + set changed 1 + test $verbose -eq 1; and echo "$c_ok→ Copied agy global state into the vault$c_reset" + end + end + + # Claude's global memory directory is symlinked into the vault exactly + # like per-project memory, so backup and restore stay one operation. + set -l claude_home $__fish_agent_vault_claude_home + test -n "$claude_home"; or set claude_home "$HOME/.claude" + set -l glive "$claude_home/memory" + set -l gvault "$vault/global/claude/memory" + + set -l gvault_content + test -d "$gvault"; and set gvault_content (command ls -A "$gvault" 2>/dev/null) + + # Link when either side already has something: the live directory + # exists (back it up) or a cloned vault carries global memory (restore + # it). Never out of thin air -- ~/.claude/memory does not exist by + # default, and fabricating it would invent state Claude never asked + # for and permanently claim the path. + if test -d "$glive"; or test -L "$glive"; or test (count $gvault_content) -gt 0 + if not mkdir -p "$gvault" + echo "$c_err""agents-vault: could not create $gvault$c_reset" >&2 + return 1 + end + set -l gmsg (_agents_repo_ensure_symlink "$glive" "$gvault") + set -l grc $status + if test $grc -ne 0 + echo "$c_err""agents-vault: could not link $glive$c_reset" >&2 + return 1 + end + if test -n "$gmsg" + set changed 1 + test $verbose -eq 1; and echo "$c_ok$gmsg$c_reset" + end + end + # ─────────────────────── unimplemented modes ─────────────────────── for f in _flag_push _flag_restore _flag_status _flag_adopt _flag_remote if set -q $f diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index 5de4b94..1f5a400 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -47,6 +47,41 @@ function cleanup end end +# Describe a path well enough to prove it was not disturbed: a symlink is +# recorded by its literal target (path resolve would hide a link that was +# repointed into a since-deleted temp directory), everything else by kind. +function snapshot_path --argument-names p + if test -L "$p" + printf 'link:%s\n' (readlink "$p") + else if test -d "$p" + printf 'dir\n' + else if test -e "$p" + printf 'file\n' + else + printf 'absent\n' + end +end + +# ────────────────────────── hermeticity floor ────────────────────────── +# agents-vault reads and *writes* global agent state under ~/.claude and +# ~/.gemini when it is not told otherwise, so every run in this file is +# pointed at a throwaway home first. Without this, a test run would copy +# the real agy knowledge store into a temp vault and -- far worse -- move a +# real ~/.claude/memory into a temp directory that cleanup then deletes, +# leaving a dangling symlink behind. Individual sections override these +# with their own fixtures and must restore them here, not erase them. +set -g HERMETIC_HOME (mktemp -d) +set -ga TMPDIRS $HERMETIC_HOME +mkdir -p $HERMETIC_HOME/claude $HERMETIC_HOME/agy +set -g __fish_agent_vault_claude_home $HERMETIC_HOME/claude +set -g __fish_agent_vault_agy_root $HERMETIC_HOME/agy + +# Recorded before anything runs, asserted at the very end. +set -g REAL_CLAUDE_MEMORY "$HOME/.claude/memory" +set -g REAL_AGY_ROOT "$HOME/.gemini/antigravity-cli" +set -g REAL_CLAUDE_MEMORY_BEFORE (snapshot_path "$REAL_CLAUDE_MEMORY") +set -g REAL_AGY_ROOT_BEFORE (snapshot_path "$REAL_AGY_ROOT") + # ─────────────────────────── slug derivation ─────────────────────────── echo "== _agents_repo_slug ==" @@ -470,6 +505,135 @@ 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 +# ──────────────────────────── 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 +# SQLite databases with WAL sidecars) and Claude's *global* memory +# directory (symlinked, exactly like per-project memory). +echo "" +echo "== agents-vault (global state) ==" + +set -l vroot5 (mktemp -d); set -ga TMPDIRS $vroot5 +set -l croot5 (mktemp -d); set -ga TMPDIRS $croot5 +set -l chome5 (mktemp -d); set -ga TMPDIRS $chome5 +set -l agy5 (mktemp -d); set -ga TMPDIRS $agy5 +set -g __fish_agent_vault_dir $vroot5/agent-vault +set -g __fish_agent_vault_claude_root $croot5 +set -g __fish_agent_vault_claude_home $chome5 +set -g __fish_agent_vault_agy_root $agy5 + +mkdir -p $agy5/knowledge $agy5/conversations +echo learned >$agy5/knowledge/fact.md +echo '{"model":"x"}' >$agy5/settings.json +# Decoys that must never be copied: the allowlist names knowledge/ and +# settings.json and nothing else. +echo secret >$agy5/history.jsonl +: >$agy5/conversations/c.db-wal + +# 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* +# the real global memory would be moved into $chome5. +mkdir -p $chome5/memory +echo global-memory >$chome5/memory/g.md + +set -l gp (new_repo https://git.rootiest.dev/rootiest/globals.git) +pushd $gp >/dev/null +agents-vault --silent +popd >/dev/null + +check "agy knowledge copied" learned (cat $vroot5/agent-vault/global/agy/knowledge/fact.md) +check "agy settings copied" '{"model":"x"}' (cat $vroot5/agent-vault/global/agy/settings.json) +check "agy knowledge is a copy not a link" false (test -L $vroot5/agent-vault/global/agy/knowledge; and echo true; or echo false) +check "history.jsonl not copied" false (test -e $vroot5/agent-vault/global/agy/history.jsonl; and echo true; or echo false) +check "conversations not copied" false (test -e $vroot5/agent-vault/global/agy/conversations; and echo true; or echo false) + +check "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) +check "global memory readable through the link" global-memory (cat $chome5/memory/g.md) + +# --quiet must stay silent when nothing upstream changed. agents-vault runs +# on every claude/agy launch, so a copy step that reported "changed" on +# every run (cp cannot tell whether anything differed) would print a +# summary line at every launch and defeat the flag entirely. +pushd $gp >/dev/null +set -l q1 (agents-vault --quiet) +set -l q2 (agents-vault --quiet) +popd >/dev/null +check "first --quiet rerun prints nothing" "" "$q1" +check "second --quiet rerun prints nothing" "" "$q2" + +# ... but a genuine upstream change must still re-sync and still report. +echo "learned more" >$agy5/knowledge/fact.md +pushd $gp >/dev/null +set -l q3 (agents-vault --quiet) +popd >/dev/null +check "agy knowledge re-synced" "learned more" (cat $vroot5/agent-vault/global/agy/knowledge/fact.md) +check "changed agy content reports in --quiet" true (string match -q '*Synced*' -- "$q3"; and echo true; or echo false) + +set -e __fish_agent_vault_dir +set -e __fish_agent_vault_claude_root + +# ────────────────── emergent restore of global memory ────────────────── +# The global counterpart of the per-project restore case: a cloned vault +# already carries global/claude/memory but the live ~/.claude/memory does +# not exist yet. The link must still be created, or a starting agent writes +# fresh, history-less global memory beside the restored copy. +echo "" +echo "== agents-vault (global emergent restore) ==" + +set -l vroot6 (mktemp -d); set -ga TMPDIRS $vroot6 +set -l croot6 (mktemp -d); set -ga TMPDIRS $croot6 +set -l chome6 (mktemp -d); set -ga TMPDIRS $chome6 +set -l agy6 (mktemp -d); set -ga TMPDIRS $agy6 +set -g __fish_agent_vault_dir $vroot6/agent-vault +set -g __fish_agent_vault_claude_root $croot6 +set -g __fish_agent_vault_claude_home $chome6 +set -g __fish_agent_vault_agy_root $agy6 + +mkdir -p $vroot6/agent-vault/global/claude/memory +echo restored-global >$vroot6/agent-vault/global/claude/memory/old.md + +set -l gp6 (new_repo https://git.rootiest.dev/rootiest/globals-restore.git) +pushd $gp6 >/dev/null +agents-vault --silent +popd >/dev/null + +check "global restore: link created with no prior live dir" true (test -L $chome6/memory; and echo true; or echo false) +check "global restore: vault content readable through the link" restored-global (cat $chome6/memory/old.md 2>/dev/null) + +# A home with neither side populated must not have a memory/ invented for +# it: ~/.claude/memory does not exist by default. +set -l chome7 (mktemp -d); set -ga TMPDIRS $chome7 +set -l vroot7 (mktemp -d); set -ga TMPDIRS $vroot7 +set -g __fish_agent_vault_dir $vroot7/agent-vault +set -g __fish_agent_vault_claude_home $chome7 +set -l gp7 (new_repo https://git.rootiest.dev/rootiest/globals-absent.git) +pushd $gp7 >/dev/null +agents-vault --silent +popd >/dev/null +check "absent global memory is not fabricated" false (test -e $chome7/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 +# override would move ~/.claude/memory into a mktemp vault that cleanup +# then deletes, leaving the live path a dangling symlink. +echo "" +echo "== hermeticity ==" + +check "real ~/.claude/memory untouched" "$REAL_CLAUDE_MEMORY_BEFORE" (snapshot_path "$REAL_CLAUDE_MEMORY") +check "real agy root untouched" "$REAL_AGY_ROOT_BEFORE" (snapshot_path "$REAL_AGY_ROOT") + +set -e __fish_agent_vault_claude_home +set -e __fish_agent_vault_agy_root + cleanup echo "" echo (math $TESTS_RUN - $TESTS_FAILED)"/$TESTS_RUN passed" -- 2.54.0 From 4c7334c46edcd5bf966b5d888cbdd9e3e5cf3058 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Thu, 3 Sep 2026 01:16:03 -0400 Subject: [PATCH 12/19] fix(agents-vault): keep a global-state fault from aborting project backup The global block runs before the per-project link and the commit, but its mkdir and link failures returned 1 outright. Global memory is optional and frequently absent, so a stray file or a permission problem at ~/.claude/memory would abort the per-project memory backup and its commit for every project, on every agent launch -- a fault in the secondary feature killing the primary one. Both failures now warn to stderr and continue, matching the treatment the agy copy already had; the whole global block is best-effort by design. Continuing is safe because _agents_repo_ensure_symlink validates and refuses before mutating anything. $changed is set only when the link actually succeeded, and nothing is recorded that would make a later run believe the global memory is linked when it is not. The live-side test widens from -d to -e so a stray regular file where the global memory directory belongs is reported on every run instead of being silently skipped and mistaken for the absent-by-default case. Also documents that the agy knowledge copy is merge-only: a fact deleted upstream persists in the vault and a restore brings it back. Whether the vault should mirror deletions is a retention decision for the repo owner; the gap is worth stating either way. --- functions/agents-vault.fish | 48 +++++++++++++++++++++-------- tests/test-agents-vault.fish | 59 ++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 13 deletions(-) diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish index 6cdd3ea..baebb21 100644 --- a/functions/agents-vault.fish +++ b/functions/agents-vault.fish @@ -84,6 +84,12 @@ # it defaults to off so a backgrounded push can never hang or prompt # invisibly underneath a starting agent. # +# 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 +# fresh clone brings it back. Prune such an entry from the vault by hand +# if it must really be gone. +# # Three further variables exist only so the test suite can run against # throwaway directories instead of the real home, and are not meant for # everyday use. __fish_agent_vault_claude_root overrides Claude's @@ -288,20 +294,36 @@ function agents-vault --description 'track curated agent memory in a host-scoped # it). Never out of thin air -- ~/.claude/memory does not exist by # default, and fabricating it would invent state Claude never asked # for and permanently claim the path. - if test -d "$glive"; or test -L "$glive"; or test (count $gvault_content) -gt 0 + # -e rather than -d on the live side so a *broken* global path (a stray + # regular file where the directory belongs) is noticed and reported on + # every run, instead of being silently skipped and mistaken for the + # absent-by-default case. + if test -e "$glive"; or test -L "$glive"; or test (count $gvault_content) -gt 0 + # Best-effort, exactly like the agy copy above. Global memory is + # optional and frequently absent, and this block runs before the + # per-project link, so a fault here must warn and continue rather + # than return: aborting would let a stray file or a permission + # problem at ~/.claude/memory kill the per-project memory backup + # and its commit for every project, on every agent launch. The + # secondary feature must not take the primary one down with it. + # + # Continuing is safe: _agents_repo_ensure_symlink validates and + # refuses before it mutates anything, so its failure window is the + # same whether the caller returns or carries on. $changed stays + # untouched unless the link actually succeeded, and nothing is + # recorded to make a later run believe the global memory is linked + # when it is not -- the next run re-enters this block and retries. if not mkdir -p "$gvault" - echo "$c_err""agents-vault: could not create $gvault$c_reset" >&2 - return 1 - end - set -l gmsg (_agents_repo_ensure_symlink "$glive" "$gvault") - set -l grc $status - if test $grc -ne 0 - echo "$c_err""agents-vault: could not link $glive$c_reset" >&2 - return 1 - end - if test -n "$gmsg" - set changed 1 - test $verbose -eq 1; and echo "$c_ok$gmsg$c_reset" + echo "$c_warn""agents-vault: could not create $gvault; skipping global memory$c_reset" >&2 + else + set -l gmsg (_agents_repo_ensure_symlink "$glive" "$gvault") + set -l grc $status + if test $grc -ne 0 + echo "$c_warn""agents-vault: could not link $glive; global memory not backed up$c_reset" >&2 + else if test -n "$gmsg" + set changed 1 + test $verbose -eq 1; and echo "$c_ok$gmsg$c_reset" + end end end diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index 1f5a400..33fbdec 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -620,6 +620,65 @@ 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 global link must not abort the run ──────────── +# The global block runs before the per-project link and before the commit, +# and global memory is optional and frequently absent. A fault there must +# never take the primary per-project backup down with it: otherwise a +# stray file or a permission problem at ~/.claude/memory would break +# memory backup for every project, on every agent launch. +echo "" +echo "== agents-vault (global link failure is non-fatal) ==" + +set -l vroot8 (mktemp -d); set -ga TMPDIRS $vroot8 +set -l croot8 (mktemp -d); set -ga TMPDIRS $croot8 +set -l chome8 (mktemp -d); set -ga TMPDIRS $chome8 +set -l agy8 (mktemp -d); set -ga TMPDIRS $agy8 +set -g __fish_agent_vault_dir $vroot8/agent-vault +set -g __fish_agent_vault_claude_root $croot8 +set -g __fish_agent_vault_claude_home $chome8 +set -g __fish_agent_vault_agy_root $agy8 + +# A regular FILE where the global memory directory belongs. +# _agents_repo_ensure_symlink refuses to replace a non-directory, so the +# global link cannot succeed here. +echo not-a-directory >$chome8/memory + +# ... while this project has perfectly good memory waiting to be backed up. +set -l fp (new_repo https://git.rootiest.dev/rootiest/globalfail.git) +set -l fslug git.rootiest.dev-rootiest-globalfail +set -l fmangled (string replace -a '/' '-' -- $fp | string replace -a '.' '-') +mkdir -p $croot8/$fmangled/memory +echo project-memory >$croot8/$fmangled/memory/p.md + +set -l ferr (mktemp); set -ga TMPDIRS $ferr +pushd $fp >/dev/null +set -l frc (agents-vault --silent 2>$ferr; echo $status) +popd >/dev/null +set -l fwarn (cat $ferr) + +check "global link failure: exits 0" 0 "$frc" +check "global link failure: warns on stderr" true (string match -q "*$chome8/memory*" -- "$fwarn"; and echo true; or echo false) +check "global link failure: live file left alone" not-a-directory (cat $chome8/memory) + +# The finding itself: the primary, per-project backup must still happen. +check "global link failure: per-project link still created" true (test -L $croot8/$fmangled/memory; and echo true; or echo false) +check "global link failure: per-project memory reached the vault" project-memory (cat $vroot8/agent-vault/projects/$fslug/claude/memory/p.md) +check "global link failure: vault still committed" true (test (git -C $vroot8/agent-vault rev-list --count HEAD) -ge 1; and echo true; or echo false) +check "global link failure: per-project memory is committed" true (git -C $vroot8/agent-vault ls-files --error-unmatch projects/$fslug/claude/memory/p.md >/dev/null 2>&1; and echo true; or echo false) + +# A failed global link must not be recorded as done: the next run has to +# re-enter the block and warn again, not treat the vault as correct. +pushd $fp >/dev/null +set -l ferr2 (mktemp); set -ga TMPDIRS $ferr2 +agents-vault --silent 2>$ferr2 +popd >/dev/null +check "global link failure: retried on the next run" true (string match -q "*$chome8/memory*" -- (cat $ferr2); 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 -- 2.54.0 From 45f0fb9688d6437cda18f41b92f5684194dac11e Mon Sep 17 00:00:00 2001 From: Rootiest Date: Thu, 3 Sep 2026 01:34:11 -0400 Subject: [PATCH 13/19] feat(agents-vault): add status, restore, adopt, remote, and push --status reports link health, orphaned entries, and how far the vault is ahead of its remote, which is how an unpushed backup gets noticed. --adopt rebinds a machine-specific local-* entry by hand. --push is explicit; autopush stays opt-in via __fish_agent_vault_autopush. Three corrections to the planned shape: --status is dispatched ahead of the scaffold instead of behind it. As planned it sat after the tool install, the agy knowledge copy, and the global memory link, so asking for a report would first sync global state and claim ~/.claude/memory. It is now read-only and reports a missing vault rather than creating one. The global-state block moved below the mode dispatch so it runs only on a default or --link run; the mutating modes still need the vault repo, so they sit between the scaffold and it. --adopt validates its slug before using it. It is interpolated into "$vault/projects/$slug" and handed to `git mv`, so --adopt=../../../etc walked straight out of the vault. Only the charset the slug formula emits is accepted, with no slash and no leading dot. --remote captures the git exit status explicitly rather than chaining an `or` off the block terminator. That construct does work in fish, but it reads as the silent-false-success shape that a hook-rejected commit once produced here, and it stops working the moment the `else` goes away. Also pins the dangling-global-symlink case the suite never covered: for a broken ~/.claude/memory link both -d and -e are false, so the -L disjunct in the global-memory guard is the only thing that notices it. That is the state a buggy earlier run left on a real machine; the test asserts it is detected, repinned into the vault, and exits 0. --- completions/agents-vault.fish | 18 +++ functions/agents-vault.fish | 259 +++++++++++++++++++++++++++++++-- tests/test-agents-vault.fish | 266 ++++++++++++++++++++++++++++++++++ 3 files changed, 534 insertions(+), 9 deletions(-) create mode 100644 completions/agents-vault.fish 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 -- 2.54.0 From 090779ae5da4e0896a391a7e65614bd152d62643 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Thu, 3 Sep 2026 01:54:46 -0400 Subject: [PATCH 14/19] fix(agents-vault): make push failure fatal and adopt atomic A push that fails against a configured remote warned on stderr and then fell through to the trailing branchless `if`, which resolves to 0, so `--push` reported a successful backup while nothing had left the machine. That is the exact loss the vault exists to prevent. It now returns non-zero, verified against a real unreachable remote rather than a mock. The same audit found two more false zeros in this function, both fixed: the commit block warned about a rebase conflict and walked past it, and swallowed a hook-rejected commit entirely (neither branch of its if/else if matched, since the error goes to stderr rather than stdout); and --restore reported a relink failure and then returned 0 regardless. All three now feed one flag and the function ends on an explicit status rather than on whatever the last branchless `if` left behind. --adopt is now atomic. A rename that landed while the relink failed left the memory intact at the new slug but unreferenced: the next ordinary run found no live link, recomputed the old slug, found nothing there, and fabricated a fresh empty entry, so the agent wrote history-less memory from then on. No bytes were lost, but continuity was, with no automated recovery. The live link is no longer removed first -- ensure_symlink repins a link that points elsewhere on its own -- a contentless target entry is moved aside rather than deleted, the origin note is appended only after the relink succeeds, and a failed relink rolls the rename back so the vault is exactly as it was. The --adopt validator no longer refuses a leading dot. _agents_repo_slug legitimately emits one for a dot-led subdomain, so refusing it made such an entry impossible to adopt; inside projects/ it is a hidden directory, not an escape. The traversal cases are still refused: no slash survives the charset, and "." and ".." are refused by name. Adds the RETURNS section the header was missing. --status prints a structured report, which this repo's convention treats as return value rather than as progress output. --- functions/agents-vault.fish | 108 +++++++++++++++++++++++++++------- tests/test-agents-vault.fish | 109 ++++++++++++++++++++++++++++++++++- 2 files changed, 194 insertions(+), 23 deletions(-) diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish index 9da3d4e..ae50dcc 100644 --- a/functions/agents-vault.fish +++ b/functions/agents-vault.fish @@ -71,9 +71,11 @@ # # --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. +# SLUG must match [a-z0-9._-]+ and be neither "." nor ".." -- the charset +# the slug formula itself emits -- since it is interpolated into a vault +# path and handed to git mv. The rename and the relink are atomic: if the +# 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. @@ -94,7 +96,15 @@ # EXIT STATUS # 0 Completed successfully # 1 Fatal error (vault unavailable, git failure, ambiguous migration, -# invalid --adopt slug, or --push with no remote configured) +# invalid --adopt slug, nothing committed, or a push that did not +# reach the remote) +# +# RETURNS +# --status prints its report on stdout: the vault path, the remote and +# how far ahead of it the vault is, a warning for an unresolved rebase, +# then one line per entry reading "linked" or "orphan", the slug, and the +# file count. Every other mode prints only verbosity-gated progress +# lines, and nothing at all when there was nothing to do. # # EXAMPLE # agents-vault @@ -362,11 +372,20 @@ function agents-vault --description 'track curated agent memory in a host-scoped # 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" + # accepted, which excludes the slash that any traversal needs, and + # the two names that traverse without one are refused by name. + # + # A leading dot is *not* refused: _agents_repo_slug legitimately + # emits one for a dot-led subdomain (https://.hidden.example.com/r + # keys as .hidden.example.com-r), and refusing it would make such + # an entry impossible to adopt. Inside projects/ a leading dot is + # a hidden directory, not an escape. + set -l bad_slug 0 + string match -qr '^[a-z0-9._-]+$' -- "$_flag_adopt"; or set bad_slug 1 + contains -- "$_flag_adopt" . ..; and set bad_slug 1 + if test $bad_slug -eq 1 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 + echo "$c_err"" A slug is [a-z0-9._-]+, is not '.' or '..', and holds no slash.$c_reset" >&2 return 1 end @@ -393,23 +412,49 @@ function agents-vault --description 'track curated agent memory in a host-scoped 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" + # Adopting is a rename plus a relink, and it must be all or + # nothing. A rename that lands while the relink fails leaves the + # memory intact at the new slug but unreferenced: the next ordinary + # run finds no live link, recomputes the old slug, finds nothing + # there, and fabricates a fresh empty entry, so the agent writes + # history-less memory from then on. No bytes are lost, but + # continuity is, and nothing recovers it automatically. + # + # 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. + set -l stash "" + if test -d "$to" + set stash "$vault/.adopt-stash" + 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 - command mv "$from" "$to"; or return 1 + if not command mv "$from" "$to" + test -n "$stash"; and command mv "$stash" "$to" + return 1 + end 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" + # The live link is deliberately left in place for ensure_symlink to + # repin, which it does on its own for a link pointing elsewhere. + # Removing it first would only widen the window in which a failure + # leaves the project with no link at all. 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 + if not git -C "$vault" mv "projects/$_flag_adopt" "projects/$cur" 2>/dev/null + command mv "$to" "$from" + end + test -n "$stash"; and command mv "$stash" "$to" + echo "$c_err""agents-vault: could not relink $claude_root/$mangled/memory; $cur was left as it was$c_reset" >&2 return 1 end + printf 'adopted: %s → %s (%s)\n' "$cur" "$_flag_adopt" (date -I) >>"$to/origin" + test -n "$stash"; and rm -rf "$stash" _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" @@ -427,6 +472,7 @@ function agents-vault --description 'track curated agent memory in a host-scoped 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" + set -l restore_failed 0 for entry in "$vault"/projects/* test -d "$entry/claude/memory"; or continue set -l eslug (path basename "$entry") @@ -440,6 +486,7 @@ function agents-vault --description 'track curated agent memory in a host-scoped 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 + set restore_failed 1 else if test -n "$msg" test $verbose -eq 1; and echo "$c_ok→ Restored $eslug$c_reset" end @@ -448,7 +495,11 @@ function agents-vault --description 'track curated agent memory in a host-scoped and echo "$c_warn→ Cannot place $eslug: no live project found; use --adopt from the project$c_reset" end end - return 0 + # An entry that could not be relinked is a failure, not a note in + # passing: the same reasoning as the commit and push paths below. + # An entry with no live project is not -- there is nothing wrong + # with the vault, the project simply is not on this machine. + return $restore_failed end # ────────────────────────── global state ─────────────────────────── @@ -642,6 +693,12 @@ function agents-vault --description 'track curated agent memory in a host-scoped end # ───────────────────────────── commit ────────────────────────────── + # A sync that did not commit is a backup that did not happen, so it is + # reported as a failure rather than warned about and walked past. The + # function otherwise ends on a branchless `if`, which resolves to 0, + # and a backup tool that reports success while nothing was recorded + # recreates the exact loss the vault exists to prevent. + set -l failed 0 if not set -q _flag_link set -l msg "chore: sync agent memory vault" test $did_init -eq 1; and set msg "chore: initialize agent memory vault" @@ -649,6 +706,10 @@ function agents-vault --description 'track curated agent memory in a host-scoped 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 + set failed 1 + else if test $sync_rc -ne 0 + echo "$c_err""agents-vault: the vault 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" @@ -669,9 +730,12 @@ function agents-vault --description 'track curated agent memory in a host-scoped 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 + # 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_warn""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 # An explicit --push that pushed nowhere must not read as a @@ -689,4 +753,8 @@ function agents-vault --description 'track curated agent memory in a host-scoped echo "$c_ok→ Synced agent memory vault$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/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index 475df2f..411f480 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -771,7 +771,8 @@ check "adopt recorded the rebind" true (string match -q "*$aslug*my-chosen-slug* # 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. +# formula itself emits is accepted, plus a by-name refusal of the two +# traversal names that need no slash. set -l bp (new_repo) set -l bmang (string replace -a '/' '-' -- $bp | string replace -a '.' '-') mkdir -p $croot9/$bmang/memory @@ -781,7 +782,7 @@ 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' '' +set -l bad_slugs ../escape has/slash . .. 'UPPER' 'sp ace' '' pushd $bp >/dev/null for bad in $bad_slugs set -l berr (mktemp); set -ga TMPDIRS $berr @@ -793,7 +794,71 @@ 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) + +# ... but a *leading* dot is legitimate, not traversal. _agents_repo_slug +# emits one for a dot-led subdomain, so refusing it would make such an +# entry impossible to adopt. Inside projects/ it is a hidden directory. +pushd $bp >/dev/null +set -l drc (agents-vault --adopt=.hidden.example.com-repo --silent; echo $status) +popd >/dev/null +check "adopt accepts a leading-dot slug" 0 "$drc" +check "leading-dot slug landed inside projects/" bad (cat $vroot9/agent-vault/projects/.hidden.example.com-repo/claude/memory/b.md 2>/dev/null) +check "leading-dot slug escaped nothing" false (test -e $vroot9/agent-vault/.hidden.example.com-repo; and echo true; or echo false) +check "leading-dot slug repinned the link" (path resolve $vroot9/agent-vault/projects/.hidden.example.com-repo/claude/memory) (path resolve $croot9/$bmang/memory) + +# --adopt must be atomic. A rename that lands while the relink fails +# leaves the memory intact but unreferenced: the next ordinary run finds +# no live link, recomputes the old slug, finds nothing there, and +# fabricates a fresh empty entry, so the agent writes history-less memory +# from then on. The relink is forced to fail by making the project's live +# parent directory read-only, which stops ensure_symlink repinning it. +set -l tp (new_repo https://git.rootiest.dev/rootiest/atomic.git) +set -l tslug git.rootiest.dev-rootiest-atomic +set -l tmang (string replace -a '/' '-' -- $tp | string replace -a '.' '-') +mkdir -p $croot9/$tmang/memory +echo atomic-precious >$croot9/$tmang/memory/keep.md +pushd $tp >/dev/null +agents-vault --silent +popd >/dev/null + +set -l pre_entries (command ls -A $vroot9/agent-vault/projects | sort | string join ',') +set -l pre_head (git -C $vroot9/agent-vault rev-list --count HEAD) +set -l pre_porcelain (git -C $vroot9/agent-vault status --porcelain | string join ',') +set -l pre_link (path resolve $croot9/$tmang/memory) + +set -l terr (mktemp); set -ga TMPDIRS $terr +chmod 500 $croot9/$tmang +pushd $tp >/dev/null +set -l trc (agents-vault --adopt=atomic-target --silent 2>$terr; echo $status) +popd >/dev/null +chmod 700 $croot9/$tmang + +check "atomic adopt: failed relink returns 1" 1 "$trc" +check "atomic adopt: says the entry was left alone" true (string match -q "*$tslug*left as it was*" -- (cat $terr); and echo true; or echo false) +check "atomic adopt: rename rolled back" "$pre_entries" (command ls -A $vroot9/agent-vault/projects | sort | string join ',') +check "atomic adopt: target entry not created" false (test -e $vroot9/agent-vault/projects/atomic-target; and echo true; or echo false) +check "atomic adopt: original entry intact" atomic-precious (cat $vroot9/agent-vault/projects/$tslug/claude/memory/keep.md 2>/dev/null) +check "atomic adopt: no origin note appended" false (string match -q '*adopted:*' -- (cat $vroot9/agent-vault/projects/$tslug/origin); and echo true; or echo false) +check "atomic adopt: nothing committed" "$pre_head" (git -C $vroot9/agent-vault rev-list --count HEAD) +check "atomic adopt: index and worktree unchanged" "$pre_porcelain" (git -C $vroot9/agent-vault status --porcelain | string join ',') +check "atomic adopt: live link never removed" "$pre_link" (path resolve $croot9/$tmang/memory) +check "atomic adopt: no stash left behind" false (test -e $vroot9/agent-vault/.adopt-stash; and echo true; or echo false) + +# The point of rolling back: an ordinary run afterwards must find the +# original entry and must NOT fabricate a second one. +pushd $tp >/dev/null +agents-vault --silent +popd >/dev/null +check "atomic adopt: ordinary run fabricated no entry" "$pre_entries" (command ls -A $vroot9/agent-vault/projects | sort | string join ',') +check "atomic adopt: ordinary run kept the original link" (path resolve $vroot9/agent-vault/projects/$tslug/claude/memory) (path resolve $croot9/$tmang/memory) +check "atomic adopt: memory still reachable through the link" atomic-precious (cat $croot9/$tmang/memory/keep.md 2>/dev/null) + +# ... and once the underlying problem is fixed, --adopt simply works. +pushd $tp >/dev/null +set -l t2rc (agents-vault --adopt=atomic-target --silent; echo $status) +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) set -e __fish_agent_vault_dir set -e __fish_agent_vault_claude_root @@ -843,6 +908,17 @@ 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) +# 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. +rm -f $croot10/$rmang/memory +chmod 500 $croot10/$rmang +set -l r3err (mktemp); set -ga TMPDIRS $r3err +set -l r3rc (agents-vault --restore >/dev/null 2>$r3err; echo $status) +chmod 700 $croot10/$rmang +check "restore: a failed relink returns non-zero" 1 "$r3rc" +check "restore: a failed relink is reported" true (string match -q '*restoreme*' -- (cat $r3err); 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 @@ -902,6 +978,33 @@ 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) +# A push that *fails* against a configured remote must return non-zero. +# The function otherwise ends on a branchless `if`, which resolves to 0, +# so warning on stderr and falling through reports a successful backup +# while nothing left the machine -- the exact loss the vault prevents. +# The remote is a real path that is not a repository, not a mock. +set -l deadremote $vroot11/not-a-repo.git +agents-vault --remote=$deadremote --silent +echo pushed-never >$croot11/$pmang/memory/p4.md +set -l fperr (mktemp); set -ga TMPDIRS $fperr +pushd $pp >/dev/null +set -l fprc (agents-vault --push --silent 2>$fperr; echo $status) +popd >/dev/null +check "failing push returns non-zero" 1 "$fprc" +check "failing push warns on stderr" true (string match -q '*push failed*' -- (cat $fperr); and echo true; or echo false) +check "failing push still committed locally" true (git -C $vroot11/agent-vault ls-files --error-unmatch projects/$pslug/claude/memory/p4.md >/dev/null 2>&1; and echo true; or echo false) + +# Autopush is the same failure through the quiet path: a summary line +# saying "Synced" must not come with a zero exit when the push failed. +echo pushed-never-2 >$croot11/$pmang/memory/p5.md +set -g __fish_agent_vault_autopush 1 +set -l fp2err (mktemp); set -ga TMPDIRS $fp2err +pushd $pp >/dev/null +set -l fp2rc (agents-vault --quiet 2>$fp2err >/dev/null; echo $status) +popd >/dev/null +set -e __fish_agent_vault_autopush +check "failing autopush returns non-zero" 1 "$fp2rc" + set -e __fish_agent_vault_dir set -e __fish_agent_vault_claude_root set -g __fish_agent_vault_claude_home $HERMETIC_HOME/claude -- 2.54.0 From 11f4551fa5d9b0769e6329eecc13ab52a441d89c Mon Sep 17 00:00:00 2001 From: Rootiest Date: Thu, 3 Sep 2026 12:22:25 -0400 Subject: [PATCH 15/19] fix(agents-vault): repair the adopt rollback and see dot-led entries The adopt rollback restored the worktree but not the index. Every move it makes is a plain rename as far as git is concerned -- the stash move out from under the index most of all -- so a rolled-back adopt left a half-applied rename staged against a clean vault. No bytes were at risk and the next ordinary run's `git add -A` healed it, but a hand `git commit` in that window recorded the half-applied state. Both rollback sites now re-read projects/ once the worktree is whole again, the stash restore included. projects/ is named whole rather than the two entries, because `git add` refuses a pathspec that matches nothing -- which one of the two always is, once it has been moved back -- and then stages neither. The stash itself moves from the vault root into .git/, where neither the entry walk nor `git add -A` can reach it, so a crash between the two moves can no longer leave junk at the vault root for the next run to commit. A vault whose .git is not a directory falls back to the root, which the scaffolded .gitignore now covers. --status and --restore walked the vault with a fish glob, which does not match dot-led names. A dot-led slug is both reachable and sanctioned: 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, committed and pushed normally, yet --status under-reported it and batch --restore left that project unlinked, both without saying so. Both walks now list the directory instead. The --adopt completion gains -A for the same reason: an entry that cannot be completed reads as one that is not there. The now-fatal push failure is painted as an error rather than a warning, matching its sibling on the commit path. The header notes that --adopt does not pin a name. The slug is re-derived on every run, so the next ordinary run migrates the adopted entry back to the canonical key, memory and live link following. Behaviour unchanged; only the documentation gap is closed. Tests, 173 -> 209 checks. The whole stash branch of --adopt was uncovered, because the existing atomicity test adopts onto a slug with no entry at all: a successful stash-adopt and a stash-adopt whose relink fails are both pinned now, the latter asserting an empty `git status --porcelain` and a still-reachable live memory. The stash location is pinned by making the vault root unwritable for the duration, which only a stash at the root would need. agents-vault's own propagation of a failed sync had no test at all -- the third recurrence of fish's branchless-`if` false zero here -- so both ways it can fail are now driven end to end: a rejecting pre-commit at the vault's own core.hooksPath, and a real rebase conflict against a bare remote. A dot-led entry is asserted in --status and in --restore. --- completions/agents-vault.fish | 5 +- functions/agents-vault.fish | 73 +++++++++-- tests/test-agents-vault.fish | 228 ++++++++++++++++++++++++++++++++++ 3 files changed, 296 insertions(+), 10 deletions(-) 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 -- 2.54.0 From 2ad5bf75d20345d19d9fb49c737b5d6867553301 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Thu, 3 Sep 2026 12:43:13 -0400 Subject: [PATCH 16/19] feat(agents-vault): sync the vault from the claude and agy wrappers Both wrappers stay behind the C1 guard, so disabling __fish_config_op_aliases still passes straight through to the real binary. Launch commits but never pushes, keeping the network and any credential prompt off the critical path; pushing is left to the Claude Code SessionEnd hook. agy has no such hook, so its memory lands one launch later. --- functions/agents-vault.fish | 13 +++++--- functions/agy.fish | 14 +++++--- functions/claude.fish | 9 +++++- tests/functional.fish | 28 ++++++++++++++++ tests/run-tests.fish | 14 ++++++++ tests/test-agents-vault.fish | 62 ++++++++++++++++++++++++++++++++++++ 6 files changed, 131 insertions(+), 9 deletions(-) diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish index 066d01d..5b41da0 100644 --- a/functions/agents-vault.fish +++ b/functions/agents-vault.fish @@ -475,8 +475,12 @@ function agents-vault --description 'track curated agent memory in a host-scoped # 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 + # that did match. Its stderr is deliberately not swallowed: + # projects/ provably exists here, so the only thing a + # redirect could hide is a real failure -- a held + # index.lock, say -- after which the index stays + # half-applied while nothing says so. + git -C "$vault" add -A -- projects return 1 end end @@ -498,8 +502,9 @@ function agents-vault --description 'track curated agent memory in a host-scoped # 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 + # See the same call above for why projects/ is named whole, + # and why its stderr is left visible. + git -C "$vault" add -A -- projects echo "$c_err""agents-vault: could not relink $claude_root/$mangled/memory; $cur was left as it was$c_reset" >&2 return 1 end diff --git a/functions/agy.fish b/functions/agy.fish index cfc751d..2cd47c1 100644 --- a/functions/agy.fish +++ b/functions/agy.fish @@ -8,7 +8,7 @@ # aliases/dev-tools # # DEPENDENCIES -# agents-init +# agents-init, agents-vault # # SYNOPSIS # agy [ARGS...] @@ -18,9 +18,14 @@ # sub-repository is initialized and any agent-made changes are committed # before launch. Delegates all scaffold and commit logic to agents-init # --quiet (full setup), which ensures AGENTS/ is scaffolded and CLAUDE.md -# is symlinked to AGENTS/AGENTS.md in the current project. Arguments are -# forwarded verbatim to the real agy binary, except for -r/--resume which -# are translated to -c/--continue. +# is symlinked to AGENTS/AGENTS.md in the current project. +# +# Also syncs the host-scoped agent memory vault (agents-vault). agy has +# no session-end hook, so its memory is captured on the next launch +# rather than at session end. +# +# Arguments are forwarded verbatim to the real agy binary, except for +# -r/--resume which are translated to -c/--continue. # # Opinionated component (C1): when disabled via __fish_config_op_aliases # (or the __fish_config_opinionated master), the command is passed through @@ -44,6 +49,7 @@ function agy --wraps=agy --description 'agy wrapper: auto-initializes AGENTS/ su end agents-init --quiet + agents-vault --quiet for i in (seq (count $argv)) if test "$argv[$i]" = "-r" diff --git a/functions/claude.fish b/functions/claude.fish index ff3d278..37285b5 100644 --- a/functions/claude.fish +++ b/functions/claude.fish @@ -8,7 +8,7 @@ # aliases/dev-tools # # DEPENDENCIES -# agents-init +# agents-init, agents-vault # # SYNOPSIS # claude [ARGS...] @@ -19,6 +19,12 @@ # Delegates all scaffold and commit logic to agents-init --quiet (full # setup), which ensures AGENTS/ is scaffolded and CLAUDE.md is symlinked # to AGENTS/AGENTS.md in the current project. +# +# Also syncs the host-scoped agent memory vault (agents-vault), which +# tracks curated memory living outside the project tree. The vault +# commits on launch but does not push; pushing happens from the Claude +# Code SessionEnd hook or an explicit agents-vault --push. +# # All arguments are forwarded verbatim to the real claude binary. # # Opinionated component (C1): when disabled via __fish_config_op_aliases @@ -42,6 +48,7 @@ function claude --wraps=claude --description 'claude wrapper: auto-links AGENTS. end agents-init --quiet + agents-vault --quiet command claude $argv end diff --git a/tests/functional.fish b/tests/functional.fish index a6f326f..8dc6649 100644 --- a/tests/functional.fish +++ b/tests/functional.fish @@ -58,6 +58,34 @@ function test_greeting_function_defined functions -q fish_greeting end +function test_agents_vault_defined + for f in agents-vault _agents_vault_dir _agents_repo_slug \ + _agents_repo_ensure_symlink _agents_repo_sync \ + _agents_repo_install_tools + if not functions -q $f + echo " missing function: $f" + return 1 + end + end +end + +function test_wrappers_call_agents_vault + functions -q claude; or return 1 + functions claude | string match -q '*agents-vault*'; or return 1 + functions -q agy; or return 1 + functions agy | string match -q '*agents-vault*' +end + +function test_vault_dir_honors_override + set -l saved + set -q __fish_agent_vault_dir; and set saved $__fish_agent_vault_dir + set -g __fish_agent_vault_dir /tmp/vault-override-check + set -l got (_agents_vault_dir) + set -e __fish_agent_vault_dir + test (count $saved) -gt 0; and set -g __fish_agent_vault_dir $saved + test "$got" = /tmp/vault-override-check +end + function functional_test_main set -l names (functions -a | string match 'test_*' | sort) set -l failed 0 diff --git a/tests/run-tests.fish b/tests/run-tests.fish index a0af7cc..59bc260 100755 --- a/tests/run-tests.fish +++ b/tests/run-tests.fish @@ -11,6 +11,8 @@ # and loads it as an isolated interactive session. # 3. Runs the functional checks in tests/functional.fish inside that # loaded session. +# 4. Runs tests/test-agents-vault.fish as its own process; that suite +# builds its own throwaway repos and needs no loaded config. # # Usage: fish tests/run-tests.fish @@ -86,4 +88,16 @@ if test $functional_status -ne 0 set overall_failed 1 end +# ---- Phase 3: hermetic vault helper tests -------------------------------- +# Run as its own fish process rather than inside the sandboxed session: +# the suite builds its own throwaway git repos and binds the vault, claude +# and agy roots to them, so it needs no loaded config and must never see +# the real ~/.claude. +echo "" +echo "== Vault helper tests ==" +fish $repo_root/tests/test-agents-vault.fish +if test $status -ne 0 + set overall_failed 1 +end + exit $overall_failed diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index ea208be..51b6efe 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -958,6 +958,68 @@ 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) +# The other rollback in --adopt: the *forward* move failing outright, +# before the relink is ever reached. A plain file sitting where the target +# entry would go makes `git mv` refuse ("destination already exists") and +# the coreutils fallback refuse too ("cannot overwrite non-directory"), so +# the worktree needs no repair -- but the index does, and that is the half +# of the rollback nothing else pins. With a real git there is no way for a +# test to leave a genuinely half-applied rename here, so the divergence +# stands in for one: the index is desynced by hand first, and the check +# that matters is that the way out re-read projects/ and healed it. +# Without that setup the repair is an invisible no-op, and a refactor can +# drop it with the suite still green. +set -l ff (new_repo https://git.rootiest.dev/rootiest/fwd-fail.git) +set -l ffslug git.rootiest.dev-rootiest-fwd-fail +set -l ffmang (string replace -a '/' '-' -- $ff | string replace -a '.' '-') +mkdir -p $croot9/$ffmang/memory +echo fwdfail-precious >$croot9/$ffmang/memory/keep.md +pushd $ff >/dev/null +agents-vault --silent +popd >/dev/null + +# The blocker must be tracked and committed, so that the only thing dirty +# at adopt time is the divergence staged just below. +echo occupied >$vroot9/agent-vault/projects/fwdfail-target +pushd $ff >/dev/null +agents-vault --silent +popd >/dev/null +check "fwd-fail adopt: the blocking file is tracked" true (git -C $vroot9/agent-vault ls-files --error-unmatch projects/fwdfail-target >/dev/null 2>&1; and echo true; or echo false) +check "fwd-fail adopt: vault clean before the divergence" "" (git -C $vroot9/agent-vault status --porcelain | string join ',') + +git -C $vroot9/agent-vault rm -q --cached projects/$ffslug/origin >/dev/null +set -l ff_dirty (git -C $vroot9/agent-vault status --porcelain | string join ',') +check "fwd-fail adopt: index diverges before the adopt" true (string match -q "*D projects/$ffslug/origin*" -- "$ff_dirty"; and echo true; or echo false) + +set -l ff_entries (command ls -A $vroot9/agent-vault/projects | sort | string join ',') +set -l ff_head (git -C $vroot9/agent-vault rev-list --count HEAD) +set -l ff_link (path resolve $croot9/$ffmang/memory) + +pushd $ff >/dev/null +set -l ffrc (agents-vault --adopt=fwdfail-target --silent 2>/dev/null; echo $status) +popd >/dev/null + +check "fwd-fail adopt: returns 1" 1 "$ffrc" +check "fwd-fail adopt: entries unchanged" "$ff_entries" (command ls -A $vroot9/agent-vault/projects | sort | string join ',') +check "fwd-fail adopt: source entry intact" fwdfail-precious (cat $vroot9/agent-vault/projects/$ffslug/claude/memory/keep.md 2>/dev/null) +check "fwd-fail adopt: blocking file untouched" occupied (cat $vroot9/agent-vault/projects/fwdfail-target 2>/dev/null) +check "fwd-fail adopt: no origin note appended" false (string match -q '*adopted:*' -- (cat $vroot9/agent-vault/projects/$ffslug/origin); and echo true; or echo false) +check "fwd-fail adopt: nothing committed" "$ff_head" (git -C $vroot9/agent-vault rev-list --count HEAD) +check "fwd-fail adopt: live link never removed" "$ff_link" (path resolve $croot9/$ffmang/memory) +check "fwd-fail adopt: memory still reachable live" fwdfail-precious (cat $croot9/$ffmang/memory/keep.md 2>/dev/null) +check "fwd-fail adopt: 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) +# The pin: projects/ was re-read on the way out, so git describes the +# files as they actually are rather than as the abandoned rename left them. +check "fwd-fail adopt: index re-read to match the worktree" "" (git -C $vroot9/agent-vault status --porcelain | string join ',') +check "fwd-fail adopt: nothing left staged against HEAD" "" (git -C $vroot9/agent-vault diff HEAD --name-only | string join ',') + +# And the ordinary run afterwards still finds the original entry. +pushd $ff >/dev/null +agents-vault --silent +popd >/dev/null +check "fwd-fail adopt: ordinary run fabricated no entry" "$ff_entries" (command ls -A $vroot9/agent-vault/projects | sort | string join ',') +check "fwd-fail adopt: ordinary run kept the memory reachable" fwdfail-precious (cat $croot9/$ffmang/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 -- 2.54.0 From 16ea31289d5beebb92c0234b35f37f89dd0b193f Mon Sep 17 00:00:00 2001 From: Rootiest Date: Thu, 3 Sep 2026 13:23:24 -0400 Subject: [PATCH 17/19] 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 -- 2.54.0 From d9b56790c58af252279956c6dc55727469c23f16 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Thu, 3 Sep 2026 18:18:55 -0400 Subject: [PATCH 18/19] fix(agents-vault): keep the knowledge walk and the launch push inside their bounds The agy knowledge allowlist walked the store with `**` and copied with plain cp, so a symlink inside the store was both followed and dereferenced. The extension rule still bounded what kind of file was collected, but not whose: a link to a home directory hands over settings.json, CLAUDE.md and every cached .json in it, and those reached a commit. A link to / made the walk itself unbounded, on the path that runs before every agent launch. The tree is now walked a level at a time and nothing that is a symlink is followed or copied. Autopush had the same shape one layer out. Neither GIT_TERMINAL_PROMPT nor GIT_ASKPASS closes a socket, and git has no connect timeout to set: against a blackholed address a push took 135s with http.lowSpeedLimit and http.lowSpeedTime set as well as without them. ssh can time itself out and is now told to; the autopush pull and push are additionally capped with timeout(1). An explicit --push stays uncapped, since it is watched and has to report what a real transfer really did. Also: scaffold /.migrate-stash into .gitignore beside /.adopt-stash, which the comment already claimed was covered; and drop the live memory path during a slug migration only when it is a link. Reached from the path-derived fallback candidate it can be a real populated directory, where rm -f correctly refuses -- but said so in rm's voice, so a --silent run that had succeeded printed what read as an error. --- docs/manual/07-customization.md | 4 +- functions/agents-vault.fish | 108 +++++++++++++++++++++++++---- tests/test-agents-vault.fish | 119 ++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 14 deletions(-) diff --git a/docs/manual/07-customization.md b/docs/manual/07-customization.md index e5f1a1a..02a9559 100644 --- a/docs/manual/07-customization.md +++ b/docs/manual/07-customization.md @@ -170,7 +170,9 @@ full sub-category breakdown of every category. When set to 1, agents-vault also pushes on wrapper launch. Defaults to off: the vault commits locally on every launch and pushes from the - Claude Code SessionEnd hook or an explicit agents-vault --push. + Claude Code SessionEnd hook or an explicit agents-vault --push. That + push is synchronous, so with autopush on the pull and the push are + each capped at 20 seconds; an explicit --push is left uncapped. NOTE: With autopush off and no SessionEnd hook installed, backups accumulate diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish index 393d8ae..9193c65 100644 --- a/functions/agents-vault.fish +++ b/functions/agents-vault.fish @@ -38,7 +38,9 @@ # 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. +# same rule rather than by being known about in advance. Symlinks found +# inside the store are neither followed nor copied, so the allowlist +# bounds whose files it collects and not merely what kind. # # Global state that belongs to no project is tracked as well. Claude's # global memory directory (~/.claude/memory) is symlinked into the vault @@ -131,9 +133,13 @@ # # NOTES # Set __fish_agent_vault_dir to relocate the vault. Set -# __fish_agent_vault_autopush to 1 to also push on wrapper launch; -# it defaults to off so a backgrounded push can never hang or prompt -# invisibly underneath a starting agent. +# __fish_agent_vault_autopush to 1 to also push on wrapper launch; it +# defaults to off because that push is synchronous and so delays every +# launch. With it on, the pull and the push are each capped at 20 +# seconds, since git has no connect timeout of its own and an +# unreachable remote otherwise blocks for minutes. An explicit --push +# is left uncapped: it is watched, and it must report what a real +# transfer really did. # # --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 @@ -329,10 +335,12 @@ function agents-vault --description 'track curated agent memory in a host-scoped '*.db-wal' \ '*.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" + '# --adopt and the slug migration each stash the entry they are' \ + '# about to overwrite inside .git/, out of reach of `git add -A`.' \ + '# These cover the fallback locations they use when .git is not a' \ + '# directory.' \ + '/.adopt-stash' \ + '/.migrate-stash' >"$vault/.gitignore" set changed 1 end @@ -627,7 +635,39 @@ function agents-vault --description 'track curated agent memory in a host-scoped # 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 + # + # The tree is walked a level at a time rather than globbed with **, + # because a symlink has to stop the walk and ** has no way to say + # so. A recursive ** descends through a symlinked directory and cp + # follows a symlinked file, which between them undo the whole point + # of the allowlist twice over. A `ln -s ~ knowledge/x` puts every + # qualifying .md and .json in the home directory -- settings.json, + # CLAUDE.md, cache and status files -- into the vault and into a + # commit: the extension rule still bounds what *kind* of file goes + # in, but the store boundary that decides *whose* files they are is + # gone. Worse, `ln -s / knowledge/x` makes the walk itself + # unbounded, and this runs synchronously in front of every agent + # launch. Fish does stop a true self-referential cycle; a symlink + # to a merely enormous tree is not a cycle. + # + # So nothing that is a symlink is ever followed or copied, whether + # it names a file or a directory. The knowledge store's own root + # may still be a link -- that one is the configured location of the + # store rather than something found inside it. + set -l kfiles + set -l kdirs "$knowledge" + while set -q kdirs[1] + set -l dir $kdirs[1] + set -e kdirs[1] + for e in $dir/* + test -L "$e"; and continue + if test -d "$e" + set -a kdirs "$e" + else if string match -qr '\.(md|json)$' -- "$e" + set -a kfiles "$e" + end + end + end set -l kfailed 0 for f in $kfiles test -f "$f"; or continue @@ -836,7 +876,18 @@ function agents-vault --description 'track curated agent memory in a host-scoped end printf 'renamed: %s → %s (%s)\n' "$prev_slug" "$slug" (date -I) \ >>"$entry/origin" - rm -f "$live" + # Only a link is dropped here, and only so the relink below has + # somewhere to put the new one. Usually $live is exactly that: a + # symlink at the old entry, now dangling. But the migration is + # also reachable from the path-derived fallback candidate, and + # there $live can be a real, populated directory -- someone's + # actual memory. rm -f cannot delete it, which is the right + # outcome, but it says so on stderr in rm's own voice, so a + # --silent run that succeeded printed what reads as an error. + # The directory case needs no removal anyway: + # _agents_repo_ensure_symlink copies a populated live directory + # into the vault without clobbering before it replaces it. + test -L "$live"; and rm -f "$live" set changed 1 test $verbose -eq 1; and echo "$c_ok→ Migrated vault entry $prev_slug → $slug$c_reset" end @@ -930,10 +981,41 @@ function agents-vault --description 'track curated agent memory in a host-scoped # 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. + # + # They close the prompt, not the socket, and git has no knob + # that closes the socket either: there is no HTTP connect + # timeout in its configuration at all, and http.lowSpeedLimit / + # http.lowSpeedTime -- the usual suggestion -- only start + # counting once bytes are moving, so they expire never against + # an address that simply blackholes the SYN. Measured against + # 192.0.2.1 with both set: no return inside 30s; unset: 135s. + # + # ssh is the one transport that can time itself out, so it is + # told to, unless the user has already said how to run ssh. + # Everything else is bounded from outside with timeout(1). + set -l gitenv GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=true + set -q GIT_SSH_COMMAND + or set -a gitenv 'GIT_SSH_COMMAND=ssh -o ConnectTimeout=10' + set -l gitnet env $gitenv + + # Only autopush is wrapped. An explicit --push is a thing the + # user asked for and is watching, and it must report the real + # exit status of a real transfer, so it is allowed to take as + # long as the transfer honestly takes. Autopush runs + # synchronously in front of every agent launch, which is the + # block this whole design exists to remove; there an + # unreachable remote costs a bounded 20s per operation instead + # of an open-ended wait. timeout's own 124 is a non-zero exit + # like any other, so a bounded push still reports as failed and + # the commit it could not send has already landed locally. A + # push too slow for the bound can always be run by hand. + if not set -q _flag_push; and type -q timeout + set gitnet timeout 20 $gitnet + end 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 + if not $gitnet 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 @@ -956,7 +1038,7 @@ function agents-vault --description 'track curated agent memory in a host-scoped # 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 + if $gitnet 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 diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index eee5415..e3aa4aa 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -563,6 +563,54 @@ set -l dirty_new_slug git.rootiest.dev-rootiest-dirty check "fallback finds sanitized local entry" dirty-precious (cat $vroot2/agent-vault/projects/$dirty_new_slug/claude/memory/keep.md) check "fallback old entry removed" false (test -d $vroot2/agent-vault/projects/$dirty_local_slug; and echo true; or echo false) +# Same fallback path, but the live memory directory is a real populated +# directory rather than a symlink -- the shape a machine ends up in when +# an agent wrote memory while the link was missing. The migration then +# tries to clear the live path out of the relink's way, and clearing a +# directory is not something it may do: that is somebody's memory, and +# _agents_repo_ensure_symlink already folds it into the vault without +# clobbering. Refusing is therefore correct, but refusing in rm's voice +# is not: a --silent run that did the right thing and returned 0 still +# printed "rm: cannot remove ...: Is a directory", which is the only +# thing the user sees and reads as a failure. +set -l real_root (mktemp -d); set -ga TMPDIRS $real_root +set -l rp "$real_root/proj" +mkdir -p "$rp" +git -C "$rp" init -q +git -C "$rp" config user.email t@t +git -C "$rp" config user.name t +git -C "$rp" config commit.gpgsign false +git -C "$rp" config core.hooksPath /dev/null + +set -l rmangled (string replace -a '/' '-' -- $rp | string replace -a '.' '-') +mkdir -p $croot2/$rmangled/memory +echo "banked" >$croot2/$rmangled/memory/old.md + +pushd $rp >/dev/null +agents-vault --silent +set -l real_local_slug (_agents_repo_slug $rp) +popd >/dev/null + +# Replace the link with a real directory holding memory the vault has +# never seen, then change the slug so the migration runs. +rm -f $croot2/$rmangled/memory +mkdir -p $croot2/$rmangled/memory +echo "written-live" >$croot2/$rmangled/memory/fresh.md +git -C $rp remote add origin https://git.rootiest.dev/rootiest/realdir.git + +set -l rerr (mktemp); set -ga TMPDIRS $rerr +pushd $rp >/dev/null +set -l real_rc (agents-vault --silent 2>$rerr; echo $status) +popd >/dev/null +set -l real_err (cat $rerr) +set -l real_new_slug git.rootiest.dev-rootiest-realdir +check "real-directory migration succeeds" 0 "$real_rc" +check "real-directory migration stays silent" "" "$real_err" +check "real-directory migration keeps banked memory" banked (cat $vroot2/agent-vault/projects/$real_new_slug/claude/memory/old.md 2>/dev/null) +check "real-directory migration keeps live memory" written-live (cat $vroot2/agent-vault/projects/$real_new_slug/claude/memory/fresh.md 2>/dev/null) +check "real-directory migration relinks" true (test -L $croot2/$rmangled/memory; and echo true; or echo false) +check "real-directory old entry removed" false (test -d $vroot2/agent-vault/projects/$real_local_slug; and echo true; or echo false) + set -e __fish_agent_vault_dir set -e __fish_agent_vault_claude_root @@ -699,6 +747,30 @@ printf 'transcript\n' >$agy5/knowledge/session.jsonl : >$agy5/knowledge/conversations.db-wal : >$agy5/knowledge/conversations.db-shm +# Symlinks inside the store. The extension allowlist bounds what *kind* of +# file is collected; it says nothing about whose. A store-relative walk +# that dereferenced links would pull qualifying .md and .json out of +# whatever the link names -- a real home directory is full of them -- so +# the store boundary has to hold on its own. $outside5 stands in for that +# home: a directory the store has no business reaching into, planted with +# exactly the shapes that qualify. +set -l outside5 (mktemp -d); set -ga TMPDIRS $outside5 +mkdir -p $outside5/nested +echo SECRET-OUTSIDE-KNOWLEDGE >$outside5/leaked.json +echo SECRET-OUTSIDE-KNOWLEDGE >$outside5/target.md +echo SECRET-OUTSIDE-KNOWLEDGE >$outside5/nested/deep.md +ln -s $outside5 $agy5/knowledge/linked +ln -s $outside5/target.md $agy5/knowledge/alias.md +# Not a cycle, so a recursive glob's cycle guard does not catch it: a link +# to the filesystem root simply makes the walk enormous. This one is here +# for the clock as much as for the contents -- the copy runs synchronously +# in front of every agent launch, and a `**` glob over this fixture did +# not return within 20s. +ln -s / $agy5/knowledge/root +# A genuine cycle too, since the walk must not depend on the glob's guard. +mkdir -p $agy5/knowledge/cyc +ln -s $agy5/knowledge $agy5/knowledge/cyc/loop + # 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* @@ -707,9 +779,11 @@ mkdir -p $chome5/memory echo global-memory >$chome5/memory/g.md set -l gp (new_repo https://git.rootiest.dev/rootiest/globals.git) +set -l t5_start (date +%s) pushd $gp >/dev/null agents-vault --silent popd >/dev/null +set -l t5_elapsed (math (date +%s) - $t5_start) check "agy knowledge copied" learned (cat $vroot5/agent-vault/global/agy/knowledge/fact.md) check "agy settings copied" '{"model":"x"}' (cat $vroot5/agent-vault/global/agy/settings.json) @@ -726,9 +800,27 @@ 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) + +# Symlinks: nothing the links name may appear, under any name. The linked +# directory must not exist in the vault at all (following it would recreate +# its tree wholesale), and the aliased file must not exist either, even +# though its own name qualifies -- a dereferencing copy writes a real file +# at the link's name and the extension rule waves it through. +for escapee in linked linked/leaked.json linked/nested/deep.md alias.md root cyc/loop + check "knowledge: symlinked $escapee stayed out" false (test -e $vroot5/agent-vault/global/agy/knowledge/$escapee; and echo true; or echo false) +end +check "knowledge: nothing outside the store reached a commit" false (git -C $vroot5/agent-vault grep -q SECRET-OUTSIDE-KNOWLEDGE HEAD 2>/dev/null; and echo true; or echo false) +# The clock, not the contents: a walk that descends a link to / does not +# finish, and this is the every-launch path. Generous enough that a loaded +# machine cannot fail it by being slow. +check "knowledge: a link to / does not stall the launch path" true (test $t5_elapsed -lt 20; 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) +# Both stash fallbacks, or a crash mid-rename leaves a shadow copy of an +# entry sitting at the vault root for the next `git add -A` to commit. +check "scaffolded .gitignore excludes /.adopt-stash" true (grep -qxF '/.adopt-stash' $vroot5/agent-vault/.gitignore; and echo true; or echo false) +check "scaffolded .gitignore excludes /.migrate-stash" true (grep -qxF '/.migrate-stash' $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) @@ -1369,6 +1461,33 @@ popd >/dev/null set -e __fish_agent_vault_autopush check "failing autopush returns non-zero" 1 "$fp2rc" +# Autopush runs synchronously in front of every agent launch, so it must be +# bounded. Nothing in git bounds it: there is no HTTP connect timeout in +# its configuration, http.lowSpeedLimit/http.lowSpeedTime only start +# counting once bytes move, and GIT_TERMINAL_PROMPT/GIT_ASKPASS close the +# credential prompt rather than the socket. Unwrapped, this remote took +# 135s to give up. 192.0.2.1 is TEST-NET-1: reserved, unrouted, and +# therefore a blackhole rather than a fast refusal. A network that does +# refuse it quickly makes this pass without proving much, which is the +# right way round for a test that must never fail spuriously. +# +# This test costs its own bound in wall time. That is the price of +# measuring a timeout, and this defect -- a network call on the launch +# path -- has now been introduced twice. +set -l blackhole https://192.0.2.1/vault.git +agents-vault --remote=$blackhole --silent +echo pushed-into-the-void >$croot11/$pmang/memory/p6.md +set -g __fish_agent_vault_autopush 1 +set -l bh_start (date +%s) +pushd $pp >/dev/null +set -l bhrc (agents-vault --silent 2>/dev/null; echo $status) +popd >/dev/null +set -l bh_elapsed (math (date +%s) - $bh_start) +set -e __fish_agent_vault_autopush +check "autopush to a blackholed remote returns non-zero" 1 "$bhrc" +check "autopush to a blackholed remote is bounded" true (test $bh_elapsed -lt 60; and echo true; or echo false) +check "a bounded autopush still committed locally" true (git -C $vroot11/agent-vault ls-files --error-unmatch projects/$pslug/claude/memory/p6.md >/dev/null 2>&1; 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 -- 2.54.0 From f9d400699f610ded0ef5b998abdc262d71f7b5c3 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Thu, 3 Sep 2026 18:55:14 -0400 Subject: [PATCH 19/19] fix(agents-vault): keep the user's ssh command and drop the quadratic walk The connect bound was delivered by injecting GIT_SSH_COMMAND, and an environment variable outranks git's core.sshCommand -- so the guard, which read only the environment, did not merely miss a configured ssh command, it overruled one. A vault remote reachable only as `ssh -i ~/.ssh/vault_key` failed to authenticate on every push, autopush and --push alike, for the sake of a ten-second timeout. Both spellings now count, and `set -qx` rather than `set -q` on the environment side so an unexported fish variable -- which git never sees -- does not leave the push with neither the user's ssh command nor a bound. The agy knowledge walk appended each find with `set -a`, which rewrites the whole variable every time; 500 files cost 21ms but 20,000 cost 58s, on a path that runs in front of every agent launch. The walk now prints NUL-separated and the list is built once, which is flat: the same 20,000 files take 707ms. NUL rather than newline because a filename may legally contain one. What the walk collects, and its symlink and dot-led semantics, are byte-for-byte unchanged. Autopush is bounded by timeout(1) alone, so without it the launch path was quietly back to an open-ended network call. It now says so and skips the push instead; --push was never wrapped and is unaffected. --- functions/agents-vault.fish | 105 +++++++++++++++++---- tests/test-agents-vault.fish | 178 +++++++++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+), 17 deletions(-) diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish index 9193c65..02330f1 100644 --- a/functions/agents-vault.fish +++ b/functions/agents-vault.fish @@ -139,7 +139,17 @@ # seconds, since git has no connect timeout of its own and an # unreachable remote otherwise blocks for minutes. An explicit --push # is left uncapped: it is watched, and it must report what a real -# transfer really did. +# transfer really did. The cap is timeout(1); on a system that somehow +# lacks it, autopush says so on stderr and does not push at all, since +# an unbounded network call in front of a launch is the one outcome the +# cap exists to prevent. --push still works there. +# +# Over ssh the cap is delivered by setting GIT_SSH_COMMAND, which would +# silently outrank the user's own configuration -- so it is not set at +# all when GIT_SSH_COMMAND is already exported or git's core.sshCommand +# is configured. A vault remote reachable only through a particular +# identity file or ssh wrapper therefore keeps it, uncapped, rather than +# failing to authenticate for the sake of a timeout. # # --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 @@ -654,20 +664,36 @@ function agents-vault --description 'track curated agent memory in a host-scoped # it names a file or a directory. The knowledge store's own root # may still be a link -- that one is the configured location of the # store rather than something found inside it. - set -l kfiles - set -l kdirs "$knowledge" - while set -q kdirs[1] - set -l dir $kdirs[1] - set -e kdirs[1] - for e in $dir/* - test -L "$e"; and continue - if test -d "$e" - set -a kdirs "$e" - else if string match -qr '\.(md|json)$' -- "$e" - set -a kfiles "$e" + # + # The walk prints its finds and the list is built once from that + # output, rather than appending each path to a list as it goes. + # `set -a` rewrites the whole variable every time, so appending n + # paths one at a time costs O(n^2) copying -- 500 files took 21ms + # and 20,000 took 58s on this machine, on a path that runs in + # front of every agent launch. Printing is flat: the same 20,000 + # files take 756ms, and 500 take 13ms. Batching per directory does + # not help, because a knowledge store is mostly one flat directory + # and that is exactly where the growing list lives. + # + # NUL separators and `string split0`, not newlines: a filename may + # legally contain a newline, and splitting on one would saw such a + # path into two entries and copy neither. NUL is the one byte a + # path cannot hold, so the round trip is lossless. + set -l kfiles (begin + set -l kdirs "$knowledge" + while set -q kdirs[1] + set -l dir $kdirs[1] + set -e kdirs[1] + for e in $dir/* + test -L "$e"; and continue + if test -d "$e" + set -a kdirs "$e" + else if string match -qr '\.(md|json)$' -- "$e" + printf '%s\0' "$e" + end end end - end + end | string split0) set -l kfailed 0 for f in $kfiles test -f "$f"; or continue @@ -887,6 +913,16 @@ function agents-vault --description 'track curated agent memory in a host-scoped # The directory case needs no removal anyway: # _agents_repo_ensure_symlink copies a populated live directory # into the vault without clobbering before it replaces it. + # + # A stray *regular* file at $live is left alone too, and that is + # deliberate rather than an oversight in the test. Nothing this + # function created is a plain file there, so whatever it is came + # from the user or from something else writing to the same path, + # and deleting it unasked would destroy data to make room for a + # symlink. _agents_repo_ensure_symlink refuses the path and says + # why, and the launch stops until someone looks -- the same + # answer the global-memory block gives for the same shape of + # surprise. test -L "$live"; and rm -f "$live" set changed 1 test $verbose -eq 1; and echo "$c_ok→ Migrated vault entry $prev_slug → $slug$c_reset" @@ -965,6 +1001,20 @@ function agents-vault --description 'track curated agent memory in a host-scoped if set -q __fish_agent_vault_autopush; and test "$__fish_agent_vault_autopush" = 1 set do_push 1 end + # timeout(1) is the only thing bounding autopush, and an unbounded + # network call in front of an agent launch is the exact block this + # design exists to remove -- so without it, autopush does not happen + # at all rather than happening open-endedly. Nothing is lost that was + # not already local: the commit has landed, and `agents-vault --push` + # still sends it by hand, deliberately unbounded because the user is + # watching that one. It says so rather than skipping quietly, because + # a vault that stopped leaving the machine must never look like one + # that did not. timeout ships with coreutils, so this is a guard + # against the impossible-until-it-happens, not a real dependency. + if test $do_push -eq 1; and not set -q _flag_push; and not type -q timeout + echo "$c_warn""agents-vault: timeout is unavailable, so autopush cannot be bounded; the vault is committed locally but not pushed$c_reset" >&2 + set do_push 0 + end if test $do_push -eq 1 if git -C "$vault" remote get-url origin >/dev/null 2>&1 # The pull belongs here and nowhere earlier. Fetching is only @@ -993,9 +1043,28 @@ function agents-vault --description 'track curated agent memory in a host-scoped # ssh is the one transport that can time itself out, so it is # told to, unless the user has already said how to run ssh. # Everything else is bounded from outside with timeout(1). + # + # "Already said" has two spellings and both have to count. + # GIT_SSH_COMMAND is the obvious one; core.sshCommand is the + # documented place to name an identity file or an ssh wrapper, + # and it is the one a vault on a private host is most likely to + # need. An injected environment variable outranks the config, + # so consulting only the environment does not merely miss the + # user's setting -- it overrides it, and a remote reachable + # only as `ssh -i ~/.ssh/vault_key` then fails to authenticate + # on every push. A ten-second connect bound is not worth that. + # + # -qx rather than -q on the environment side: a fish variable + # that was never exported satisfies -q but is not in the + # environment git runs in, so treating it as the user's answer + # would leave the push with neither their ssh command nor a + # connect timeout. Only what git can actually see counts, and + # exporting it is the user's call to make, not this function's. set -l gitenv GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=true - set -q GIT_SSH_COMMAND - or set -a gitenv 'GIT_SSH_COMMAND=ssh -o ConnectTimeout=10' + set -l user_ssh (git -C "$vault" config --get core.sshCommand 2>/dev/null) + if not set -qx GIT_SSH_COMMAND; and test -z "$user_ssh" + set -a gitenv 'GIT_SSH_COMMAND=ssh -o ConnectTimeout=10' + end set -l gitnet env $gitenv # Only autopush is wrapped. An explicit --push is a thing the @@ -1008,8 +1077,10 @@ function agents-vault --description 'track curated agent memory in a host-scoped # of an open-ended wait. timeout's own 124 is a non-zero exit # like any other, so a bounded push still reports as failed and # the commit it could not send has already landed locally. A - # push too slow for the bound can always be run by hand. - if not set -q _flag_push; and type -q timeout + # push too slow for the bound can always be run by hand. An + # autopush with no timeout(1) to wrap it never reaches here; + # it was turned off above. + if not set -q _flag_push set gitnet timeout 20 $gitnet end set -l reached 1 diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index e3aa4aa..ef590d0 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -1493,6 +1493,184 @@ 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 bound never overrides the user's ssh ───────────── +# The connect bound is delivered by injecting GIT_SSH_COMMAND, and an +# environment variable outranks core.sshCommand -- so a guard that reads +# only the environment does not merely fail to notice the config, it +# overrules it. A vault reachable only as `ssh -i ~/.ssh/vault_key` then +# fails to authenticate on every push, autopush and --push alike, for the +# sake of a ten-second timeout. Counting ssh invocations is the honest +# measurement here: it asks what git actually ran, not what this function +# meant to arrange. +echo "" +echo "== agents-vault (the user's ssh command wins) ==" + +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 + +# Two fake ssh binaries that record their arguments: one found on PATH as +# plain `ssh` (what the injected default resolves to) and one named +# explicitly by the user. Both refuse the connection, so nothing leaves +# the machine and no test waits on a network. +set -l sbin (mktemp -d); set -ga TMPDIRS $sbin +set -l pathssh_log $sbin/path-ssh.log +set -l usessh $sbin/user-ssh +set -l usessh_log $sbin/user-ssh.log +printf '#!/bin/sh\nprintf "%%s\\n" "$*" >>%s\nexit 255\n' $pathssh_log >$sbin/ssh +printf '#!/bin/sh\nprintf "%%s\\n" "$*" >>%s\nexit 255\n' $usessh_log >$usessh +chmod +x $sbin/ssh $usessh + +set -l sp14 (new_repo https://git.rootiest.dev/rootiest/sshcmd.git) +set -l smang14 (string replace -a '/' '-' -- $sp14 | string replace -a '.' '-') +mkdir -p $croot14/$smang14/memory +echo s1 >$croot14/$smang14/memory/s1.md +pushd $sp14 >/dev/null +agents-vault --remote='ssh://git@example.invalid/vault.git' --silent >/dev/null 2>&1 +popd >/dev/null + +set -l realpath14 $PATH +set -g PATH $sbin $PATH + +# Runs one autopush (or an explicit --push) with a fresh memory file, so +# there is always a commit worth pushing, and reports nothing itself. +function ssh_run --argument-names memroot proj name mode + rm -f $argv[5..] + echo $name >$memroot/memory/$name.md + test "$mode" = auto; and set -g __fish_agent_vault_autopush 1 + pushd $proj >/dev/null + if test "$mode" = auto + agents-vault --silent >/dev/null 2>&1 + else + agents-vault --push --silent >/dev/null 2>&1 + end + popd >/dev/null + set -e __fish_agent_vault_autopush +end + +git -C $vroot14/agent-vault config core.sshCommand $usessh +ssh_run $croot14/$smang14 $sp14 s2 auto $usessh_log $pathssh_log +check "autopush runs the user's core.sshCommand" true (test -s $usessh_log; and echo true; or echo false) +ssh_run $croot14/$smang14 $sp14 s3 push $usessh_log $pathssh_log +check "--push runs the user's core.sshCommand" true (test -s $usessh_log; and echo true; or echo false) + +# With nothing configured either way the bound is still applied -- the +# fix must not have simply removed it. +git -C $vroot14/agent-vault config --unset core.sshCommand +ssh_run $croot14/$smang14 $sp14 s4 auto $usessh_log $pathssh_log +check "an unconfigured ssh still gets a connect bound" true (string match -q '*ConnectTimeout=10*' -- (cat $pathssh_log 2>/dev/null); and echo true; or echo false) + +# A fish variable that was never exported satisfies `set -q` but is not in +# git's environment, so treating it as the user's answer would leave the +# push with neither their ssh command nor a bound. +set -g GIT_SSH_COMMAND $usessh +ssh_run $croot14/$smang14 $sp14 s5 auto $usessh_log $pathssh_log +set -e GIT_SSH_COMMAND +check "an unexported GIT_SSH_COMMAND does not suppress the bound" true (string match -q '*ConnectTimeout=10*' -- (cat $pathssh_log 2>/dev/null); and echo true; or echo false) + +# An exported one is git's already and is passed through untouched. +set -gx GIT_SSH_COMMAND "$usessh -o Marker=yes" +ssh_run $croot14/$smang14 $sp14 s6 auto $usessh_log $pathssh_log +set -e GIT_SSH_COMMAND +check "an exported GIT_SSH_COMMAND is used verbatim" true (string match -q '*Marker=yes*' -- (cat $usessh_log 2>/dev/null); and echo true; or echo false) +check "an exported GIT_SSH_COMMAND is not overridden" false (string match -q '*ConnectTimeout*' -- (cat $usessh_log 2>/dev/null); and echo true; or echo false) + +set -g PATH $realpath14 +functions -e ssh_run +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 + +# ─────────────── autopush without timeout(1) does not run ────────────── +# timeout(1) is the only thing bounding autopush, so without it the launch +# path would be back to an open-ended network call -- the exact block this +# design exists to remove. The push is skipped and says so instead. An +# explicit --push is deliberately unaffected: it was never wrapped. +# +# The PATH here is the real one with every directory that provides a +# timeout replaced by a symlink farm of itself missing that one entry, so +# everything else agents-vault shells out to still resolves normally. +echo "" +echo "== agents-vault (autopush without timeout) ==" + +set -l vroot15 (mktemp -d); set -ga TMPDIRS $vroot15 +set -l croot15 (mktemp -d); set -ga TMPDIRS $croot15 +set -l chome15 (mktemp -d); set -ga TMPDIRS $chome15 +set -l agy15 (mktemp -d); set -ga TMPDIRS $agy15 +set -l bare15 (mktemp -d); set -ga TMPDIRS $bare15 +git init -q --bare $bare15 +set -g __fish_agent_vault_dir $vroot15/agent-vault +set -g __fish_agent_vault_claude_root $croot15 +set -g __fish_agent_vault_claude_home $chome15 +set -g __fish_agent_vault_agy_root $agy15 + +set -l tp15 (new_repo https://git.rootiest.dev/rootiest/notimeout.git) +set -l tslug15 git.rootiest.dev-rootiest-notimeout +set -l tmang15 (string replace -a '/' '-' -- $tp15 | string replace -a '.' '-') +mkdir -p $croot15/$tmang15/memory +echo t1 >$croot15/$tmang15/memory/t1.md +pushd $tp15 >/dev/null +agents-vault --remote=$bare15 --silent >/dev/null 2>&1 +popd >/dev/null +# symbolic-ref, not rev-parse: the vault branch is still unborn here and +# rev-parse would report the literal string HEAD with a fatal on stderr. +set -l vb15 (git -C $vroot15/agent-vault symbolic-ref --short HEAD) + +set -l shimroot (mktemp -d); set -ga TMPDIRS $shimroot +set -l nopath +set -l shimn 0 +for d in $PATH + test -d "$d"; or continue + if test -x "$d/timeout" + set shimn (math $shimn + 1) + mkdir -p $shimroot/$shimn + command cp -rs "$d/." $shimroot/$shimn/ 2>/dev/null + rm -f $shimroot/$shimn/timeout + set -a nopath $shimroot/$shimn + else + set -a nopath $d + end +end +set -l realpath15 $PATH + +echo t2 >$croot15/$tmang15/memory/t2.md +set -l terr (mktemp); set -ga TMPDIRS $terr +set -g __fish_agent_vault_autopush 1 +set -g PATH $nopath +check "the shimmed PATH really has no timeout" false (type -q timeout; and echo true; or echo false) +pushd $tp15 >/dev/null +set -l trc (agents-vault --silent 2>$terr; echo $status) +popd >/dev/null +set -g PATH $realpath15 +set -e __fish_agent_vault_autopush +check "an unboundable autopush is skipped, not run" false (git -C $bare15 cat-file -e $vb15:projects/$tslug15/claude/memory/t2.md 2>/dev/null; and echo true; or echo false) +check "a skipped autopush says why" true (string match -q '*timeout is unavailable*' -- (cat $terr); and echo true; or echo false) +check "a skipped autopush still committed locally" true (git -C $vroot15/agent-vault ls-files --error-unmatch projects/$tslug15/claude/memory/t2.md >/dev/null 2>&1; and echo true; or echo false) +# Not a failure: nothing was attempted and failed, and the one thing the +# user did not ask for -- a hang in front of an agent launch -- did not +# happen. --push is where a caller demands a real transfer answer. +check "a skipped autopush is not a failure" 0 "$trc" + +echo t3 >$croot15/$tmang15/memory/t3.md +set -g PATH $nopath +pushd $tp15 >/dev/null +set -l t3rc (agents-vault --push --silent 2>/dev/null; echo $status) +popd >/dev/null +set -g PATH $realpath15 +check "--push still pushes without timeout" 0 "$t3rc" +check "--push landed in the remote without timeout" t3 (git -C $bare15 show $vb15:projects/$tslug15/claude/memory/t3.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 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 -- 2.54.0