feat(agents-vault): back up curated agent memory to a host-scoped vault repo #126

Merged
rootiest merged 19 commits from feat/agent-memory-vault into main 2026-09-03 23:09:57 +00:00
Owner

Summary

Adds agents-vault, a host-scoped git repo that backs up curated agent memory living outside any project tree. agents-init already tracks AGENTS.md/CLAUDE.md inside the repo, but the memories that matter most live under ~/.claude and were backed up by nothing.

  • functions/agents-vault.fish — the tool. Per project, projects/<slug>/claude/memory in the vault is the real directory and ~/.claude/projects/<mangled>/memory becomes a symlink into it, so backup and restore are the same code path. Modes: no-flag (scaffold + link + commit), --link, --status, --restore, --adopt=SLUG, --remote=URL, --push, plus -v/-q/-s.
  • Slugs come from the normalized git remote URL, so an entry is machine-independent and survives a re-clone to a different path. Projects without a remote fall back to local-<basename>-<8hex>, and the entry migrates automatically if a remote is added later.
  • Five shared helpers (_agents_repo_slug, _agents_repo_local_slug, _agents_repo_ensure_symlink, _agents_repo_sync, _agents_repo_install_tools) extracted from agents-init, which is refactored onto them.
  • Global state too: ~/.claude/memory is symlinked into the vault; the agy knowledge store is copied (not linked, since agy rewrites it wholesale).
  • Wired into the claude and agy wrappers behind the C1 guard, so it syncs on every launch.

The vault is allowlist-only, never denylist. ~/.claude and the agy root also hold .credentials.json, history.jsonl, sessions/, session-env/, shell-snapshots/, and conversation DBs with -wal/-shm sidecars. Only memory directories and an explicit extension allowlist are ever copied, so nothing an upstream release adds can leak into a repo that may be pushed.

Why

Recovering from the loss of a machine meant rebuilding memory that existed only in session history — it was never in the tracked AGENTS.md. A vault with a remote turns that from a rebuild into a clone.

How it works

Because the live memory directory is a symlink into the vault, restore is emergent rather than a separate mechanism: cloning the vault onto a new machine and running agents-vault in a project re-points the link at the recovered content. --restore is a convenience that walks every entry; --adopt rebinds an orphaned entry to the current project.

Behavior

Launch commits but never pushes, keeping the network and any credential prompt off the critical path — measured at ~14 ms per launch with no remote configured, and the launch path makes zero network git calls. Pushing is explicit (--push) or opt-in (__fish_agent_vault_autopush=1), and autopush is bounded at 20 s so a dead remote cannot stall a launch.

Opinionated guard (C1)

Both wrappers stay inside the existing __fish_config_op_enabled gate. With __fish_config_op_aliases disabled, claude and agy pass straight through to the real binary and the vault never runs.

Notes

Deliberately deferred, and tracked locally rather than gating this PR:

  • The SessionEnd hook is not installed. ~/.claude/settings.json is outside the repo. Until it exists, nothing leaves the machine except via an explicit agents-vault --push.
  • No live end-to-end has been run. Every test so far is hermetic against a throwaway $HERMETIC_HOME; relinking real memory from an unmerged branch was not worth the risk. To be done after merge.
  • The agy copy never prunes. A file deleted from the knowledge store stays in the vault and is re-committed. No vault exists yet, so nothing is affected today.
  • agents-init no longer pulls automatically. Removed with the launch-path network fix: GIT_TERMINAL_PROMPT=0 closes the credential prompt but not the TCP connect timeout (measured 135 s against a blackholed address with it set), so a "non-fatal pull" would still hang every launch. Neither local AGENTS/ sub-repo has a remote, so nothing is currently affected. If wanted back, the right shape is an opt-in flag or a backgrounded fetch — not a synchronous pull.

Verification

  • fish tests/run-tests.fish — passes end to end, exit 0: lint 226/226, functional 13/13, vault 317/317. The vault suite is added to the runner as Phase 3 and its failure genuinely affects the overall exit status.
  • python3 docs/verify-manual.py73/74. The sole failure is test_concat_roundtrips_original, because docs/fish-config.md is a tracked generated file that CI regenerates and auto-commits on push (see the ordering comment in .github/workflows/ci.yml). No other failure.
  • fish -n and fish_indent clean on every changed file (covered by the lint phase).
  • Allowlist audit. ~20 sensitive files (.credentials.json, history.jsonl, sessions/, session-env/, shell-snapshots/, *.db + -wal/-shm) planted at the agy root, at two depths inside knowledge/, in the claude home, and beside per-project memory — plus six symlink escapes pointing back at the agy root and the claude home. Result: only legitimate files tracked, and zero planted content in any blob in the entire object database, not just HEAD.
  • Offline behavior. With an unreachable remote configured, memory is still committed locally and the launch is not blocked (~0.08 s). Verified for both agents-vault and agents-init.
  • --adopt path traversal. ../../../etc, /etc/passwd, a/b, ., .., foo/../bar, and empty are all rejected with exit 1, vault untouched.
  • Restore from a real git clone. A vault is built by running the tool, committed, then cloned; agents-vault against the clone relinks memory correctly, including onto the origin-only entry shape that git produces for an entry whose memory was empty.
  • C1 guard off. With __fish_config_op_aliases=0, claude and agy pass through to the real binary and no vault is created.
  • Confirm CI is green on this branch (the docs job regenerates docs/fish-config.md, docs/fish-config.1, and the component registry, and auto-commits them).
