Skip to content

Address the memory by repository, so a worktree has one - #96

Merged
spscream merged 1 commit into
mainfrom
fix/am/memory-in-worktrees
Sep 25, 2026
Merged

spscream merged 1 commit into
mainfrom
fix/am/memory-in-worktrees

Conversation

@spscream

Copy link
Copy Markdown
Owner

Why

A git worktree of a correctly wired repository arrived with no memory.

The memory was reached through <repo>/<memory_dir> — a gitignored symbolic
link — and git carries neither ignored paths nor that link into a worktree. So
in every worktree:

$ run lint
no .agent-memory/MEMORY.md in <worktree> — this repository does not use this memory layout
rc=2

$ run check docs/statuses/NOW.md
-- memory
  MEMORY LINT COULD NOT RUN — it refused before checking anything:
    no .agent-memory/MEMORY.md in <worktree> — this repository does not use this memory layout
rc=1

$ run commit --no-push -m "…" docs/statuses/NOW.md
-- gates
  memory lint is red — run check with your file list
rc=1

commit never reached the file-list gate. The corpus was on the machine,
complete, and unreachable from the tree the session was working in. The
memory store section of check and guard did not appear at all, so nothing
in the output said the memory existed elsewhere.

What changed

The address is computed, not stored. A repository whose .floppy/config
carries public_repo and a key resolves its memory to
<agents_memory_dir>/<key>/shared. No file and no symbolic link of that name is
created in a working copy at all. store runs once per machine; a worktree
needs no step of its own.

The key is project_key from .floppy/config. Three derived identifiers
were measured against a real worktree and its checkout, and all three agree from
both sides — rev-parse --path-format=absolute --git-common-dir, remote get-url origin, and the root commit. All three were rejected anyway, for what
they do when the world moves rather than for what they answer today: the
git-common-dir path moves with the checkout, so a mv of the repository orphans
the memory; origin is empty in a repository with no remote, which is every
test sandbox and many clones; the root commit does not exist until the first
commit, which is the state init runs in. project_key is tracked, so every
worktree of a repository computes the same one, and it survives both a move and
a change of remote.

The configuration decides, not the file system. The first draft resolved
into the cache only when nothing stood at memory_dir, which reads as caution
and is not: the skills of this same plugin tell an agent to write
.agent-memory/<file>, and one note from an agent that followed them recreated
the directory, took the address back, and reproduced the failure above. The same
trap is reachable without any mistake, through the plugin's own
statuses_personal default. A real directory in that position is now a fork of
the corpus: wrap-guard refuses while it stands and names the verb that moves
it.

Migration is mandatory, explicit, and never automatic. store --migrate
prints every file before it moves any, refuses the whole run on a name that
exists on both sides, refuses outright when <memory_dir>/private or
<memory_dir>/common is a real directory — those notes belong to another
repository, and moving them into the public store would publish them — and
deletes nothing, including the directory it empties. Nothing calls this flag:
not a verb, not a rite, not init.

The harness pointer is made for you. The session loader reads
<config>/projects/<encoded cwd>/memory, one per working directory, and that
path is the harness's to choose. Addressing by repository cannot close that
half, so the config parser creates the pointer on any verb, announces it once on
stderr, repairs it when it dangles, and is skipped entirely for --check.

A hand-wired repository is not left behind. One with a memory_dir pointed
at a store by hand has no public_repo to compose a cache path from; its
worktrees ask the main working copy of the same clone, which is where that link
was made. That fallback needs git 2.31 and is documented as such.

Two repositories, two scopes. The public scope commits from the public
store and the private one from the workplace store. Measured from a fresh
worktree: FLOPPY_MEMORY_STORE and FLOPPY_PRIVATE_STORE are two distinct
clones, and git add -A in the code worktree stages zero memory paths.

How verified

  • The mandatory acceptance. A worktree cut with git worktree add from
    main, zero manual verbs run in it, then lint → check → guard →
    commit. All four are green, check prints the memory store section, and
    commit passes the file-list gate and commits. The same four verbs against
    the parent commit, on the same worktree shape, reproduce the four failures
    quoted at the top.
  • The suite: 1105 assertions across 25 files, 0 failed, on /bin/bash.
    knowledge-recheck.py: 9 passed, 0 failed, 3 skipped, 14 not
    machine-checkable. translation-check.py: clean.
  • New coverage. tests/test-worktree-memory.sh (37 assertions) runs the
    whole rite from a real git worktree add fixture with no manual verbs, and
    carries a case per defect found in review. tests/test-memory-migrate.sh (45)
    covers the migration, including the private-scope refusal and a name with
    spaces. tests/test-wrap-lock.sh no longer hand-creates the symbolic link a
    real worktree does not have — that line was the coverage hole this defect
    lived in.
  • Each fix has a negative control. Reverting the one hunk fails the one
    assertion and no other.
  • tests/lib.sh now unsets AI_FLOPPY_HOME, CLAUDE_PLUGIN_ROOT and
    CURSOR_PLUGIN_ROOT. With it exported in the shell, init's own subcall
    reached the developer's checkout rather than the fixture, and a test asserting
    the old symbolic link passed against code that no longer creates one.