## Summary Adds `agents-vault`, a host-scoped git repo that backs up curated agent memory living **outside** any project tree. `agents-init` already tracks `AGENTS.md`/`CLAUDE.md` inside the repo, but the memories that matter most live under `~/.claude` and were backed up by nothing. - **`functions/agents-vault.fish`** — the tool. Per project, `projects/<slug>/claude/memory` in the vault is the **real** directory and `~/.claude/projects/<mangled>/memory` becomes a **symlink** into it, so backup and restore are the same code path. Modes: no-flag (scaffold + link + commit), `--link`, `--status`, `--restore`, `--adopt=SLUG`, `--remote=URL`, `--push`, plus `-v`/`-q`/`-s`. - **Slugs come from the normalized git remote URL**, so an entry is machine-independent and survives a re-clone to a different path. Projects without a remote fall back to `local-<basename>-<8hex>`, and the entry migrates automatically if a remote is added later. - **Five shared helpers** (`_agents_repo_slug`, `_agents_repo_local_slug`, `_agents_repo_ensure_symlink`, `_agents_repo_sync`, `_agents_repo_install_tools`) extracted from `agents-init`, which is refactored onto them. - **Global state too:** `~/.claude/memory` is symlinked into the vault; the agy knowledge store is copied (not linked, since agy rewrites it wholesale). - **Wired into the `claude` and `agy` wrappers** behind the C1 guard, so it syncs on every launch. The vault is **allowlist-only, never denylist**. `~/.claude` and the agy root also hold `.credentials.json`, `history.jsonl`, `sessions/`, `session-env/`, `shell-snapshots/`, and conversation DBs with `-wal`/`-shm` sidecars. Only memory directories and an explicit extension allowlist are ever copied, so nothing an upstream release adds can leak into a repo that may be pushed. ## Why Recovering from the loss of a machine meant rebuilding memory that existed only in session history — it was never in the tracked `AGENTS.md`. A vault with a remote turns that from a rebuild into a clone. ## How it works Because the live memory directory *is* a symlink into the vault, restore is emergent rather than a separate mechanism: cloning the vault onto a new machine and running `agents-vault` in a project re-points the link at the recovered content. `--restore` is a convenience that walks every entry; `--adopt` rebinds an orphaned entry to the current project. ## Behavior Launch **commits but never pushes**, keeping the network and any credential prompt off the critical path — measured at ~14 ms per launch with no remote configured, and the launch path makes zero network git calls. Pushing is explicit (`--push`) or opt-in (`__fish_agent_vault_autopush=1`), and autopush is bounded at 20 s so a dead remote cannot stall a launch. ## Opinionated guard (C1) Both wrappers stay inside the existing `__fish_config_op_enabled` gate. With `__fish_config_op_aliases` disabled, `claude` and `agy` pass straight through to the real binary and the vault never runs. ## Notes Deliberately deferred, and tracked locally rather than gating this PR: - **The `SessionEnd` hook is not installed.** `~/.claude/settings.json` is outside the repo. Until it exists, nothing leaves the machine except via an explicit `agents-vault --push`. - **No live end-to-end has been run.** Every test so far is hermetic against a throwaway `$HERMETIC_HOME`; relinking real memory from an unmerged branch was not worth the risk. To be done after merge. - **The agy copy never prunes.** A file deleted from the knowledge store stays in the vault and is re-committed. No vault exists yet, so nothing is affected today. - **`agents-init` no longer pulls automatically.** Removed with the launch-path network fix: `GIT_TERMINAL_PROMPT=0` closes the credential prompt but not the TCP connect timeout (measured 135 s against a blackholed address with it set), so a "non-fatal pull" would still hang every launch. Neither local `AGENTS/` sub-repo has a remote, so nothing is currently affected. If wanted back, the right shape is an opt-in flag or a backgrounded fetch — not a synchronous pull. ## Verification - [x] `fish tests/run-tests.fish` — passes end to end, exit 0: lint **226/226**, functional **13/13**, vault **317/317**. The vault suite is added to the runner as Phase 3 and its failure genuinely affects the overall exit status. - [x] `python3 docs/verify-manual.py` — **73/74**. The sole failure is `test_concat_roundtrips_original`, because `docs/fish-config.md` is a tracked generated file that CI regenerates and auto-commits on push (see the ordering comment in `.github/workflows/ci.yml`). No other failure. - [x] `fish -n` and `fish_indent` clean on every changed file (covered by the lint phase). - [x] **Allowlist audit.** ~20 sensitive files (`.credentials.json`, `history.jsonl`, `sessions/`, `session-env/`, `shell-snapshots/`, `*.db` + `-wal`/`-shm`) planted at the agy root, at two depths inside `knowledge/`, in the claude home, and beside per-project memory — plus six symlink escapes pointing back at the agy root and the claude home. Result: only legitimate files tracked, and zero planted content in **any blob in the entire object database**, not just `HEAD`. - [x] **Offline behavior.** With an unreachable remote configured, memory is still committed locally and the launch is not blocked (~0.08 s). Verified for both `agents-vault` and `agents-init`. - [x] **`--adopt` path traversal.** `../../../etc`, `/etc/passwd`, `a/b`, `.`, `..`, `foo/../bar`, and empty are all rejected with exit 1, vault untouched. - [x] **Restore from a real `git clone`.** A vault is built by running the tool, committed, then cloned; `agents-vault` against the clone relinks memory correctly, including onto the origin-only entry shape that git produces for an entry whose memory was empty. - [x] **C1 guard off.** With `__fish_config_op_aliases=0`, `claude` and `agy` pass through to the real binary and no vault is created. - [x] Confirm CI is green on this branch (the docs job regenerates `docs/fish-config.md`, `docs/fish-config.1`, and the component registry, and auto-commits them).
rootiest added 19 commits 2026-09-03 23:02:34 +00:00
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.
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.
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.
_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.
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.
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.
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.
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).
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.
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.
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.
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.
--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.
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.
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.
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.
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/<slug>/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).
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.
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.
rootiest added the Area/FunctionsKind/Enhancement labels 2026-09-03 23:09:04 +00:00
rootiest merged commit 3c967c0cee into main 2026-09-03 23:09:57 +00:00
rootiest deleted branch feat/agent-memory-vault 2026-09-03 23:09:57 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: rootiest/fish-config#126