What is out, and what was risked

  • No version bump and no CHANGELOG entry. A parallel task edits the same
    release-adjacent files, and this would be a guaranteed conflict for no gate:
    tests/test-release.sh asserts only that the three manifests agree with each
    other. Whoever ships this should bump them together and answer "Refresh
    .floppy/run?" — the answer is no, the shim is unchanged.
  • scripts/run and scripts/init.sh are touched, though a parallel task
    owns them. Two lines each and neither is avoidable: run is the only place
    the arguments are visible, so it is where --check can exempt itself from
    creating a pointer; and init wrote MEMORY.md into an ignored directory in
    the tree, which is the stranded corpus this change exists to prevent.
  • wrap-guard's un-ignored-memory check keeps its narrowing. Restoring it
    to fire unconditionally would print "nothing will ever commit these notes",
    which is now false — the notes go to the cache and commit from the store. The
    dangerous state it was aimed at is caught by the new stranded-directory gate
    instead.
  • Nothing is deleted and nothing is moved without being asked. A symbolic
    link from an earlier release still resolves and everything follows it; it is
    reported and left standing. A real directory is refused, not emptied. The
    principle in memory-store.sh — this script does not decide the fate of
    memory — is intact.

A git worktree of a correctly wired repository arrived with no memory. The
memory was reached through <repo>/<memory_dir>, a gitignored symlink, and git
carries neither ignored files nor symlinks into a worktree — so `lint` exited 2
with "this repository does not use this memory layout", `check` printed MEMORY
LINT COULD NOT RUN, and `commit` stopped at "memory lint is red" before it ever
reached the file-list gate. The whole corpus was on the machine, complete, and
unreachable from the tree the session was working in.

The address is now computed, not stored. A repository whose .floppy/config
carries public_repo and project_key resolves its memory to
<agents_memory_dir>/<key>/shared, and nothing of that name is created in a
working copy at all. project_key is tracked, so every worktree of a repository
computes the same one; `store` runs once per machine and a worktree needs no
step of its own.

The configuration decides, not the file system. An earlier draft fell back to
the working copy whenever nothing stood at the store's address, which reads as
caution and is not: the skills of this plugin tell an agent to write
.agent-memory/<file>, so one note from an agent that followed them recreated the
directory, took the address back, and put the repository into exactly the state
above. A real directory there is now a fork of the corpus: `wrap-guard` refuses
while it stands, and `store --migrate` moves it, printing every file first and
refusing the whole run on a collision or on a private scope it has no right to
publish.

The harness's own pointer is not addressed by repository and cannot be: it is
<claude-config>/projects/<encoded cwd>/memory, one per working directory. That
half is made automatically by the config parser on any verb, announced once, and
skipped for --check.

Two repositories keep the fence: the public scope commits from the public store
and the private one from the workplace store; `git add -A` in a code worktree
stages no memory path at all.

scripts/run and scripts/init.sh are touched: `run` exports FLOPPY_AUTOLINK=0 for
--check, and `init` lays the memory out at the store address instead of writing
MEMORY.md into an ignored directory in the tree.
@spscream
spscream force-pushed the fix/am/memory-in-worktrees branch from 63ffe14 to 2797b89 Compare September 25, 2026 20:00
@spscream
spscream merged commit 9c65aa6 into main Sep 25, 2026
4 checks passed
spscream added a commit that referenced this pull request Sep 26, 2026
…you point it (#98)

Bump the three version-carrying manifests from 0.26.0 to 0.27.0 and add the
CHANGELOG section the release workflow extracts as the release body.

Minor rather than patch: #96 changes behaviour a consumer relies on. The
memory of a project in a store is addressed by repository, so `store` runs
once per machine instead of once per working copy and `link` stops being a
setup step. Four guide pages already on main say "since 0.27.0" in ten places.

No code: only the version, the changelog entry, and the Cursor plugin manifest
that tests/test-release.sh asserts against the plugin manifest.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant