diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index abc4900..431cb36 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ { "name": "floppy", "description": "Session ritual for agent work: durable memory, a start rite, a wrap rite, and the guards that keep them honest", - "version": "0.25.1", + "version": "0.26.0", "source": "./" } ] diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6078ca8..d15dfd2 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "floppy", "description": "Session ritual for agent work: durable memory, a start rite, a wrap rite, and the guards that keep them honest", - "version": "0.25.1", + "version": "0.26.0", "author": { "name": "Alexander Malaev" }, diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index d02de36..29116a8 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "floppy", "displayName": "Floppy", "description": "Session ritual for agent work: durable memory, a start rite, a wrap rite, and the guards that keep them honest", - "version": "0.25.1", + "version": "0.26.0", "author": { "name": "Alexander Malaev" }, diff --git a/.floppy/config b/.floppy/config index e8089cb..46ae9b2 100644 --- a/.floppy/config +++ b/.floppy/config @@ -4,7 +4,7 @@ memory_dir=.agent-memory memory_language=en # This repository does not hold its own memory: memory_dir is a symlink into -# the store below. Wire it on each machine with "bash .floppy/run store". +# the store below. Wire it on each machine with floppy's "store" verb. public_repo=git@github.com:spscream/ai_floppy_memory.git # One key, not two: project_key is the default of both memory_project_key and # workplace_project_key, so the project is named the same in every store it @@ -23,11 +23,11 @@ private_repo=git@github.com:spscream/agents_memory.git # clone of its own instead of sharing the derived one: a live session of # another project (measured 2026-09-13: vps_inventory) held its half-written # status file dirty in the shared tree at the moment this project's wrap -# synced. A new machine runs "bash .floppy/run workplace" once and gets the +# synced. A new machine runs the "workplace" verb once and gets the # same layout — the leading ~ resolves to that machine's home (0.24.1). workplace_memory_dir=~/agents_memory/.clones/agents_memory--ai_floppy -# commit_push controls what "bash .floppy/run commit" does after committing: +# commit_push controls what the "commit" verb does after committing: # auto (default) pulls --rebase then pushes, same as always. A repository # with no upstream configured fails that every time — set commit_push=never # to keep every commit local instead (the --no-push flag does this per call). @@ -44,7 +44,7 @@ workplace_memory_dir=~/agents_memory/.clones/agents_memory--ai_floppy # may write only the status file. wrap-guard matches these by prefix, so a # nested path needs no code change. watched_dirs=docs/statuses -watched_files=AGENTS.md,.floppy/run,.floppy/config +watched_files=AGENTS.md,.floppy/config # A metric that got worse stays in the current-state file, marked. The mark is # the word the file is written in, and this memory is English. diff --git a/.floppy/run b/.floppy/run deleted file mode 100755 index 9912ce1..0000000 --- a/.floppy/run +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env bash -# The only file of this plugin that lands in a consumer repository, as -# .floppy/run. Since 0.14.0 it does ONE thing: find the plugin and hand the -# call to it. The verb table and the config parser moved into the plugin -# (scripts/run, scripts/lib-config.sh), where `plugin update` delivers them. -# -# Why that shape. This file is a COPY, carried by the consumer's git, and no -# plugin update touches it — so whatever lives here goes stale silently and is -# fixed only when a human remembers to re-copy it. Measured over the first 24 -# commits that touched this file: at least 7 changed only the config parser or -# the verb table. A third of the refreshes it demanded of every consumer bought -# them nothing. Locating the plugin is what cannot move — code that finds the -# plugin cannot itself live in the plugin — and it is also the part whose -# failures are loud: "plugin not found", never a wrong default applied quietly. -# -# Runs on macOS bash 3.2: no mapfile, no declare -A, no GNU-only flags. -set -uo pipefail - -# The plugin cache path carries a version (Claude Code) or a git commit SHA -# (Cursor) in its last segment and changes on every update, so the only -# stable handles across both harnesses are the harness-set variable and an -# explicit override. -# -# Each candidate below only counts if it actually has SCRIPTS in scripts/ — -# a cache entry that exists but is mid-write, or a stale leftover from a -# broken install, must not be trusted just because it is the newest thing on -# disk. The directory alone is not evidence, and testing for it was not -# enough: on 2026-08-25 Claude Code held a cache from a moment when scripts/ -# held only .gitkeep, `claude plugin update` called it "already at the latest -# version" because the version string had not moved, and every verb died with -# "No such file or directory" from inside the resolved root. Gating on at -# least one *.sh makes that candidate fall through to the next layout, or to -# the install hint below — which is what a broken install should produce. -# -# skills/init/SKILL.md duplicates this exact search rather than sourcing it -# from here: `init` runs before this file exists in the target repository -# (creating it is init's own first step), so it cannot read this copy. Keep -# the two in sync by hand if this search ever changes. -has_scripts() { [[ -n "${1:-}" ]] && ls "$1"/scripts/*.sh >/dev/null 2>&1; } -claude_cache="$(ls -d "$HOME"/.claude/plugins/cache/*/floppy/*/ 2>/dev/null | sort -V | tail -n1)" -cursor_local="$HOME/.cursor/plugins/local/floppy" -# Cursor's cache path ends in a git commit SHA, not a version — measured on -# the owner's own machine: -# ~/.cursor/plugins/cache/floppy/floppy/ed18232fd3b616d570a707fb8464b678b8542dbf -# Sorting SHAs with `sort -V` (or any lexicographic sort) orders them by hash -# value, which has no relationship to recency at all: on a machine with two -# cached copies it would silently pick an arbitrary one, quite possibly the -# stale one — exactly the failure mode this whole guard exists to prevent. -# `ls -dt` sorts candidate directories by modification time instead, which -# is what "newest" actually means here, and it does so portably across BSD -# (macOS) and GNU `ls` without touching `stat`'s incompatible flags. Kept as -# a second, separate lookup rather than unified with claude_cache's sort -V: -# a mechanism correct for a version-named directory is not correct for a -# SHA-named one, and unifying them would make one of the two wrong. -cursor_cache="$(ls -dt "$HOME"/.cursor/plugins/cache/*/floppy/*/ 2>/dev/null | head -n1)" - -# A cache path is a guess, and falling through a wrong guess in silence is -# right. A variable somebody SET is not a guess — it states where the plugin -# is — and falling through that in silence is how a call ends up against a -# different copy than the one named. Measured 2026-08-25: AI_FLOPPY_HOME was -# pointed at a directory holding no plugin, the search fell through to the -# Claude Code cache, an older copy answered, and a script that had just been -# fixed was reported as still broken. Nothing in the output said which copy -# had run. -# -# The two variables are not treated alike, because they are not alike. -# AI_FLOPPY_HOME belongs to floppy and nothing else sets it, so a bad value is -# a mistake and this stops. CLAUDE_PLUGIN_ROOT is set by the harness per -# plugin, and a call made from inside some other plugin's skill can carry that -# other plugin's root through no fault of anyone's — so that one warns, names -# the root it used instead, and carries on. -if [[ -n "${AI_FLOPPY_HOME:-}" ]] && ! has_scripts "$AI_FLOPPY_HOME"; then - echo "x AI_FLOPPY_HOME is set to '$AI_FLOPPY_HOME', which holds no scripts/*.sh" >&2 - echo " That variable names where the plugin is, so this stops rather than" >&2 - echo " searching the caches behind your back: a run against a copy other than" >&2 - echo " the one you named is how a fixed script gets reported as still broken." >&2 - echo " Unset it, or point it at a floppy checkout." >&2 - exit 1 -fi - -# CURSOR_PLUGIN_ROOT before the caches, and it was missing entirely until -# 0.14.0. Cursor exports it the way Claude Code exports CLAUDE_PLUGIN_ROOT -# (measured 2026-08-26 from a cross-harness plugin that branches on the two by -# name), and an answer from the harness beats the guesswork below: on the -# owner's own machine `~/.cursor/plugins/cache/floppy/floppy/` exists and is -# EMPTY, so the cache branch resolves nothing and only the local symlink saves -# the run. A variable naming the root cannot be wrong about which copy is live. -if has_scripts "${CLAUDE_PLUGIN_ROOT:-}"; then - FLOPPY_ROOT="$CLAUDE_PLUGIN_ROOT" -elif has_scripts "${CURSOR_PLUGIN_ROOT:-}"; then - FLOPPY_ROOT="$CURSOR_PLUGIN_ROOT" -elif has_scripts "${AI_FLOPPY_HOME:-}"; then - FLOPPY_ROOT="$AI_FLOPPY_HOME" -elif has_scripts "$claude_cache"; then - FLOPPY_ROOT="$claude_cache" -elif has_scripts "$cursor_local"; then - FLOPPY_ROOT="$cursor_local" -elif has_scripts "$cursor_cache"; then - FLOPPY_ROOT="$cursor_cache" -fi - -if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]] && ! has_scripts "$CLAUDE_PLUGIN_ROOT"; then - echo "! CLAUDE_PLUGIN_ROOT is set to '$CLAUDE_PLUGIN_ROOT', which holds no scripts/*.sh." >&2 - echo " Ignoring it and using ${FLOPPY_ROOT:-nothing found} instead." >&2 -fi - -if ! has_scripts "${FLOPPY_ROOT:-}"; then - echo "x floppy plugin not found." >&2 - echo " Claude Code: /plugin marketplace add spscream/ai-floppy && /plugin install floppy" >&2 - echo " Cursor: Dashboard -> Plugins -> Add Marketplace -> Import from Repo (spscream/ai-floppy)" >&2 - echo " Cursor (dev): ln -s /path/to/ai-floppy ~/.cursor/plugins/local/floppy" >&2 - echo " Or point AI_FLOPPY_HOME at a checkout directly." >&2 - echo " A cache directory that exists but holds no scripts/*.sh counts as not found:" >&2 - echo " reinstall rather than trusting \`plugin update\`, which is a no-op while the version is unchanged." >&2 - exit 1 -fi -export FLOPPY_ROOT - -# Is this copy the same file as the plugin's? Both are on disk now, so the -# question is one `cmp` away. -# -# Why every call and not just a red one: this file is a COPY in the consumer -# repository and does not arrive with `plugin update`, and the ways it goes -# stale are mostly SILENT. A missing verb announces itself; a fixed config -# parser, a fixed repository resolver, a corrected search order do not — the -# old copy simply keeps doing the old thing. Measured over the first week after -# extraction: the shim took 9 commits, and 4 of the 8 after the first were in -# the plugin resolver — the one part that by construction cannot move into the -# plugin, so no amount of shrinking this file removes them. A content -# comparison covers all 8. -# -# The hint is a bare `cp` rather than a verb like `refresh` on purpose: a shim -# old enough to need refreshing is by definition too old to know the verb that -# would do it. -# -# Silent when the plugin ships no shim/ (some install layouts flatten it) — -# a check with nothing to compare against must not invent a warning. Silent -# too when this IS the plugin's own copy, which is the case while developing -# the plugin itself. -self="${BASH_SOURCE[0]:-$0}" -# Absolute, because the hint below is a command a human is meant to paste, and -# this file is normally invoked as the relative `.floppy/run` — from a shell -# whose working directory the reader may no longer be in by the time they read -# the warning. -case "$self" in - /*) ;; - *) self="$(cd "$(dirname "$self")" 2>/dev/null && pwd -P)/$(basename "$self")" ;; -esac -plugin_shim="$FLOPPY_ROOT/shim/run" -if [[ -f "$self" && -f "$plugin_shim" ]] && ! cmp -s "$self" "$plugin_shim"; then - # Its own path, not one derived from the repository root: this file no longer - # resolves the repository (scripts/run does), and a hint that names the wrong - # copy is worse than none where several checkouts are open at once. - echo "! .floppy/run differs from the installed plugin's shim — usually stale: cp \"$plugin_shim\" \"$self\"" >&2 -fi -# Everything else is the plugin's. A plugin new enough to be found but too old -# to have a dispatcher is named as such: the alternative is `bash: .../run: No -# such file or directory`, which reads as a broken install. -if [[ ! -f "$FLOPPY_ROOT/scripts/run" ]]; then - echo "x the installed plugin has no scripts/run — it predates 0.14.0" >&2 - echo " This copy of .floppy/run only locates the plugin; the verbs live in the" >&2 - echo " plugin itself now. Update the plugin on this machine, and remember that" >&2 - echo " \`plugin update\` is a no-op while the version string is unchanged." >&2 - echo " Resolved plugin root: $FLOPPY_ROOT" >&2 - exit 1 -fi -# $BASH, not a bare `bash`, for the reason scripts/run gives at its own exec: -# an interpreter chosen on the command line has to survive both hops, or -# `/bin/bash .floppy/run` silently runs the verbs on whatever PATH offers. -exec "${BASH:-bash}" "$FLOPPY_ROOT/scripts/run" "$@" diff --git a/AGENTS.md b/AGENTS.md index 9958449..cd61b6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,10 @@ project at all — the latter belongs in `common/`, which no index links to and ## Agent memory This repository uses the `floppy` plugin for its session ritual and its -durable memory. The entry point is `.floppy/run` — see `agent-memory` +durable memory. Its verbs are run from the plugin, not from here: +`bash /scripts/run `, where `` is two directories above +the base directory the harness states when it loads a floppy skill +(`Base directory for this skill: /skills/start`). See `agent-memory` for what a note looks like and how the memory is laid out, and `start` / `workstatus` / `wrap` for the three rites built on top of it. Settings live in `.floppy/config`; the memory itself is diff --git a/CHANGELOG.md b/CHANGELOG.md index 273ff17..6ca6707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,20 +4,86 @@ Why this file exists, in one line: **`plugin update` compares version strings and copies nothing while the version is unchanged**, so the version here is load-bearing, and a consumer needs to know whether an update is worth taking. -One column matters more than the rest and is called out per release: +One column mattered more than the rest and is called out per release: -> **Refresh `.floppy/run`?** — `.floppy/run` is a *copy* in your repository, -> not a link, and no plugin update ever touches it. When a release changes the -> shim, updating the plugin is only half the job: -> `cp "$FLOPPY_ROOT/shim/run" .floppy/run`. Since 0.2.1 the shim notices this -> itself and prints the command; before that it was silent. +> **Refresh `.floppy/run`?** — until 0.26.0, `.floppy/run` was a *copy* in your +> repository, not a link, and no plugin update ever touched it. When a release +> changed the shim, updating the plugin was only half the job: +> `cp "$FLOPPY_ROOT/shim/run" .floppy/run`. Since 0.2.1 the shim noticed this +> itself and printed the command; before that it was silent. > -> Since 0.14.0 the answer is usually **no**. The copy holds only the search for -> the plugin; verbs, config keys and defaults live in the plugin and arrive with -> `plugin update`. A release says **yes** here only when that search changes. +> Since 0.14.0 the answer was usually **no**: the copy held only the search for +> the plugin, and verbs, config keys and defaults arrived with `plugin update`. +> Since 0.26.0 there is nothing to refresh — the skills call the plugin's +> dispatcher by path and `init` puts no runner in your repository. The line is +> kept in every entry because a repository created before 0.26.0 still carries +> its copy, and the answer for it is still worth stating. Dates are the day the version was tagged in `.claude-plugin/plugin.json`. +## 0.26.0 — 2026-09-25 + +**Refresh `.floppy/run`: no — there is nothing left to refresh.** A repository +that already carries a copy keeps working; a repository created from here on +never gets one. + +### The runner leaves your repository + +Every `init` before this release copied `shim/run` into the consumer as +`.floppy/run`, and every skill, document and status note named that path. The +copy existed for one reason: a skill had no way to say where the plugin was, so +something inside the repository had to search for it. + +It does have a way. A harness states the skill's own base directory above the +skill text — `Base directory for this skill: /skills/workstatus` — and +the plugin directory is two levels above that (measured in Claude Code on +2026-09-22, on this plugin and one other). So the skills now call +`bash /scripts/run ` directly, and `scripts/run` derives +`FLOPPY_ROOT` from its own `${BASH_SOURCE[0]}` instead of being handed it. A +direct call needs no floppy variable at all: `bash /scripts/run status` +runs with nothing set but `HOME`, which the config parser has always read. + +What follows from that: + +- **`init` writes `.floppy/config` and nothing else.** Your repository carries + data, not code. Its refusal when the plugin had no `shim/run` is gone with + the copy it guarded. +- **`skills/init/SKILL.md` drops its hand copy of the plugin search** — 33 + lines that existed because `init` ran before `.floppy/run` existed. The test + that executed that fenced block (`tests/test-init-bootstrap.sh`) goes with + it. Prose cannot be executed, so `tests/test-skills.sh` resolves it instead: + no skill names `.floppy/run`, a skill using `` says where it comes + from and how far above the base directory it sits, and every `/…` + path a skill names has to exist in this checkout. The last two were added + after a review showed `/run` and "one directory above" passing. +- **`shim/run` still ships, byte for byte.** It is what a pre-0.26.0 consumer + calls, and it `cmp`s itself against the plugin's copy — so any edit here + would tell every one of those repositories that their copy is stale. It is + deliberately left untouched. + +### Migrating a repository you already have + +Nothing breaks if you do nothing: `.floppy/run` still finds the plugin and +still runs the verb. Nothing in the plugin calls it any more, so when you want +it gone: + +```bash +git rm .floppy/run +``` + +In the same commit, correct the line in `AGENTS.md` that names `.floppy/run` as +the entry point — the verbs are run from the plugin now: +`bash /scripts/run `, where `` is two directories above +the base directory the harness states. A later `init` prints a reminder when it +sees the old line, and prints one more when it finds the leftover file. + +### The command a human typed + +There isn't one, and that is the measurement: the operator of the six +repositories that use floppy reports never having typed `.floppy/run` by hand. +The surface is dropped rather than replaced; no `AI_FLOPPY_HOME` recipe and no +cache path with a version in its last segment is invented to stand in for it. + ## 0.25.1 — 2026-09-18 **Refresh `.floppy/run`: no.** The shim is untouched. diff --git a/CLAUDE.md b/CLAUDE.md index 09410f4..66bbfd5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,27 +46,38 @@ keep it. ### Running the plugin's own verbs against this checkout ```bash -AI_FLOPPY_HOME=$(pwd) bash .floppy/run status +bash scripts/run status ``` -Without `AI_FLOPPY_HOME`, `.floppy/run` resolves the **installed plugin cache**, -not this working tree — so a script you just fixed will be reported as still -broken. `AI_FLOPPY_HOME` pointing somewhere without `scripts/*.sh` is a hard -error rather than a fallthrough, for exactly that reason. +The dispatcher roots itself in its own path (0.26.0), so the copy that answers +is always the one you called — this checkout, not the installed plugin cache. +That was the point of the change: `AI_FLOPPY_HOME` existed because a run could +otherwise land in a different copy of the same repository and report a script +you had just fixed as still broken. `shim/run` still honours the variable, for +the consumers who still call through a copy. ## Architecture -### Three layers, and why the seam is where it is +### Two layers, and where the third one went ``` -.floppy/run (consumer repo) → scripts/run (plugin) → scripts/.sh - shim/run, copied dispatcher + lib-config.sh the work +/scripts/run → scripts/.sh + dispatcher + lib-config.sh the work ``` -- **`shim/run`** is the *only* file the plugin puts into a consumer repository - (as `.floppy/run`). It is a copy carried by the consumer's git, and no - `plugin update` ever touches it — so it does exactly one thing that cannot - live in the plugin: **find the plugin**. Resolution order is +Since 0.26.0 there is no layer in the consumer's repository. A skill is handed +its own base directory when the harness loads it (`Base directory for this +skill: /skills/`, measured 2026-09-22), so the caller knows where +the plugin is and calls `/scripts/run` directly; `scripts/run` derives +`FLOPPY_ROOT` from `${BASH_SOURCE[0]}`. `init` writes `.floppy/config` and no +code. What the consumer carries is data. + +- **`shim/run`** is what that layer used to be, copied into a consumer as + `.floppy/run` by every `init` before 0.26.0. It still ships, for the + repositories that already carry a copy — and it is deliberately left + byte-identical, because it `cmp`s itself against the plugin's copy and any + edit here would tell every one of those repositories that their copy is + stale. It does one thing: **find the plugin**. Resolution order is `CLAUDE_PLUGIN_ROOT` → `CURSOR_PLUGIN_ROOT` → `AI_FLOPPY_HOME` → Claude cache (`sort -V`, version-named) → Cursor local symlink → Cursor cache (`ls -dt`, SHA-named, so mtime not lexical order). A candidate counts only if it holds @@ -83,9 +94,17 @@ error rather than a fallthrough, for exactly that reason. Verbs: `env lint link workplace store guard heat lock status check commit`. `lib-checkout.sh` is shared by `store`/`workplace` and is not a verb. -`skills/init/SKILL.md` **duplicates the shim's plugin search by hand** — `init` -runs before `.floppy/run` exists. `tests/test-init-bootstrap.sh` extracts that -fenced block and executes it, so keep the two in sync. +`skills/init/SKILL.md` carried a hand copy of that search until 0.26.0, because +`init` ran before `.floppy/run` existed; the harness states the base directory +instead, so the copy and the test that executed it +(`tests/test-init-bootstrap.sh`) are both gone. What replaced them is in +`tests/test-skills.sh`: no `SKILL.md` may name `.floppy/run`, a skill using the +`` placeholder has to say where it comes from and how far above the +base directory the plugin sits, and every `/…` path it names is +resolved against this checkout — which is as close to executing prose as the +guard gets. `tests/test-dispatcher.sh` holds the other half: that `scripts/run` +roots itself, ignores an inherited `FLOPPY_ROOT`, survives an exported +`CDPATH`, and hands the verbs a hint that can be pasted back. ### The wrap rite @@ -121,7 +140,10 @@ request. `.claude-plugin/marketplace.json`, `.cursor-plugin/plugin.json`, plus a `CHANGELOG.md` entry for that version. The *marketplace* manifest is what `plugin update` compares — five releases once shipped nothing because only - `plugin.json` moved. Every changelog entry answers **"Refresh `.floppy/run`?"**. + `plugin.json` moved. Every changelog entry up to 0.26.0 answers **"Refresh + `.floppy/run`?"**; the question retires with the copy, and + `tests/test-changelog.sh` still requires the word "Refresh" in the entry for + the shipped version. - **The documentation site is generated, never hand-written.** Pages come from `README*.md`, `docs/*.md`, `skills/*/SKILL.md`, `knowledge/` and `CHANGELOG.md`. Adding a file to `docs/*.md` **or `docs/guide/*.md`** means @@ -132,7 +154,7 @@ request. (`docs/plans/`, `docs/specs/`) neither needs a row nor gets one, and is not published. Say the globs, not "under `docs/`": the loose phrasing described a rule wider than the guard, which is how a wrong rule survives a green suite. -- **`wrap` here may only commit `docs/statuses`, `AGENTS.md`, `.floppy/run`, +- **`wrap` here may only commit `docs/statuses`, `AGENTS.md`, `.floppy/config`** (`watched_dirs`/`watched_files`). `skills/`, `scripts/`, `shim/` and `tests/` are the product and belong in reviewed commits — a `knowledge/` note from this repository needs a deliberate PR. diff --git a/README.md b/README.md index f340e81..30b1e48 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,10 @@ Cursor, a local checkout, and what to do when an update copies nothing: ## Then -Run `init` once in each repository. It writes `.floppy/run` and -`.floppy/config`, creates the memory index and the state file, and points your -`AGENTS.md` at the conventions. +Run `init` once in each repository. It writes `.floppy/config`, creates the +memory index and the state file, and points your `AGENTS.md` at the +conventions. It puts no code in your repository: the commands are run from the +plugin. Six skills: `init`, `agent-memory`, `start`, `workstatus`, `wrap`, `consolidate`. What each one does: [The six skills](docs/guide/skills.md). @@ -81,9 +82,10 @@ reports, it does not gate. ## Releases -See [CHANGELOG.md](CHANGELOG.md). For each release it answers one question that -you cannot answer without it: does this update also need a new copy of the shim -file? `.floppy/run` is a copy, and no plugin update changes it. +See [CHANGELOG.md](CHANGELOG.md). Up to 0.26.0 each release answered one +question you could not answer without it: does this update also need a new copy +of the shim file in your repository? Since 0.26.0 there is no copy, and the +answer is always no. ## License diff --git a/README.ru.md b/README.ru.md index 8bf75e0..adc62a1 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,4 +1,4 @@ - + # floppy @@ -31,9 +31,10 @@ Cursor, локальный чекаут и что делать, если обн ## Дальше -Выполните `init` один раз в каждом репозитории. Он пишет `.floppy/run` и -`.floppy/config`, создаёт индекс памяти и файл состояния, и добавляет в ваш -`AGENTS.md` указатель на соглашения. +Выполните `init` один раз в каждом репозитории. Он пишет `.floppy/config`, +создаёт индекс памяти и файл состояния, и добавляет в ваш `AGENTS.md` +указатель на соглашения. Кода он в ваш репозиторий не кладёт: команды +выполняются из плагина. Шесть скиллов: `init`, `agent-memory`, `start`, `workstatus`, `wrap`, `consolidate`. Что делает каждый: [Шесть скиллов](docs/guide/skills.ru.md). @@ -81,10 +82,10 @@ Cursor, локальный чекаут и что делать, если обн ## Релизы -См. [CHANGELOG.md](CHANGELOG.md). Для каждого релиза он отвечает на один вопрос, -на который без него не ответить: нужна ли этому обновлению ещё и новая копия -файла-прокладки? `.floppy/run` — это копия, и ни одно обновление плагина её не -меняет. +См. [CHANGELOG.md](CHANGELOG.md). До 0.26.0 каждый релиз отвечал на один +вопрос, на который без него не ответить: нужна ли этому обновлению ещё и новая +копия файла-прокладки в вашем репозитории? С 0.26.0 копии нет, и ответ всегда +«нет». ## Лицензия diff --git a/docs/guide/config.md b/docs/guide/config.md index bba98be..84fa153 100644 --- a/docs/guide/config.md +++ b/docs/guide/config.md @@ -1,9 +1,9 @@ # Config reference -The file contains one `key=value` line for each setting. The shim file -(`.floppy/run`) reads it, and exports each value as a `FLOPPY_*` variable for -the scripts. +The file contains one `key=value` line for each setting. The dispatcher +(`/scripts/run`) reads it, and exports each value as a `FLOPPY_*` +variable for the scripts. All keys are optional. The table shows the value that each key has if the file does not contain it. @@ -12,8 +12,8 @@ does not contain it. |---|---|---| | `memory_dir` | `.agent-memory` | the directory of the memory of this repository | | `memory_private_dir` | `private` | the name of the private scope in the memory: facts about this project that the code repository must not carry, such as somebody else's checkout or an access note. The workplace repository holds them, so **other machines do read them**. Facts about one machine go to `machines//` of that repository instead. Only the name is a setting; the rule is not — committed memory must not link into this scope, and the check uses this key. The same rule covers `common/`, whose name is fixed rather than configurable: it is written into the store paths themselves, and a name settable in one of the two places would be a name that drifts | -| `public_repo` | *(not set)* | the git URL of the repository that holds this project's **public** memory when the code repository cannot. Set `project_key` also. Then run `bash .floppy/run store` one time for each machine and each worktree | -| `private_repo` | *(not set)* | the git URL of the repository that holds this project's **private** memory: facts the team must not get. `bash .floppy/run workplace` wires it | +| `public_repo` | *(not set)* | the git URL of the repository that holds this project's **public** memory when the code repository cannot. Set `project_key` also. Then run the `store` command one time for each machine and each worktree | +| `private_repo` | *(not set)* | the git URL of the repository that holds this project's **private** memory: facts the team must not get. The `workplace` command wires it | | `machine_key` | *(not set)* | the name of this machine in the memory repositories, chosen by you. `hostname` is not used: on one of the author's machines it is `WIN-GVR0V5UPOD7`. Only needed for a note that is true on one machine | | `workplace_key` | *(not set)* | the name of this workplace, when one private repository serves several of them. Only needed for a note that is true at one workplace | | `project_key` | *(not set)* | the name of this project in every memory repository it uses, and the name of its directory in `agents_memory_dir`. The scopes are `public/projects/` (in `public_repo`) and `private/projects/` (in `private_repo`) | @@ -107,7 +107,7 @@ resolves somewhere real — remove those two links first, then rewire: git -C ~/agents_memory/.clones/agents-memory push # flush this project's leftovers first rm ~/agents_memory/acme/private rm /common/private -bash .floppy/run workplace +bash /scripts/run workplace ``` The cost is one more clone on disk. The remote stays one repository, the @@ -181,8 +181,8 @@ Some repositories cannot hold agent notes with the code. Examples are a customer checkout that you do not own, and a policy that keeps the two apart. In that condition, the memory goes into a store repository. Your code -repository keeps two files only: `.floppy/run` and `.floppy/config`. Together -they are approximately 110 lines. A review of them takes one minute. +repository keeps one file: `.floppy/config`. It is a short list of settings. A +review of it takes one minute. To set this up during `init`, use the flags: @@ -194,12 +194,12 @@ To set it up later, put `public_repo` and `project_key` in `.floppy/config`. Then run: ``` -bash .floppy/run store # clone or pull, link, ignore, and verify a write -bash .floppy/run link # then the memory directory of the agent application +bash /scripts/run store # clone or pull, link, ignore, verify a write +bash /scripts/run link # then the agent application's memory directory ``` `store` runs one time for each machine and each worktree. It is idempotent. To -see the result without a change, run `bash .floppy/run store --check`. +see the result without a change, run `store --check`. If a directory is in the position of the symbolic link, `store` stops. It does not delete the directory. Those notes can be the only copies. @@ -301,6 +301,6 @@ do" are different reports — and one warning is created by adoption itself: `pointers_max` is seeded at the longest index found, which leaves that index at 100% of its own ceiling from the first run. -While the file is absent, `bash .floppy/run lint` gives a warning. It does not +While the file is absent, the `lint` command gives a warning. It does not fail. diff --git a/docs/guide/config.ru.md b/docs/guide/config.ru.md index 8664a6e..189b589 100644 --- a/docs/guide/config.ru.md +++ b/docs/guide/config.ru.md @@ -1,10 +1,10 @@ - + # Справочник конфигурации -Файл содержит по одной строке `key=value` на каждую настройку. Файл-прокладка -(`.floppy/run`) читает его и экспортирует каждое значение как переменную -`FLOPPY_*` для скриптов. +Файл содержит по одной строке `key=value` на каждую настройку. Диспетчер +(`/scripts/run`) читает его и экспортирует каждое значение как +переменную `FLOPPY_*` для скриптов. Все ключи необязательны. В таблице показано значение, которое ключ принимает, если файл его не содержит. @@ -13,8 +13,8 @@ |---|---|---| | `memory_dir` | `.agent-memory` | каталог памяти этого репозитория | | `memory_private_dir` | `private` | имя приватной области в памяти: факты об этом проекте, которые репозиторий с кодом нести не должен, — например чужой чекаут или заметка о доступе. Их держит репозиторий рабочего места, поэтому **другие машины их читают**. Факты об одной машине идут вместо этого в `machines//` того же репозитория. Настройкой является только имя, а не правило: закоммиченная память не должна ссылаться внутрь этой области, и проверка пользуется этим ключом. То же правило распространяется на `common/`, чьё имя не настраивается: оно вписано в сами пути хранилищ, а имя, задаваемое в одном из двух мест, — это имя, которое разъедется | -| `public_repo` | *(не задан)* | git-URL репозитория, который держит **публичную** память этого проекта, когда репозиторий с кодом её держать не может. Задайте также `project_key`. Затем выполните `bash .floppy/run store` по одному разу на каждой машине и в каждом worktree | -| `private_repo` | *(не задан)* | git-URL репозитория, который держит **приватную** память этого проекта: факты, которые команда получить не должна. Проводку делает `bash .floppy/run workplace` | +| `public_repo` | *(не задан)* | git-URL репозитория, который держит **публичную** память этого проекта, когда репозиторий с кодом её держать не может. Задайте также `project_key`. Затем выполните команду `store` по одному разу на каждой машине и в каждом worktree | +| `private_repo` | *(не задан)* | git-URL репозитория, который держит **приватную** память этого проекта: факты, которые команда получить не должна. Проводку делает команда `workplace` | | `machine_key` | *(не задан)* | имя этой машины в репозиториях памяти, выбранное вами. `hostname` не используется: на одной из машин автора он равен `WIN-GVR0V5UPOD7`. Нужен только для заметки, верной на одной машине | | `workplace_key` | *(не задан)* | имя этого рабочего места, когда один приватный репозиторий обслуживает несколько. Нужен только для заметки, верной на одном рабочем месте | | `project_key` | *(не задан)* | имя этого проекта в каждом репозитории памяти, который он использует, и имя его каталога в `agents_memory_dir`. Области — это `public/projects/` (в `public_repo`) и `private/projects/` (в `private_repo`) | @@ -108,7 +108,7 @@ workplace_memory_dir=~/agents_memory/.clones/agents-memory--acme git -C ~/agents_memory/.clones/agents-memory push # сначала вытолкнуть остатки этого проекта rm ~/agents_memory/acme/private rm /common/private -bash .floppy/run workplace +bash /scripts/run workplace ``` Цена — ещё один клон на диске. Удалённый репозиторий остаётся одним, области @@ -182,8 +182,8 @@ private/common в private_repo — ни про один проек вещи. В таком случае память уходит в репозиторий-хранилище. Ваш репозиторий с кодом -хранит только два файла: `.floppy/run` и `.floppy/config`. Вместе это примерно -110 строк. Ревью их занимает минуту. +хранит один файл: `.floppy/config`. Это короткий список настроек. Ревью его +занимает минуту. Чтобы настроить это при `init`, используйте флаги: @@ -195,13 +195,13 @@ private/common в private_repo — ни про один проек Затем выполните: ``` -bash .floppy/run store # клонировать или подтянуть, слинковать, игнорировать и проверить запись -bash .floppy/run link # затем каталог памяти агентского приложения +bash /scripts/run store # клонировать или подтянуть, слинковать, игнорировать и проверить запись +bash /scripts/run link # затем каталог памяти агентского приложения ``` `store` выполняется по одному разу на каждой машине и в каждом worktree. Он идемпотентен. Чтобы увидеть результат без изменений, выполните -`bash .floppy/run store --check`. +`store --check`. Если на месте символической ссылки лежит каталог, `store` останавливается. Он не удаляет каталог. Эти заметки могут быть единственными копиями. @@ -306,5 +306,5 @@ bash .floppy/run link # затем каталог памяти агентс найденному индексу, отчего этот индекс с первого же прогона стоит на 100% своего собственного потолка. -Пока файла нет, `bash .floppy/run lint` выдаёт предупреждение. Он не падает. +Пока файла нет, команда `lint` выдаёт предупреждение. Она не падает. diff --git a/docs/guide/install.md b/docs/guide/install.md index 5bec2a6..3664493 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -81,37 +81,45 @@ To correct this, do one of these steps: - Increase the version number in `.claude-plugin/plugin.json`. - Remove the plugin, then install it again. -`.floppy/run` refuses a cache directory that contains no `scripts/*.sh` file. -This gives a clear message instead of a later "No such file or directory". +A cached copy with no `scripts/*.sh` file in it is a broken install. Until +0.26.0 the copy in your repository searched for the plugin and refused such a +directory with a clear message. A call that uses the path the harness states +checks nothing first, so the error is `No such file or directory` on +`/scripts/run`. The answer is the same: install the plugin again. -### 2. The shim file in your repository +### 2. How a command is run -`.floppy/run` is a copy of a file in the plugin. It is not a link. A plugin -update does not change it. Git moves it with your repository. +Since 0.26.0 your repository holds no runner. The commands live in the plugin +and are called by path: -Thus `.floppy/run` can be older than the plugin. On a second machine it can -also be newer than the plugin. +```bash +bash /scripts/run status +``` + +`` is the plugin directory. The agent is told it: when a harness loads +a floppy skill, it states the base directory of that skill above the skill +text — `Base directory for this skill: /skills/workstatus`. The plugin +directory is two levels above that. Measured in Claude Code on 2026-09-22, for +this plugin and for one other. -Since 0.14.0 this matters much less. The file does one thing: it finds the -plugin and gives the call to it. The commands and the configuration keys are -in the plugin. A new command, a new key or a new default reaches your -repository with a plugin update alone. You do not copy the file again for them. +The dispatcher finds its own directory from its own path, so the call needs no +variable and no file in your repository. -One thing still travels in the copy: the search for the plugin. If that search -changes, an old copy can fail to find a plugin that is there. This failure is -loud. It says `floppy plugin not found` and names the install commands. +**A repository from before 0.26.0 still works.** The plugin still ships +`shim/run`, and a `.floppy/run` that an older `init` copied there still finds +the plugin and runs the command. Nothing in the plugin calls it any more. To +remove it: -At each call, `.floppy/run` compares itself with the file in the plugin. If the -two files are different, it prints one line on stderr. That line contains the -`cp` command that corrects the copy. +```bash +git rm .floppy/run +``` -The correction is a `cp` command, not a floppy command. This is deliberate. A -shim file that is old enough to need a correction does not know the new -commands. +In the same commit, correct your `AGENTS.md` if it names `.floppy/run` as the +entry point. A later `init` prints a reminder when it sees that line. -**Caution:** a plugin older than the copy is now a full stop, not a partial -one. A plugin from before 0.14.0 has no dispatcher, so no command runs against -it. The message says so and names the plugin directory it used. +**Caution:** a plugin older than the copy is a full stop. A plugin from before +0.14.0 has no dispatcher, so no command runs against it. The message says so +and names the plugin directory it used. ## `init` @@ -124,9 +132,9 @@ Run `init` one time in each repository. `init` then does all of these steps: -- copies the shim file to `.floppy/run`. This is the only file that the plugin - puts in your repository. -- writes `.floppy/config`. +- writes `.floppy/config`. It is the only file `init` puts there, and the + only one you commit; `heat` later writes a `.floppy/heat.log` that + `.gitignore` covers. Your repository carries data, not code. - creates the memory index `/MEMORY.md`. - creates the state file `docs/statuses/NOW.md`. - adds the private memory scope to `.gitignore`. That path becomes a symlink diff --git a/docs/guide/install.ru.md b/docs/guide/install.ru.md index 427c5a8..ec4f10a 100644 --- a/docs/guide/install.ru.md +++ b/docs/guide/install.ru.md @@ -1,4 +1,4 @@ - + # Установка и init ## Требования @@ -83,39 +83,45 @@ version». - Увеличьте номер версии в `.claude-plugin/plugin.json`. - Удалите плагин и установите его заново. -`.floppy/run` отказывается работать с каталогом кэша, в котором нет ни одного -файла `scripts/*.sh`. Это даёт внятное сообщение вместо позднего «No such file or -directory». +Копия кэша, в которой нет ни одного файла `scripts/*.sh`, — это сломанная +установка. До 0.26.0 копия в вашем репозитории искала плагин и отказывалась +работать с таким каталогом, давая внятное сообщение. Вызов по пути, который +называет харнесс, ничего заранее не проверяет, поэтому ошибка будет «No such +file or directory» на `/scripts/run`. Ответ тот же: установите плагин +заново. -### 2. Файл-прокладка в вашем репозитории +### 2. Как выполняется команда -`.floppy/run` — копия файла из плагина. Это не ссылка. Обновление плагина её не -меняет. Git перемещает её вместе с вашим репозиторием. +С версии 0.26.0 ваш репозиторий не хранит раннер. Команды живут в плагине и +вызываются по пути: -Поэтому `.floppy/run` может быть старше плагина. На второй машине он может -оказаться и новее плагина. +```bash +bash /scripts/run status +``` + +`` — это каталог плагина. Агенту его сообщают: загружая скилл floppy, +харнесс печатает над текстом скилла его базовый каталог — `Base directory for +this skill: /skills/workstatus`. Каталог плагина — на два уровня выше. +Замерено в Claude Code 2026-09-22, на этом плагине и ещё на одном. -С версии 0.14.0 это значит куда меньше. Файл делает одно: находит плагин и -передаёт ему вызов. Команды и ключи конфигурации лежат в плагине. Новая команда, -новый ключ или новое значение по умолчанию доезжают до вашего репозитория одним -только обновлением плагина. Копировать файл заново ради них не нужно. +Диспетчер определяет свой каталог из собственного пути, поэтому вызову не нужны +ни переменная, ни файл в вашем репозитории. -Одно всё же путешествует в копии: поиск плагина. Если этот поиск изменится, -старая копия может не найти плагин, который на месте. Отказ при этом громкий. Он -говорит `floppy plugin not found` и называет команды установки. +**Репозиторий, заведённый до 0.26.0, продолжает работать.** Плагин по-прежнему +везёт `shim/run`, и `.floppy/run`, положенный старым `init`, всё так же находит +плагин и выполняет команду. Просто ничто в плагине его больше не зовёт. Чтобы +убрать файл: -При каждом вызове `.floppy/run` сравнивает себя с файлом в плагине. Если файлы -различаются, он печатает одну строку в stderr. В этой строке — команда `cp`, -исправляющая копию. +```bash +git rm .floppy/run +``` -Исправление — это команда `cp`, а не команда floppy. Так сделано намеренно. -Файл-прокладка, достаточно старый, чтобы требовать исправления, не знает новых -команд. +Тем же коммитом поправьте `AGENTS.md`, если он называет `.floppy/run` точкой +входа. Следующий `init` печатает напоминание, когда видит такую строку. -**Осторожно:** плагин старше копии — теперь полная остановка, а не частичная. -У плагина до 0.14.0 нет диспетчера, поэтому против него не выполнится ни одна -команда. Сообщение говорит об этом и называет каталог плагина, который был -использован. +**Осторожно:** плагин старше копии — это полная остановка. У плагина до 0.14.0 +нет диспетчера, поэтому против него не выполнится ни одна команда. Сообщение +говорит об этом и называет каталог плагина, который был использован. ## `init` @@ -128,9 +134,10 @@ directory». Затем `init` делает всё перечисленное: -- копирует файл-прокладку в `.floppy/run`. Это единственный файл, который плагин - кладёт в ваш репозиторий. -- пишет `.floppy/config`. +- пишет `.floppy/config`. Это единственный файл, который туда кладёт `init`, + и единственный, который вы коммитите; позже `heat` пишет там же + `.floppy/heat.log`, закрытый `.gitignore`. Ваш репозиторий несёт данные, + а не код. - создаёт индекс памяти `/MEMORY.md`. - создаёт файл состояния `docs/statuses/NOW.md`. - добавляет приватную область памяти в `.gitignore`. Этот путь становится diff --git a/docs/guide/skills.md b/docs/guide/skills.md index 93d3e9e..043de7a 100644 --- a/docs/guide/skills.md +++ b/docs/guide/skills.md @@ -21,18 +21,18 @@ short name. Only the short name is correct in both applications. the state file. The agent then finds the half of the memory for this task, and reads the guidance and the index of that half. If the repository has no memory yet, the agent omits this step. The agent then runs - `bash .floppy/run status`, because live facts are more reliable than the - documents. + `bash /scripts/run status`, because live facts are more reliable + than the documents. - **`workstatus`** — reports the state during a session: git state, difference from the remote, background jobs, memory configuration, the workplace memory repository, and the age of the state file. - **`wrap`** — closes a session. The agent takes the lock. Most facts are notes already, written when they appeared; here the agent adds only what is left, updates the state file, and records the unfinished work. The agent then runs - `bash .floppy/run check`, which changes nothing and shows the lint result, - the file-list check, and the diff. Last, the agent runs - `bash .floppy/run commit`, which stages, commits, pushes, and releases the - lock. + `bash /scripts/run check`, which changes nothing and shows the lint + result, the file-list check, and the diff. Last, the agent runs + `bash /scripts/run commit`, which stages, commits, pushes, and + releases the lock. - **`consolidate`** — merges and prunes the memory when a size warning fires, or before a limit in `quota.lock` is raised. The agent reads one half of the memory, proposes merges, rewrites, and deletions, each with its reason, diff --git a/docs/guide/skills.ru.md b/docs/guide/skills.ru.md index fb67a04..684c989 100644 --- a/docs/guide/skills.ru.md +++ b/docs/guide/skills.ru.md @@ -1,4 +1,4 @@ - + # Шесть скиллов @@ -21,18 +21,19 @@ Claude Code и Cursor показывают имена по-разному. Claud - **`start`** — готовит новую сессию, до первой правки. Агент читает файл состояния. Затем агент находит половину памяти, относящуюся к этой задаче, и читает руководство и индекс этой половины. Если памяти в репозитории ещё нет, - агент этот шаг пропускает. Затем агент выполняет `bash .floppy/run status`, - потому что живые факты надёжнее документов. + агент этот шаг пропускает. Затем агент выполняет + `bash /scripts/run status`, потому что живые факты надёжнее + документов. - **`workstatus`** — докладывает состояние по ходу сессии: состояние git, расхождение с удалённым, фоновые задачи, конфигурацию памяти, репозиторий памяти рабочего места и возраст файла состояния. - **`wrap`** — закрывает сессию. Агент берёт замок. Большая часть фактов уже записана заметками в момент их появления; здесь агент добавляет только то, что осталось, обновляет файл состояния и записывает незавершённую работу. - Затем агент выполняет `bash .floppy/run check`, который ничего не меняет и - показывает результат линта, проверку списка файлов и дифф. Последним агент - выполняет `bash .floppy/run commit`, который индексирует, коммитит, пушит и - освобождает замок. + Затем агент выполняет `bash /scripts/run check`, который ничего не + меняет и показывает результат линта, проверку списка файлов и дифф. Последним + агент выполняет `bash /scripts/run commit`, который индексирует, + коммитит, пушит и освобождает замок. - **`consolidate`** — сливает и чистит память, когда срабатывает предупреждение о размере, или перед поднятием лимита в `quota.lock`. Агент читает одну половину памяти, предлагает слияния, переписывания и удаления, diff --git a/docs/lessons.md b/docs/lessons.md index aa0a6a1..7654cc0 100644 --- a/docs/lessons.md +++ b/docs/lessons.md @@ -86,7 +86,7 @@ Wrap acquired work rather than shedding it. is not the cheap thing": - *"The verbs that replaced the scripts are 3.6% of all turns, so the mechanical - half of wrap is nearly free."* On the billed unit, `.floppy/run` verbs are + half of wrap is nearly free."* On the billed unit, floppy's verbs are **5.1 turns per run — 23% of all turns**, the largest single category, ahead of note edits (3.9), other `Bash` (4.0) and state-file edits (2.6). About half of it is redundant: runs call `lint`, `guard` and `status` **on top of** `check`, diff --git a/docs/lessons.ru.md b/docs/lessons.ru.md index 7f0dbd0..5d824ea 100644 --- a/docs/lessons.ru.md +++ b/docs/lessons.ru.md @@ -1,4 +1,4 @@ - + # Уроки @@ -84,7 +84,7 @@ **Что пересчёт опроверг**, оба раза в сторону «дешёвое оказалось не дешёвым»: - *«Глаголы, заменившие скрипты, — 3,6% всех ходов, значит механическая половина - wrap почти бесплатна».* В оплачиваемой единице глаголы `.floppy/run` — это + wrap почти бесплатна».* В оплачиваемой единице глаголы floppy — это **5,1 хода на прогон, 23% всех ходов**, крупнейшая категория, впереди правок заметок (3,9), прочего `Bash` (4,0) и правок файла состояния (2,6). Примерно половина из этого избыточна: прогоны зовут `lint`, `guard` и `status` diff --git a/docs/statuses/NOW.md b/docs/statuses/NOW.md index 12e4bf8..5f351aa 100644 --- a/docs/statuses/NOW.md +++ b/docs/statuses/NOW.md @@ -68,9 +68,14 @@ defect intact and the tests green. ## What is frozen -- **`.floppy/run` stays a committed copy, not a generated file** (owner, - 2026-09-13): a gitignored shim is absent from a fresh clone and from CI. Full - trade and reversing condition in `shim-is-committed-rather-than-generated`. +- **A runner in the consumer's repository is either committed or absent — + never gitignored** (owner, 2026-09-13; carried out 2026-09-25): a gitignored + shim is absent from a fresh clone, from CI and from every new worktree. The + committed copy went the other way in 0.26.0: the skills call + `/scripts/run`, `init` writes `.floppy/config` alone, and `shim/run` + still ships for the repositories that already carry a copy. The note + `shim-is-committed-rather-than-generated` states the older half of this and + needs the second half written into it. - **`watched_dirs` is `docs/statuses`, and documentation is product** (narrowed 2026-09-08). The closing rite writes the status file and nothing else; `docs/guide/`, `docs/lessons.md` and `docs/memory-model.md` go through diff --git a/evals/README.md b/evals/README.md index 1def3fe..ebcdc01 100644 --- a/evals/README.md +++ b/evals/README.md @@ -17,7 +17,7 @@ have scored unaided. | case | the rule it measures | where the rule is written | |---|---|---| | `wrap-rewrites-the-status-file` | the current-state file is rewritten once, not patched | `skills/wrap/SKILL.md` §5 | -| `workstatus-checks-instead-of-recalling` | live state is reported from `bash .floppy/run status`, never from the documents | `skills/workstatus/SKILL.md` | +| `workstatus-checks-instead-of-recalling` | live state is reported from floppy's `status` verb, never from the documents | `skills/workstatus/SKILL.md` | | `a-fact-becomes-one-note-with-an-index-line` | one fact per file, one pointer line, never the note's text in the index | `skills/agent-memory/SKILL.md` | The first is the one worth having. The rewrite-once rule was measured at 2.6 @@ -131,9 +131,23 @@ Worth knowing before a red score gets read as a broken skill. ## A note on the fixtures -Each `scaffold.sh` writes a stand-in `.floppy/run` rather than wiring up the -real shim. The real one resolves the installed plugin through the harness's -cache, and an eval run gets a temporary `HOME` where no such cache exists — so -the real shim would print "plugin not found" and every case would be measuring -its own fixture. The stand-ins answer the verbs their rite calls and change -nothing, which leaves the graded behaviour where it belongs. +Each `scaffold.sh` writes a stand-in runner at `.floppy/run` rather than wiring +up the real one. Two reasons, and only the first went away in 0.26.0. The shim +resolved the installed plugin through the harness's cache, and an eval run gets +a temporary `HOME` where no such cache exists — so the real shim printed +"plugin not found" and every case measured its own fixture. The second reason +stands: a stand-in is an **oracle**. It answers the verbs its rite calls with +the invented state the case is built around — a background job at 41%, a branch +that is two commits ahead — which a real dispatcher, pointed at a scratch +directory, would correctly refuse to say. + +**What 0.26.0 left open here.** Skills now call the plugin's own +`/scripts/run`, so a model following them lands on the real dispatcher +and never reaches the stand-in at `.floppy/run`. These cases are unrun by +decision (2026-09-17, above), so nothing turned red; what is true is that the +oracle now sits at a path the skills no longer name. +Putting it back in the path of the call is the fixture work the first real run +will have to start with: the supported seam is the repository's own +`.floppy/workstatus-project.sh` hook, which the real `status` executes and +prints, and which the fixture can therefore use to state an invented fact the +scratch directory cannot produce on its own. diff --git a/evals/workstatus-checks-instead-of-recalling/case.yaml b/evals/workstatus-checks-instead-of-recalling/case.yaml index 2b8ec17..dd51701 100644 --- a/evals/workstatus-checks-instead-of-recalling/case.yaml +++ b/evals/workstatus-checks-instead-of-recalling/case.yaml @@ -1,10 +1,10 @@ schema_version: "1.1" name: workstatus-checks-instead-of-recalling description: >- - `workstatus` exists to report what is true right now, by running - `bash .floppy/run status` and describing what it printed — never by retelling - the documents. The fixture's current-state file is deliberately stale and - contradicts the shim on every fact, so an answer built from the file and an + `workstatus` exists to report what is true right now, by running floppy's + `status` verb and describing what it printed — never by retelling the + documents. The fixture's current-state file is deliberately stale and + contradicts the run on every fact, so an answer built from the file and an answer built from the command are told apart by their content alone. tags: - workstatus @@ -30,15 +30,27 @@ graders: input_match: '"skill"\s*:\s*"(?:[\w-]+:)?workstatus"' arm: with-only - # The command the skill is built around. + # The command the skill is built around. Two spellings, because 0.26.0 + # changed which one the skill names: the dispatcher in the plugin + # (`/scripts/run status`, what the skill says now) and the copy in + # the consumer's repository (`.floppy/run status`, what every consumer + # carried until then and what this fixture's stand-in still answers to). + # Grading the spelling rather than the act is what made this grader red on a + # change that was about nothing else; either path is the command being run. + # + # Widening it does NOT make the case passable again. The model now runs the + # real dispatcher, which never reaches the stand-in this scaffold writes to + # .floppy/run, so the invented fact the other graders look for is not + # printed. The fixture work that would fix it is named in evals/README.md; + # these cases are unrun by decision (2026-09-17) and nothing turned red. - type: tool_used - name: the-shim-was-actually-run + name: the-dispatcher-was-actually-run tool: Bash - input_match: '"command"\s*:\s*"[^"]*\.floppy/run[^"]*status' + input_match: '"command"\s*:\s*"[^"]*(?:scripts/run|\.floppy/run)[^"]*status' min: 1 weight: 3 - # `corpus-reindex` is printed by the shim and appears nowhere in the + # `corpus-reindex` is printed by the run and appears nowhere in the # repository's documents. An answer that names it was built from the run; an # answer that does not was built from something else. - type: regex @@ -74,7 +86,7 @@ graders: events as if true is not. expected_outcome: | - `bash .floppy/run status` is run, and the report is a retelling of what it + floppy's `status` verb is run, and the report is a retelling of what it printed — the running `corpus-reindex` job, the `fix/lock-takeover` branch, three uncommitted files — with the stale document either unmentioned or named as stale. diff --git a/scripts/init.sh b/scripts/init.sh index b6f19ee..153c0b9 100755 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -4,7 +4,6 @@ # bash scripts/init.sh --repo --memory-dir --language # # What it creates in the target repository: -# .floppy/run copied from this checkout — the ONLY file copied # .floppy/config flat key=value, memory_dir/memory_language set # /MEMORY.md the empty router, so `lint` is green immediately # .gitignore gains "//common/", and @@ -19,6 +18,15 @@ # AGENTS.md gains a section naming .floppy/ and pointing at # agent-memory # +# What it deliberately does NOT create, since 0.26.0: a runner inside the +# repository. Until then init's first act was to copy shim/run to .floppy/run, +# and that copy was the consumer's only way to find the plugin. The harness +# now states the plugin root to the agent in-band when it loads a skill +# (measured 2026-09-22), so the verbs are called at /scripts/run and +# the repository carries data — .floppy/config — and no code. A leftover +# .floppy/run from an older init still works, because shim/run still ships; +# nothing here reads it, and the report at the end names it once. +# # What it deliberately does NOT create on an EMPTY memory: /quota.lock. # There is nothing to measure, and a ceiling copied from another project is that # project's ceiling, which is the same as no ceiling at all. `memory-lint.sh` @@ -88,27 +96,21 @@ fi repo="$(cd "$repo_arg" && pwd)" # Where this script itself lives, i.e. the plugin checkout — one level above -# scripts/. This is how the shim source is found: whoever calls this script -# (the init skill, or a test with AI_FLOPPY_HOME) passes a $0 that already -# points at the right checkout, so no further search is needed here. +# scripts/. Whoever calls this script (the init skill, or a test) passes a $0 +# that already points at the right checkout, so nothing is searched for here. +# It is also what the target repository gets told to call: the dispatcher +# beside this file. +unset CDPATH # see scripts/run: it would print into the substitutions below self_dir="$(cd "$(dirname "$0")" && pwd)" plugin_root="$(cd "$self_dir/.." && pwd)" -shim_src="$plugin_root/shim/run" -[[ -f "$shim_src" ]] || { echo "x no shim at $shim_src — is this run from a floppy checkout?" >&2; exit 2; } +floppy_run="bash $plugin_root/scripts/run" echo "repository: $repo" echo "memory_dir: $mem_dir" echo "language: $language" echo -# ---------- .floppy/run ---------- -# The one file this plugin copies. Always refreshed from the plugin's own -# copy: it's meant to track the plugin, and an unchanged source produces an -# unchanged file, which is what idempotence actually requires. mkdir -p "$repo/.floppy" -cp "$shim_src" "$repo/.floppy/run" -chmod +x "$repo/.floppy/run" -echo "ok .floppy/run" # ---------- .floppy/config ---------- # Written into the generated config only when asked for, and commented out @@ -117,7 +119,7 @@ echo "ok .floppy/run" if [[ -n "$public_repo" ]]; then store_cfg=" # This repository does not hold its own memory: memory_dir is a symlink into -# the store below. Wire it on each machine with \"bash .floppy/run store\". +# the store below. Wire it on each machine with floppy's \"store\" verb. public_repo=$public_repo # One key, not two: project_key is the default of both memory_project_key and # workplace_project_key, so this project is named the same in every store it @@ -130,7 +132,7 @@ else store_cfg=" # public_repo/project_key host the memory in ANOTHER repository, for a # code repository that cannot hold agent notes at all. Set both, then run -# \"bash .floppy/run store\" once per machine and per worktree. +# floppy's \"store\" verb once per machine and per worktree. # public_repo=git@example.com:workplace/agents-memory.git # project_key names this project in every store it uses; memory_project_key and # workplace_project_key override it per scope, and are rarely needed. @@ -151,7 +153,7 @@ agents_memory_dir=$agents_memory_dir} $store_cfg # private_repo and workplace_project_key have no default on purpose: a # fresh project must not silently write into somebody else's private memory. -# Set both to use "bash .floppy/run workplace". +# Set both to use floppy's "workplace" verb. # private_repo=git@example.com:workplace/agents-memory.git # workplace_project_key=your-project-key # Checkout paths are derived: agents_memory_dir (default $HOME/agents_memory) @@ -159,7 +161,7 @@ $store_cfg # for this repository only, and is rarely needed. # workplace_memory_dir=/path/to/your/workplace-memory-checkout -# commit_push controls what "bash .floppy/run commit" does after committing: +# commit_push controls what floppy's "commit" verb does after committing: # auto (default) pulls --rebase then pushes, same as always. A repository # with no upstream configured fails that every time — set commit_push=never # to keep every commit local instead (the --no-push flag does this per call). @@ -189,11 +191,12 @@ fi # — correctly, since it never decides the fate of files somebody wrote. if [[ -n "$public_repo" ]]; then echo - # A subshell with the target repository as the working directory: the shim - # derives FLOPPY_REPO from `git rev-parse`, and init's own cwd is the plugin - # checkout, so a bare call would wire the plugin instead of the consumer. - if ( cd "$repo" && AI_FLOPPY_HOME="${AI_FLOPPY_HOME:-$(cd "$self_dir/.." && pwd)}" \ - bash "$repo/.floppy/run" store ); then + # A subshell with the target repository as the working directory: the + # dispatcher derives FLOPPY_REPO from `git rev-parse`, and init's own cwd is + # the plugin checkout, so a bare call would wire the plugin instead of the + # consumer. The dispatcher is called by its own path — there is nothing in + # the target repository to call, and this is the same call the skills make. + if ( cd "$repo" && bash "$plugin_root/scripts/run" store ); then echo else echo "x wiring the store failed — nothing else was created" >&2 @@ -319,15 +322,35 @@ $marker ## Agent memory This repository uses the \`floppy\` plugin for its session ritual and its -durable memory. The entry point is \`.floppy/run\` — see \`agent-memory\` -for what a note looks like and how the memory is laid out, and -\`start\` / \`workstatus\` / \`wrap\` for the three rites -built on top of it. Settings live in \`.floppy/config\`; the memory itself is -under \`$mem_dir\`. +durable memory. Its verbs are run from the plugin, not from this repository: +\`bash /scripts/run \`, where \`\` is two directories +above the base directory the harness states when it loads a floppy skill +(\`Base directory for this skill: /skills/start\`). Nothing here has +to be kept in step with the plugin — \`.floppy/\` holds \`config\` and +nothing else. See \`agent-memory\` for what a note looks like and how the +memory is laid out, and \`start\` / \`workstatus\` / \`wrap\` for the +three rites built on top of it. Settings live in \`.floppy/config\`; the +memory itself is under \`$mem_dir\`. EOF echo "ok AGENTS.md: floppy section added" fi +# Checked after both branches, and phrased for the whole file, because that is +# what the grep can see: prose written before 0.26.0 names `.floppy/run` as the +# entry point, and a section appended just now does not correct a mention +# somewhere else in the file. Rewriting somebody's AGENTS.md from a script is +# not init's business — idempotence here means leaving prose alone — but a +# silent stale instruction is how the old path survives the change. `` +# is printed literally: this is text for a file that travels between machines, +# not a command to run now, and the agent resolves it when it reads the line. +if grep -q '\.floppy/run' "$agents"; then + echo "! AGENTS.md still names .floppy/run, which nothing calls since 0.26.0." + echo " The verbs are run from the plugin now:" + echo " bash /scripts/run " + echo " being two directories above the base directory the harness" + echo " states when it loads a floppy skill. Update the text by hand." +fi + # ---------- an existing corpus: measure it, never rewrite it ---------- # The counterpart to the header's rule about quota.lock, for the adoption case. # @@ -376,7 +399,7 @@ if [[ "${existing_notes:-0}" -gt 0 ]]; then chars_cap=$(( (total * 11 / 10 + 4999) / 5000 * 5000 )) cat > "$lock" </dev/null 2>&1; then else echo "! not wired on this machine yet — memory would land in a second copy" echo " under ~/.claude, silently. From $repo run:" - echo " bash .floppy/run link" + echo " $floppy_run link" +fi + +# ---------- a runner left over from an older init ---------- +# Not deleted: it is a tracked file in somebody else's repository, and it still +# works — shim/run ships, and a copy of it finds the plugin exactly as before. +# What it no longer is, is the way anything here calls floppy. Naming it once +# is what keeps a repository from carrying a file nobody maintains by accident. +if [[ -f "$repo/.floppy/run" ]]; then + echo + echo "! .floppy/run is left over from an init before 0.26.0." + echo " Nothing reads it any more — the verbs are called at" + echo " $plugin_root/scripts/run, and the skills say so. It still works if" + echo " something of yours calls it. To drop it: git rm .floppy/run" fi echo diff --git a/scripts/memory-heat.sh b/scripts/memory-heat.sh index 5922f7d..b1e75cc 100644 --- a/scripts/memory-heat.sh +++ b/scripts/memory-heat.sh @@ -24,6 +24,18 @@ # UTC date, same as metadata.as_of: a local-evening stamp is tomorrow for the # CI that reads it, and the memory already paid for that lesson once. set -uo pipefail + +# How a hint spells a floppy command. The consumer's repository holds no runner +# of its own since 0.26.0, so a message names this plugin's dispatcher by its +# absolute path — pasteable from wherever the reader is standing. scripts/run +# exports FLOPPY_RUN; a direct call (the tests make them) derives the same +# value from this script's own location. +unset CDPATH # see scripts/run: it would print into the substitutions below +floppy_run="${FLOPPY_RUN:-}" +# Quoted unconditionally, where scripts/run quotes only a path that needs it: +# this branch is reached only by a direct call, and there quotes are cheaper +# than the broken command an unquoted path with a space in it produces. +[[ -n "$floppy_run" ]] || floppy_run="bash \"$(cd "$(dirname "$0")" && pwd)/run\"" # The cd is guarded because this script writes: a stale FLOPPY_REPO landing # in `pwd` would append the log into whatever repository the shell happens to # sit in, with rc 0 (review 2026-09-13, finding 8). @@ -41,7 +53,7 @@ MAX_LINES=5000 KEEP_LINES=4000 if [[ $# -eq 0 ]]; then - echo "usage: bash .floppy/run heat [...]" >&2 + echo "usage: $floppy_run heat [...]" >&2 echo " Call it when a note is actually opened, with the note's slug" >&2 echo " (the filename without .md). The log feeds lint's cold-note report." >&2 exit 2 @@ -50,8 +62,8 @@ fi # The ignore line goes into .git/info/exclude, never the consumer's # .gitignore: the first release of this verb edited .gitignore, and the first # call left the tree permanently dirty on a file wrap's guard refuses to -# commit — `bash .floppy/run guard .gitignore` exits 1, so every wrap after -# the first read "won't commit: .gitignore" forever (review 2026-09-13, +# commit — `guard .gitignore` exits 1, so every wrap after the first +# read "won't commit: .gitignore" forever (review 2026-09-13, # finding 1). The exclude file is machine-local like the log itself, which is # also why writing it needs no one's review. The check is per-file — the log # AND the rotation temp file — because a consumer's own `*.log` covers the diff --git a/scripts/memory-link.sh b/scripts/memory-link.sh index 7a0b974..a216df9 100755 --- a/scripts/memory-link.sh +++ b/scripts/memory-link.sh @@ -20,8 +20,8 @@ # The script is idempotent: a second run on a configured machine changes nothing. # Output is English on purpose: the tool is reusable, the memory is not. # -# bash .floppy/run link wire it up (idempotent) -# bash .floppy/run link --check report only, change nothing +# bash /scripts/run link wire it up (idempotent) +# bash /scripts/run link --check report only, change nothing # # --check exists so that a status report can ask the question without being # able to answer it. This is the one wiring step whose absence is silent: an @@ -30,6 +30,18 @@ # in exactly one place — here — so a checker must call this script rather than # repeat the rule and drift from it. set -uo pipefail + +# How a hint spells a floppy command. The consumer's repository holds no runner +# of its own since 0.26.0, so a message names this plugin's dispatcher by its +# absolute path — pasteable from wherever the reader is standing. scripts/run +# exports FLOPPY_RUN; a direct call (the tests make them) derives the same +# value from this script's own location. +unset CDPATH # see scripts/run: it would print into the substitutions below +floppy_run="${FLOPPY_RUN:-}" +# Quoted unconditionally, where scripts/run quotes only a path that needs it: +# this branch is reached only by a direct call, and there quotes are cheaper +# than the broken command an unquoted path with a space in it produces. +[[ -n "$floppy_run" ]] || floppy_run="bash \"$(cd "$(dirname "$0")" && pwd)/run\"" cd "${FLOPPY_REPO:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" check_only=0 @@ -104,7 +116,7 @@ if [[ $check_only -eq 1 ]]; then echo "x a real directory sits where the memory symlink belongs ($link) — forked memory, sort it out by hand" exit 1 else - echo "x memory is not wired on this machine — run: bash .floppy/run link" + echo "x memory is not wired on this machine — run: $floppy_run link" exit 1 fi fi diff --git a/scripts/memory-store.sh b/scripts/memory-store.sh index 2de9518..8d60fcf 100644 --- a/scripts/memory-store.sh +++ b/scripts/memory-store.sh @@ -20,13 +20,25 @@ # a real directory standing where the symlink belongs — that is memory somebody # wrote, and a human decides what happens to it. # -# bash .floppy/run store wire it up (idempotent) -# bash .floppy/run store --check report only, change nothing +# bash /scripts/run store wire it up (idempotent) +# bash /scripts/run store --check report only, change nothing # # Requires public_repo and memory_project_key in .floppy/config. Neither has a # default: a repository that never opted in must not silently write into # somebody else's store. set -uo pipefail + +# How a hint spells a floppy command. The consumer's repository holds no runner +# of its own since 0.26.0, so a message names this plugin's dispatcher by its +# absolute path — pasteable from wherever the reader is standing. scripts/run +# exports FLOPPY_RUN; a direct call (the tests make them) derives the same +# value from this script's own location. +unset CDPATH # see scripts/run: it would print into the substitutions below +floppy_run="${FLOPPY_RUN:-}" +# Quoted unconditionally, where scripts/run quotes only a path that needs it: +# this branch is reached only by a direct call, and there quotes are cheaper +# than the broken command an unquoted path with a space in it produces. +[[ -n "$floppy_run" ]] || floppy_run="bash \"$(cd "$(dirname "$0")" && pwd)/run\"" cd "${FLOPPY_REPO:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" repo="$(pwd)" @@ -193,10 +205,10 @@ fi # ---------- what is not pushed ---------- ahead="$(git -C "$dir" rev-list --count '@{u}..HEAD' 2>/dev/null || echo '?')" dirty="$(git -C "$dir" status --porcelain | wc -l | tr -d ' ')" -[[ "$dirty" != "0" ]] && echo "! $dirty uncommitted change(s) in $dir — bash .floppy/run commit closes them" +[[ "$dirty" != "0" ]] && echo "! $dirty uncommitted change(s) in $dir — $floppy_run commit closes them" [[ "$ahead" != "0" && "$ahead" != "?" ]] && echo "! $ahead commit(s) not pushed in $dir — the next machine cannot see them" echo -echo "next: bash .floppy/run link (the harness's memory directory, per machine and per worktree)" +echo "next: $floppy_run link (the harness's memory directory, per machine and per worktree)" # The project scope is wired and proven above; a common scope that refused is # reported by its own message and carried out in the exit code, so a script # calling this verb does not read "ok" over a half-wired memory. diff --git a/scripts/memory-workplace.sh b/scripts/memory-workplace.sh index 4ef4223..e503a61 100755 --- a/scripts/memory-workplace.sh +++ b/scripts/memory-workplace.sh @@ -17,9 +17,12 @@ # # Output is English on purpose: the tool is reusable, the memory is not. # -# bash .floppy/run workplace wire it up (idempotent) -# bash .floppy/run workplace --migrate-local plan the migration, change nothing -# bash .floppy/run workplace --migrate-local --apply carry the plan out +# bash /scripts/run workplace +# wire it up (idempotent) +# bash /scripts/run workplace --migrate-local +# plan the migration, change nothing +# bash /scripts/run workplace --migrate-local --apply +# carry the plan out # # --migrate-local exists for the machine that lagged behind: there `local/` is # still a real directory of notes, written before the workplace repository @@ -32,6 +35,18 @@ # or cross/ is a judgement about where the fact is true, and a script that # guessed it would file things where nobody looks for them. set -uo pipefail + +# How a hint spells a floppy command. The consumer's repository holds no runner +# of its own since 0.26.0, so a message names this plugin's dispatcher by its +# absolute path — pasteable from wherever the reader is standing. scripts/run +# exports FLOPPY_RUN; a direct call (the tests make them) derives the same +# value from this script's own location. +unset CDPATH # see scripts/run: it would print into the substitutions below +floppy_run="${FLOPPY_RUN:-}" +# Quoted unconditionally, where scripts/run quotes only a path that needs it: +# this branch is reached only by a direct call, and there quotes are cheaper +# than the broken command an unquoted path with a space in it produces. +[[ -n "$floppy_run" ]] || floppy_run="bash \"$(cd "$(dirname "$0")" && pwd)/run\"" cd "${FLOPPY_REPO:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" migrate=0 @@ -142,7 +157,7 @@ elif [[ -e "$link" ]]; then if [[ $migrate -eq 0 ]]; then echo "x a real directory sits where the symlink belongs. Memory files in it: $n." echo " This is the lagging-machine case. To see what a migration would do:" - echo " bash .floppy/run workplace --migrate-local" + echo " $floppy_run workplace --migrate-local" echo " Nothing is moved until you add --apply." exit 1 fi @@ -192,7 +207,7 @@ elif [[ -e "$link" ]]; then fi if [[ $apply -eq 0 ]]; then echo - echo " to carry this out: bash .floppy/run workplace --migrate-local --apply" + echo " to carry this out: $floppy_run workplace --migrate-local --apply" echo echo " 'move' is not the end of the job. Everything lands in projects//" echo " because that is what local/ was; deciding that a note actually belongs" @@ -258,7 +273,7 @@ for legacy_name in local; do done # ---------- the link itself must not be committed ---------- -# With a store (`bash .floppy/run store`), memory_dir is already a symlink, so +# With a store (the `store` verb), memory_dir is already a symlink, so # this link is created INSIDE the store's working tree. It holds an absolute # path, and committed it dangles on any machine whose checkout lives at another # path — measured 2026-08-25, together with the recursion it opens diff --git a/scripts/run b/scripts/run old mode 100644 new mode 100755 index e5f07ac..895a261 --- a/scripts/run +++ b/scripts/run @@ -1,26 +1,66 @@ #!/usr/bin/env bash # The dispatcher: one verb in, one script out. Lives in the PLUGIN and is -# reached by `.floppy/run`, the stub this plugin copies into a consumer -# repository, which does nothing but locate the plugin and exec this file. +# called directly: # -# Why the split (0.14.0). The stub is a COPY, carried by the consumer's git and -# untouched by `plugin update`, so everything inside it goes stale silently and -# has to be re-copied by hand. Measured over the first 24 commits that touched -# it: at least 7 changed only the config parser or this verb table — that is, -# a third of the refreshes it ever demanded of every consumer bought them -# nothing they could not have had for free. Both of those parts now live here, -# where an update delivers them. What CANNOT move is finding the plugin in the -# first place, which is the whole of what the stub still does. +# bash /scripts/run # -# The direction of the staleness therefore reverses, and improves. A NEW VERB -# no longer needs a fresh stub: it appears here, and every repository that has -# ever run `init` can call it. What a fresh stub is still needed for is a -# change to how the plugin is located — and that failure is loud (`plugin not -# found`), never silent. +# Every skill this plugin ships is handed its own absolute base directory when +# the harness loads it (measured 2026-09-22: "Base directory for this skill: +# /skills/"), so the caller already knows where the plugin is, +# and this file is two directories above that. +# +# Why there is no copy in the consumer repository (0.26.0). Until then the call +# went through `.floppy/run`, a COPY of shim/run carried by the consumer's git +# and untouched by `plugin update`, whose whole job was to find the plugin. It +# went stale silently: measured over the first 24 commits that touched it, at +# least 7 changed only the config parser or this verb table, so a third of the +# refreshes it demanded of every consumer bought them nothing. The verb table +# and the parser moved here in 0.14.0; 0.26.0 removed the copy itself, because +# a caller that is TOLD where the plugin is needs no file in the repository to +# compute it. +# +# `shim/run` still ships, for the consumers who already carry a copy and for a +# call with no stated root to work from. Nothing here depends on it. # # Runs on macOS bash 3.2: no mapfile, no declare -A, no GNU-only flags. set -uo pipefail +# ---------- where the plugin is: this file's own directory, one level up ---- +# The file whose location is wanted is the file being executed, so there is +# nothing to search for and no variable to require. Measured 2026-09-22: with +# FLOPPY_ROOT unset, a bare `bash /scripts/run status` died at this +# file's first use of it — `FLOPPY_ROOT: unbound variable`, a raw bash error — +# which is why the call used to have to go through the consumer's copy. +# +# Derived unconditionally, not merely when unset: a FLOPPY_ROOT naming some +# OTHER copy of the plugin would send every verb into that copy's scripts while +# this one dispatches, which is the "a script that had just been fixed was +# reported as still broken" failure shim/run's own header records. The path a +# call was made through is the truth about which copy is running. +# CDPATH, exported with a relative entry in it, makes `cd` print the directory +# it found on stdout. Inside the substitution below that lands in FLOPPY_ROOT, +# which then holds two lines and sources nothing. Measured 2026-09-25, 3/3: +# with CDPATH=.:/tmp, `bash scripts/run status` — the relative spelling +# CLAUDE.md documents — died in lib-config.sh and blamed the install +# ("Reinstall the plugin"), which was not the cause. Unset rather than worked +# around here, because it is then gone from what every verb inherits too. +unset CDPATH + +FLOPPY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)" +export FLOPPY_ROOT + +# How a message spells a floppy command. Every hint a verb prints names this +# dispatcher by its absolute path, because the consumer's repository no longer +# holds a short relative name for it — and an absolute path is pasteable from +# whatever directory the reader happens to be standing in. Quoted only when it +# has to be: quotes in every hint are noise, and an unquoted path with a space +# in it is a broken command. +case "$FLOPPY_ROOT" in + *[[:space:]]*) FLOPPY_RUN="bash \"$FLOPPY_ROOT/scripts/run\"" ;; + *) FLOPPY_RUN="bash $FLOPPY_ROOT/scripts/run" ;; +esac +export FLOPPY_RUN + # Loud on purpose: every verb below trusts FLOPPY_REPO as the root all its # paths are relative to. A silent `|| pwd` fallback here would mean a run # from outside any git repository quietly derives every downstream path — @@ -62,7 +102,7 @@ run_script() { # script-name, then its args fi # $BASH, not a bare `bash`: whoever chose an interpreter for this dispatcher # chose it for the verb too. A bare `bash` re-resolves through PATH, where a - # Homebrew bash 5 sits ahead of /bin/bash on macOS — so `/bin/bash .floppy/run + # Homebrew bash 5 sits ahead of /bin/bash on macOS — so `/bin/bash scripts/run # status` ran memory-lint.sh and workstatus.sh on 5.x, and CI's macos-bash-3-2 # job tested 3.2 for the test files and nothing else (measured 2026-09-08). exec "${BASH:-bash}" "$s" "$@" @@ -83,8 +123,8 @@ case "$verb" in status) name_repo; run_script workstatus.sh "$@" ;; check) name_repo; run_script wrap-check.sh "$@" ;; commit) name_repo; run_script wrap-commit.sh "$@" ;; - ""|help) echo "usage: bash .floppy/run {env|lint|link|workplace|store|guard|heat|lock|status|check|commit}" ;; - # Until 0.14.0 this table lived in the consumer's `.floppy/run`, and an + ""|help) echo "usage: $FLOPPY_RUN {env|lint|link|workplace|store|guard|heat|lock|status|check|commit}" ;; + # Until 0.14.0 this table lived in the consumer's copy of the shim, and an # unknown verb there was as likely to mean a stale copy as a typo — the two # were indistinguishable from inside the file. Measured the first time a verb # was added after extraction (`parity`, 2026-08-25): the plugin was current, diff --git a/scripts/workstatus.sh b/scripts/workstatus.sh index 9626755..4efc397 100755 --- a/scripts/workstatus.sh +++ b/scripts/workstatus.sh @@ -12,8 +12,8 @@ # too hard and the report stops catching the thing it exists for — a stuck # process, a divergence from origin, a stray hook. # -# bash .floppy/run status -# bash .floppy/run status --flow # plus the state of the process half +# bash /scripts/run status +# bash /scripts/run status --flow # plus the process half's state # # The project half of this report — anything that names this particular # project's data or stands (servers, corpora, a build system) — is not here. @@ -23,6 +23,18 @@ # `ps --no-headers`, `ss`, or `timeout` without a fallback — all GNU-only, and # on a Mac the report would otherwise turn silently into a wall of errors. set -uo pipefail + +# How a hint spells a floppy command. The consumer's repository holds no runner +# of its own since 0.26.0, so a message names this plugin's dispatcher by its +# absolute path — pasteable from wherever the reader is standing. scripts/run +# exports FLOPPY_RUN; a direct call (the tests make them) derives the same +# value from this script's own location. +unset CDPATH # see scripts/run: it would print into the substitutions below +floppy_run="${FLOPPY_RUN:-}" +# Quoted unconditionally, where scripts/run quotes only a path that needs it: +# this branch is reached only by a direct call, and there quotes are cheaper +# than the broken command an unquoted path with a space in it produces. +[[ -n "$floppy_run" ]] || floppy_run="bash \"$(cd "$(dirname "$0")" && pwd)/run\"" here="$(cd "$(dirname "$0")" && pwd)" cd "${FLOPPY_REPO:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" repo="$(pwd)" @@ -177,9 +189,9 @@ if [[ -n "${FLOPPY_WORKPLACE_REPO:-}" ]]; then # it keeps this section honest on a repository mid-migration. priv="${FLOPPY_MEMORY_PRIVATE_DIR:-${FLOPPY_MEMORY_LOCAL_DIR:-private}}" if [[ ! -d "$wp/.git" ]]; then - echo " not wired: no $wp — bash .floppy/run workplace" + echo " not wired: no $wp — $floppy_run workplace" elif [[ ! -L "$mem_dir/$priv" ]]; then - echo " repository exists, but $mem_dir/$priv is not a symlink — bash .floppy/run workplace" + echo " repository exists, but $mem_dir/$priv is not a symlink — $floppy_run workplace" else wp_dirty=$(git -C "$wp" status --porcelain | wc -l | tr -d ' ') wp_ahead=$(git -C "$wp" rev-list --count '@{u}..HEAD' 2>/dev/null || echo '?') @@ -269,7 +281,7 @@ if [[ $FLOW -eq 1 ]]; then lint_n=$(echo "$lint_out" | grep -cE '^ x') echo " MEMORY LINT IS RED, $lint_n problem(s) — fix before touching memory further" echo "$lint_out" | grep -E '^ x' | head -8 | sed 's/^/ /' - [[ "$lint_n" -gt 8 ]] && echo " ... and $((lint_n - 8)) more: bash .floppy/run lint" + [[ "$lint_n" -gt 8 ]] && echo " ... and $((lint_n - 8)) more: $floppy_run lint" fi # Warnings print in both branches: on a red run they would otherwise vanish # along with the green branch, and a note over its cap is a fact about the @@ -327,12 +339,12 @@ if [[ $FLOW -eq 1 ]]; then hr "process: lock and worktrees" echo " wrap lock: $(bash "$here/wrap-lock.sh" status 2>&1 | head -1)" # An extra worktree is a separate memory directory, and without its own - # bash .floppy/run link a session there writes memory past the repository, + # `link` a session there writes memory past the repository, # silently. So the list prints only when there is more than one — a single # worktree is the normal case, not news. wt_n=$(git worktree list 2>/dev/null | wc -l | tr -d ' ') if [[ "${wt_n:-1}" -gt 1 ]]; then - echo " worktrees: $wt_n — each one needs its own bash .floppy/run link" + echo " worktrees: $wt_n — each one needs its own $floppy_run link" git worktree list | sed 's/^/ /' else echo " worktrees: one, none extra" diff --git a/scripts/wrap-check.sh b/scripts/wrap-check.sh index 416f969..5e899b8 100644 --- a/scripts/wrap-check.sh +++ b/scripts/wrap-check.sh @@ -21,17 +21,29 @@ # Output is English on purpose: the wrap-* and memory-* scripts are portable # and will one day leave for a project of their own. The memory is not. # -# bash .floppy/run check [file...] +# bash /scripts/run check [file...] # # Exit 0 only when the memory is clean AND the file list survives the guard. # Runs on macOS bash 3.2: no mapfile, no declare -A, no GNU-only flags. set -uo pipefail + +# How a hint spells a floppy command. The consumer's repository holds no runner +# of its own since 0.26.0, so a message names this plugin's dispatcher by its +# absolute path — pasteable from wherever the reader is standing. scripts/run +# exports FLOPPY_RUN; a direct call (the tests make them) derives the same +# value from this script's own location. +unset CDPATH # see scripts/run: it would print into the substitutions below +floppy_run="${FLOPPY_RUN:-}" +# Quoted unconditionally, where scripts/run quotes only a path that needs it: +# this branch is reached only by a direct call, and there quotes are cheaper +# than the broken command an unquoted path with a space in it produces. +[[ -n "$floppy_run" ]] || floppy_run="bash \"$(cd "$(dirname "$0")" && pwd)/run\"" here="$(cd "$(dirname "$0")" && pwd)" cd "${FLOPPY_REPO:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" if [[ $# -eq 0 ]]; then echo "no files given: pass the files this session wrote" - echo " bash .floppy/run check [file...]" + echo " $floppy_run check [file...]" exit 2 fi @@ -59,7 +71,7 @@ else lint_n=$(echo "$lint_out" | grep -cE '^ x') echo " MEMORY LINT IS RED, $lint_n problem(s) — fix yours, name someone else's and leave it" echo "$lint_out" | grep -E '^ x' | head -8 - [[ "$lint_n" -gt 8 ]] && echo " ... and $((lint_n - 8)) more: bash .floppy/run lint" + [[ "$lint_n" -gt 8 ]] && echo " ... and $((lint_n - 8)) more: $floppy_run lint" fi # Warnings print in both branches: a note over the cap is a fact about the # memory, not a consequence of an error, and it would vanish with the green @@ -72,7 +84,7 @@ guard_out="$(bash "$here/wrap-guard.sh" "$@" 2>&1)" if [[ $? -ne 0 ]]; then rc=1 echo "$guard_out" | grep -E '^ x' | head -10 - echo " (full report: bash .floppy/run guard )" + echo " (full report: $floppy_run guard )" else echo "$guard_out" | grep -E '^safe to stage' | sed 's/^/ /' fi @@ -108,7 +120,7 @@ if [[ "${FLOPPY_MEMORY_EXTERNAL:-0}" == "1" ]]; then st_ahead=$(git -C "$st" rev-list --count '@{u}..HEAD' 2>/dev/null || echo '?') [[ "$st_ahead" != "0" && "$st_ahead" != "?" ]] && echo " $st_ahead commit(s) unpushed there" # `commit` closes it too, so this is information, not a chore for the human. - echo " (bash .floppy/run commit closes this store as well as this repository)" + echo " ($floppy_run commit closes this store as well as this repository)" fi fi @@ -127,7 +139,7 @@ if [[ -n "${FLOPPY_WORKPLACE_REPO:-}" ]]; then hr "workplace memory" wp="${FLOPPY_WORKPLACE_MEMORY_DIR:-$HOME/agents_memory}" if [[ ! -d "$wp/.git" ]]; then - echo " not wired: no $wp — bash .floppy/run workplace" + echo " not wired: no $wp — $floppy_run workplace" else # Several projects share this clone, so its dirt is counted in two piles: # this project's own paths — its scope and the common namespace — are what @@ -162,7 +174,7 @@ fi printf '\n' if [[ $rc -eq 0 ]]; then - echo "ready: bash .floppy/run commit -m \"\" " + echo "ready: $floppy_run commit -m \"\" " else echo "not ready: fix the red sections above, then run this again" fi diff --git a/scripts/wrap-commit.sh b/scripts/wrap-commit.sh index 1b49416..9a6a440 100644 --- a/scripts/wrap-commit.sh +++ b/scripts/wrap-commit.sh @@ -19,8 +19,8 @@ # # Output is English on purpose: the wrap-* scripts are portable. # -# bash .floppy/run commit -m "message" [file...] -# bash .floppy/run commit -m "message" --no-push [file...] +# bash /scripts/run commit -m "message" [file...] +# bash /scripts/run commit -m "message" --no-push [file...] # # commit_push in .floppy/config (default "auto") picks what happens after the # commit: "auto" pulls --rebase then pushes, same as always; "never" skips @@ -33,6 +33,18 @@ # # Runs on macOS bash 3.2: no mapfile, no declare -A, no GNU-only flags. set -uo pipefail + +# How a hint spells a floppy command. The consumer's repository holds no runner +# of its own since 0.26.0, so a message names this plugin's dispatcher by its +# absolute path — pasteable from wherever the reader is standing. scripts/run +# exports FLOPPY_RUN; a direct call (the tests make them) derives the same +# value from this script's own location. +unset CDPATH # see scripts/run: it would print into the substitutions below +floppy_run="${FLOPPY_RUN:-}" +# Quoted unconditionally, where scripts/run quotes only a path that needs it: +# this branch is reached only by a direct call, and there quotes are cheaper +# than the broken command an unquoted path with a space in it produces. +[[ -n "$floppy_run" ]] || floppy_run="bash \"$(cd "$(dirname "$0")" && pwd)/run\"" here="$(cd "$(dirname "$0")" && pwd)" cd "${FLOPPY_REPO:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" mem_dir="${FLOPPY_MEMORY_DIR:-.agent-memory}" @@ -59,7 +71,7 @@ while [[ $# -gt 0 ]]; do done if [[ -z "$msg" ]]; then - echo "no message: bash .floppy/run commit -m \"message\" [file...]" + echo "no message: $floppy_run commit -m \"message\" [file...]" echo " the message is about the substance of the facts, not \"updated memory\"" exit 2 fi @@ -104,7 +116,7 @@ trap unlock EXIT # ---------- gates ---------- hr "gates" if ! bash "$here/memory-lint.sh" >/dev/null 2>&1; then - echo " memory lint is red — run bash .floppy/run check with your file list" + echo " memory lint is red — run $floppy_run check with your file list" exit 1 fi guard_out="$(bash "$here/wrap-guard.sh" "${files[@]}" 2>&1)" diff --git a/scripts/wrap-guard.sh b/scripts/wrap-guard.sh index dac7cde..033c53d 100755 --- a/scripts/wrap-guard.sh +++ b/scripts/wrap-guard.sh @@ -19,9 +19,21 @@ # # Exit code 0 means the list matches reality and staging it is safe. # -# bash .floppy/run guard .agent-memory/foo.md docs/statuses/2026-08-20_status.md -# printf '%s\n' "${files[@]}" | bash .floppy/run guard +# bash /scripts/run guard .agent-memory/foo.md docs/statuses/x.md +# printf '%s\n' "${files[@]}" | bash /scripts/run guard set -uo pipefail + +# How a hint spells a floppy command. The consumer's repository holds no runner +# of its own since 0.26.0, so a message names this plugin's dispatcher by its +# absolute path — pasteable from wherever the reader is standing. scripts/run +# exports FLOPPY_RUN; a direct call (the tests make them) derives the same +# value from this script's own location. +unset CDPATH # see scripts/run: it would print into the substitutions below +floppy_run="${FLOPPY_RUN:-}" +# Quoted unconditionally, where scripts/run quotes only a path that needs it: +# this branch is reached only by a direct call, and there quotes are cheaper +# than the broken command an unquoted path with a space in it produces. +[[ -n "$floppy_run" ]] || floppy_run="bash \"$(cd "$(dirname "$0")" && pwd)/run\"" cd "${FLOPPY_REPO:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" mem_dir="${FLOPPY_MEMORY_DIR:-.agent-memory}" @@ -199,7 +211,7 @@ for f in "${claimed[@]}"; do # 2026-09-18, and it cost the reader several iterations of checking their # own paths before they ran `env` — which is the one place the truth was. if [[ "$f" == "$mem_dir/$priv_dir"/* && -n "$priv_real" && -z "$priv_store" ]]; then - err "$f — the private scope was never scanned: it resolves to $priv_real, which no store covers. Your path is fine; the wiring is not (bash .floppy/run env, FLOPPY_PRIVATE_STORE)" + err "$f — the private scope was never scanned: it resolves to $priv_real, which no store covers. Your path is fine; the wiring is not ($floppy_run env, FLOPPY_PRIVATE_STORE)" else err "$f — not changed: wrong path, or the edit was lost" fi @@ -228,9 +240,9 @@ done if [[ "$external" == "0" && -e "$mem_dir" ]] && git check-ignore -q -- "$mem_dir" 2>/dev/null; then hr "memory wiring" if [[ -n "${FLOPPY_MEMORY_REPO:-}" && -n "${FLOPPY_MEMORY_PROJECT_KEY:-}" ]]; then - err "$mem_dir is inside this repository and gitignored: nothing will ever commit these notes. Either drop the ignore line, or finish the external setup so the path resolves into the store (bash .floppy/run store)" + err "$mem_dir is inside this repository and gitignored: nothing will ever commit these notes. Either drop the ignore line, or finish the external setup so the path resolves into the store ($floppy_run store)" else - err "$mem_dir is inside this repository and gitignored: nothing will ever commit these notes. Drop the ignore line so this repository commits them — or, to host them elsewhere, set public_repo and project_key in .floppy/config first, because without both keys \`bash .floppy/run store\` refuses and cannot be the fix" + err "$mem_dir is inside this repository and gitignored: nothing will ever commit these notes. Drop the ignore line so this repository commits them — or, to host them elsewhere, set public_repo and project_key in .floppy/config first, because without both keys \`$floppy_run store\` refuses and cannot be the fix" fi fi diff --git a/scripts/wrap-lock.sh b/scripts/wrap-lock.sh index 9e37223..822805b 100755 --- a/scripts/wrap-lock.sh +++ b/scripts/wrap-lock.sh @@ -40,9 +40,9 @@ # clones. This does not lock it: one lock per rite, following the memory that # every wrap writes, rather than a lock per repository the rite can touch. # -# bash .floppy/run lock acquire "denoise eval" # 0 = taken, 1 = held -# bash .floppy/run lock release -# bash .floppy/run lock status +# bash /scripts/run lock acquire "denoise eval" # 0 taken, 1 held +# bash /scripts/run lock release +# bash /scripts/run lock status # # WRAP_LOCK_MAX_AGE_MIN (default 30) — after this age the lock is considered # abandoned and can be taken over. A /wrap takes minutes, not half an hour. diff --git a/skills/agent-memory/SKILL.md b/skills/agent-memory/SKILL.md index d75cd03..fabfdbd 100644 --- a/skills/agent-memory/SKILL.md +++ b/skills/agent-memory/SKILL.md @@ -15,6 +15,18 @@ The memory lives under one configured directory — `memory_dir` in below calls that directory "the memory directory" rather than naming it, because the name is the consumer's choice, not this plugin's. +The few commands below call floppy's dispatcher, which ships with the plugin +and has no copy in this repository. The harness names this skill's own base +directory when it loads it — `Base directory for this skill: +/skills/agent-memory` — and the dispatcher is `scripts/run` two +directories above that. Write that absolute path wherever a command says +``. + +If that line is not there — a harness that does not print it — do not guess a +cache path. `$CLAUDE_PLUGIN_ROOT` and `$CURSOR_PLUGIN_ROOT` are worth trying +first, and a checkout the human can name is worth asking for; a guess whose +failure mode is running the wrong copy of the plugin is worse than stopping. + ## One fact per file A memory note holds exactly one fact. A refinement of an existing fact edits @@ -234,7 +246,8 @@ file exists. ## The heat log: what pruning decisions stand on -Opening a note is worth one line: `bash .floppy/run heat ` appends a +Opening a note is worth one line: `bash /scripts/run heat ` +appends a dated entry to `.floppy/heat.log` — machine-local, kept out of git via `.git/info/exclude` by the verb itself, outside every quota. `lint` reads it back and names the notes no session has ever reported @@ -297,14 +310,15 @@ policy separating notes from code — so `memory_dir` is a symlink into a separate git repository and is gitignored here. The first scope above is then hosted elsewhere; it is still this project's memory, not the workplace store. -Wiring that up is `bash .floppy/run store`, once per machine and per worktree; +Wiring that up is `bash /scripts/run store`, once per machine and per +worktree; `init --memory-repo … --memory-key …` does it at setup time. The state to know about is the half-done one — the ignore line added, the symlink never created — because it is comfortable: notes are written and read normally while nothing will ever commit them. `guard` fails on that combination by name. Nothing about writing a note changes: same paths, same index, same frontmatter. -Two things about **closing** a session do, and the shim handles both — `check` +Two things about **closing** a session do, and the verbs handle both — `check` prints a `memory store` section, because this repository's own diff cannot see the notes, and `commit` commits and pushes that store from the same file list. What you must not do is conclude from a clean `git status` here that the @@ -354,7 +368,7 @@ half out shrinks how often that happens — it does not close it. ## Language -Everything this plugin ships — scripts, shim output, these skills — is +Everything this plugin ships — scripts, their output, these skills — is English, because it is meant to travel between projects unmodified. The memory notes it manages are not: their language is the consumer's own choice, set once as `memory_language` in `.floppy/config`. Neither of those diff --git a/skills/consolidate/SKILL.md b/skills/consolidate/SKILL.md index b463729..a92c670 100644 --- a/skills/consolidate/SKILL.md +++ b/skills/consolidate/SKILL.md @@ -37,6 +37,18 @@ the reason it is not part of `wrap`: closing is the costliest place to think. ## The pass +The commands below call floppy's dispatcher, which ships with the plugin and +has no copy in this repository. The harness names this skill's own base +directory when it loads it — `Base directory for this skill: +/skills/consolidate` — and the dispatcher is `scripts/run` two +directories above that. Write that absolute path wherever a command says +``. + +If that line is not there — a harness that does not print it — do not guess a +cache path. `$CLAUDE_PLUGIN_ROOT` and `$CURSOR_PLUGIN_ROOT` are worth trying +first, and a checkout the human can name is worth asking for; a guess whose +failure mode is running the wrong copy of the plugin is worse than stopping. + 1. **Pick one half.** The one `lint` warned about, or the largest in its by-half breakdown. One half is a bounded read and a bounded diff; "the whole memory" is how a consolidation session becomes the bloat it was @@ -45,7 +57,7 @@ the reason it is not part of `wrap`: closing is the costliest place to think. frame every proposal below must fit. 2. **Read the half whole**: its `INDEX.md` (and sub-indexes), then every note - it points to. Log the reads honestly — `bash .floppy/run heat ` + it points to. Log the reads honestly — `bash /scripts/run heat ` in one call — this rite is the one reader for which "I opened everything" is true. @@ -78,18 +90,18 @@ the reason it is not part of `wrap`: closing is the costliest place to think. 5. **Apply what was approved**, by the standing rules: one fact per file; the index pointer moves in the same edit as its note; `[[links]]` to a merged-away slug are re-pointed at the survivor; the whole-index rewrite - happens under the wrap lock (`bash .floppy/run lock acquire consolidate`, + happens under the wrap lock (`bash /scripts/run lock acquire consolidate`, release after), because an index rewrite is where a second writer silently loses work. -6. **Re-run `bash .floppy/run lint`.** If the corpus now fits its ceiling, +6. **Re-run `bash /scripts/run lint`.** If the corpus now fits its ceiling, the ceiling stays where it is — a successful consolidation that still ends in a raise is two contradictory claims in one commit. If nothing fit-worthy was found and the ceiling still binds, the raise follows the ratchet rule: same commit as the material that needs the room, reason in the message, this rite's empty-handed pass named in it. -7. **Close through the shim** — `check`, then `commit` — the same as any +7. **Close through the dispatcher** — `check`, then `commit` — the same as any session that touched the memory. In a store layout the notes move with the store section, and a clean `git status` here proves nothing. diff --git a/skills/init/SKILL.md b/skills/init/SKILL.md index fd82580..d44ceee 100644 --- a/skills/init/SKILL.md +++ b/skills/init/SKILL.md @@ -1,6 +1,6 @@ --- name: init -description: Lay the floppy memory out in this repository — copy the shim to .floppy/run, write .floppy/config, create the memory skeleton, gitignore the local scope, and point AGENTS.md at agent-memory. Idempotent, safe to run again. Use once per repository, when setting the plugin up for the first time, or when the user asks to init, set up, or bootstrap floppy here. +description: Lay the floppy memory out in this repository — write .floppy/config, create the memory skeleton, gitignore the local scope, and point AGENTS.md at agent-memory. Idempotent, safe to run again. Use once per repository, when setting the plugin up for the first time, or when the user asks to init, set up, or bootstrap floppy here. --- # Init @@ -29,66 +29,30 @@ Several projects can be open in the same harness at once (Cursor especially), and the shell a skill runs in is not necessarily the one the human was talking about. One line naming it is enough. -Neither harness is trusted to hand a skill the plugin root, so do not rely on -a variable alone. Claude Code sets `CLAUDE_PLUGIN_ROOT` and Cursor sets -`CURSOR_PLUGIN_ROOT` (measured 2026-08-26), but each only in some contexts, and -a variable a harness did not set is indistinguishable here from one it did. -Locate the plugin **all six ways** `.floppy/run` locates it once installed — -the two harness variables, then `AI_FLOPPY_HOME` for development, then the -Claude Code cache, then Cursor's local symlink, then Cursor's cache — and fail -loudly, naming the install command, if none resolves. All six, not the first -four: on 2026-09-09 this block carried the Claude cache and stopped, so a -Cursor user whose `CURSOR_PLUGIN_ROOT` the harness had not set was told -"plugin not found" for a plugin `.floppy/run` would have resolved through -`cursor_local` — which on the owner's own machine is the branch that answers, -because the Cursor cache directory there exists and is empty. This block -duplicates the `has_scripts` chain in `shim/run` rather than reading it from -there, because `.floppy/run` does not exist yet in this repository — -creating it is the first thing the script below does — and that search -normally lives in the one file this plugin copies into a consumer, which has -to stay self-contained. `tests/test-init-bootstrap.sh` extracts this block and -runs it against each branch, so the two drift apart loudly rather than -silently. Run: +The script is in the plugin, which is where this skill itself came from. The +harness states this skill's own base directory when it loads it — `Base +directory for this skill: /skills/init` — so the plugin root is two +directories above that, and the script is `/scripts/init.sh`. Write +that absolute path: ```bash -has_scripts() { [[ -n "${1:-}" ]] && ls "$1"/scripts/*.sh >/dev/null 2>&1; } - -# A cache directory that exists but holds no scripts/*.sh is not a candidate, -# and each cache is read the way its own names allow: Claude Code's last -# segment is a version, so `sort -V`; Cursor's is a commit SHA, where any -# lexicographic sort orders by hash value rather than recency, so `ls -dt`. -claude_cache="$(ls -d "$HOME"/.claude/plugins/cache/*/floppy/*/ 2>/dev/null | sort -V | tail -n1)" -cursor_local="$HOME/.cursor/plugins/local/floppy" -cursor_cache="$(ls -dt "$HOME"/.cursor/plugins/cache/*/floppy/*/ 2>/dev/null | head -n1)" - -if has_scripts "${CLAUDE_PLUGIN_ROOT:-}"; then - floppy_root="$CLAUDE_PLUGIN_ROOT" -elif has_scripts "${CURSOR_PLUGIN_ROOT:-}"; then - floppy_root="$CURSOR_PLUGIN_ROOT" -elif has_scripts "${AI_FLOPPY_HOME:-}"; then - floppy_root="$AI_FLOPPY_HOME" -elif has_scripts "$claude_cache"; then - floppy_root="$claude_cache" -elif has_scripts "$cursor_local"; then - floppy_root="$cursor_local" -else - floppy_root="$cursor_cache" -fi - -if ! has_scripts "$floppy_root"; then - echo "x floppy plugin not found (a cache directory with no scripts/*.sh counts as not found)." >&2 - echo " Install it: /plugin marketplace add spscream/ai-floppy && /plugin install floppy" >&2 - echo " If it is installed, reinstall: `plugin update` is a no-op while the version is unchanged." >&2 - exit 1 -fi - -bash "$floppy_root/scripts/init.sh" \ +bash /scripts/init.sh \ --repo . --memory-dir --language ``` -This can only run this way — through the script directly, not through -`.floppy/run` — because `.floppy/run` does not exist yet in this repository; -creating it is the first thing the script does. +Until 0.26.0 this step carried a 33-line hand copy of the shim's six-way +search for the plugin, on the grounds that `init` runs before the repository +has anything that could do the finding. Measured 2026-09-22: the harness hands +the path over in-band at skill load, so the root is two directories up from a +string that is already on screen — no cache glob, no `sort -V`, no Cursor SHA +ordering to get right. The copy had been wrong once before it was removed +(four branches of six, for as long as it had existed; found 2026-09-09). + +If the base directory is genuinely not stated — a harness that does not print +it — do not guess a cache path: say so and stop. `$CLAUDE_PLUGIN_ROOT` and +`$CURSOR_PLUGIN_ROOT` are worth trying first, and a checkout the human can +name is worth asking for; both beat a search whose failure mode is running the +wrong copy of the plugin. ## 3. Report diff --git a/skills/start/SKILL.md b/skills/start/SKILL.md index 9bfbe22..18f7155 100644 --- a/skills/start/SKILL.md +++ b/skills/start/SKILL.md @@ -1,6 +1,6 @@ --- name: start -description: Orient a fresh session before touching any file — read the current-state file, the relevant half's own guidance, and that half's memory index, then verify live facts with `bash .floppy/run status` instead of trusting the documents. Use at the start of a session, when picking up a new task or branch of work, or when the user asks to start, orient, or "what's next". +description: Orient a fresh session before touching any file — read the current-state file, the relevant half's own guidance, and that half's memory index, then verify live facts with the plugin's own `status` verb instead of trusting the documents. Use at the start of a session, when picking up a new task or branch of work, or when the user asks to start, orient, or "what's next". --- # Start @@ -13,6 +13,17 @@ reads. See `agent-memory` for what a note, an index, and evidence mean; this skill only says which of them to open and in what order. +The commands below call floppy's dispatcher, which ships with the plugin and +has no copy in this repository. The harness names this skill's own base +directory when it loads it — `Base directory for this skill: +/skills/start` — and the dispatcher is `scripts/run` two directories +above that. Write that absolute path wherever a command says ``. + +If that line is not there — a harness that does not print it — do not guess a +cache path. `$CLAUDE_PLUGIN_ROOT` and `$CURSOR_PLUGIN_ROOT` are worth trying +first, and a checkout the human can name is worth asking for; a guess whose +failure mode is running the wrong copy of the plugin is worse than stopping. + ## Order 1. **Identify the half the task belongs to.** Halves are directories under @@ -57,7 +68,7 @@ only says which of them to open and in what order. sub-index within it), not the whole memory tree. Read the one or two notes that plainly relate to this task; skip the rest. - **Log each note you actually open**: `bash .floppy/run heat `, the + **Log each note you actually open**: `bash /scripts/run heat `, the slug being the filename without `.md` (several at once is fine). The log is what lets pruning lean on "was this ever read" instead of on dates — the one time usage was counted by hand, fourteen of twenty notes had @@ -77,7 +88,7 @@ only says which of them to open and in what order. 5. **Check live facts instead of trusting the documents:** ```bash - bash .floppy/run status + bash /scripts/run status ``` Documents can be stale in a way that looks identical to being current — a @@ -93,7 +104,7 @@ only says which of them to open and in what order. worktrees, recent process edits: ```bash - bash .floppy/run status --flow + bash /scripts/run status --flow ``` Run one or the other, never both: `--flow` is the plain report with the diff --git a/skills/workstatus/SKILL.md b/skills/workstatus/SKILL.md index c05a02b..b8cec72 100644 --- a/skills/workstatus/SKILL.md +++ b/skills/workstatus/SKILL.md @@ -1,6 +1,6 @@ --- name: workstatus -description: Report what is true right now, checked live rather than recalled — retells `bash .floppy/run status` and names what is waiting on the human. Use mid-session for "where are we", a live status check, before answering a question about current state, or when the user asks for workstatus. +description: Report what is true right now, checked live rather than recalled — retells the plugin's own `status` verb and names what is waiting on the human. Use mid-session for "where are we", a live status check, before answering a question about current state, or when the user asks for workstatus. --- # Workstatus @@ -17,8 +17,19 @@ kind of guess this report exists to rule out. ## What to run +The dispatcher ships with the plugin; this repository holds no copy of it. The +harness names this skill's own base directory when it loads it — `Base +directory for this skill: /skills/workstatus` — and the dispatcher is +`scripts/run` two directories above that. Write that absolute path wherever a +block below says ``. + +If that line is not there — a harness that does not print it — do not guess a +cache path. `$CLAUDE_PLUGIN_ROOT` and `$CURSOR_PLUGIN_ROOT` are worth trying +first, and a checkout the human can name is worth asking for; a guess whose +failure mode is running the wrong copy of the plugin is worse than stopping. + ```bash -bash .floppy/run status +bash /scripts/run status ``` One call collects background jobs, git state (divergence from the remote, @@ -36,7 +47,7 @@ A repository split into halves (see `agent-memory`) may also support a wrap-lock, worktrees, recent process edits): ```bash -bash .floppy/run status --flow +bash /scripts/run status --flow ``` Use it only when the task is about the process itself — on any other branch diff --git a/skills/wrap/SKILL.md b/skills/wrap/SKILL.md index ebbf489..24bc2a3 100644 --- a/skills/wrap/SKILL.md +++ b/skills/wrap/SKILL.md @@ -1,6 +1,6 @@ --- name: wrap -description: Close a session — select the facts worth keeping into memory, update the current-state file, name what's left unfinished, then check and commit through the shim. Use when a session is ending, context is about to be cleared, or the user asks to wrap up. +description: Close a session — select the facts worth keeping into memory, update the current-state file, name what's left unfinished, then check and commit through the plugin's own verbs. Use when a session is ending, context is about to be cleared, or the user asks to wrap up. --- # Wrap @@ -13,10 +13,21 @@ detail is worse than a short one. See `agent-memory` for what a note, an index, and `evidence` mean; this skill is about which facts earn a place in them and how the session closes. +The commands below call floppy's dispatcher, which ships with the plugin and +has no copy in this repository. The harness names this skill's own base +directory when it loads it — `Base directory for this skill: +/skills/wrap` — and the dispatcher is `scripts/run` two directories +above that. Write that absolute path wherever a command says ``. + +If that line is not there — a harness that does not print it — do not guess a +cache path. `$CLAUDE_PLUGIN_ROOT` and `$CURSOR_PLUGIN_ROOT` are worth trying +first, and a checkout the human can name is worth asking for; a guess whose +failure mode is running the wrong copy of the plugin is worse than stopping. + ## 0. Take the lock ```bash -bash .floppy/run lock acquire "" +bash /scripts/run lock acquire "" ``` A parallel session writes into the same current-state file and the same @@ -147,7 +158,7 @@ session may have written to them since this one started. State explicitly, in memory or the current-state file: -- background jobs and uncommitted work (`bash .floppy/run status` gives +- background jobs and uncommitted work (`bash /scripts/run status` gives both in one call); - what's waiting on the human versus what can proceed without them; - what's broken or deferred — if it stays red, say so plainly. @@ -155,7 +166,7 @@ State explicitly, in memory or the current-state file: ## 5. Check, see the diff, commit — two calls, not ten ```bash -bash .floppy/run check +bash /scripts/run check ``` Both calls print the repository they resolved as their first line @@ -168,7 +179,7 @@ whichever one the shell happened to be in rather than the one the human was talking about. One line is enough — name it, don't turn it into a ceremony. ```bash -bash .floppy/run commit -m "" +bash /scripts/run commit -m "" ``` `commit` pulls `--rebase` and pushes after committing, by default — that diff --git a/tests/test-dispatcher.sh b/tests/test-dispatcher.sh new file mode 100755 index 0000000..58a66a5 --- /dev/null +++ b/tests/test-dispatcher.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# `scripts/run` became the entry point in 0.26.0, and two things it does had +# nothing watching them. It derives FLOPPY_ROOT from its own path — the whole +# reason a skill can call it without a variable — and it hands every verb the +# string their hints paste back at the reader (FLOPPY_RUN). The rest of the +# suite calls the dispatcher by an absolute path and never reads either, so +# "what stays green if this is not wired?" answered "all of it". +# +# Two of the cases below are regressions, not inventions: an exported CDPATH +# made `cd` print its target into the command substitution and FLOPPY_ROOT +# came out two lines long (measured 2026-09-25, 3/3), and a plugin under a +# path with a space printed a hint that could not be pasted back. +set -uo pipefail +cd "$(dirname "$0")/.." +. tests/lib.sh +ROOT="$(pwd)" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +repo="$(sandbox)" + +# ---------- 1. no variable is needed, and none is believed ---------- +out="$(cd "$repo" && env -u FLOPPY_ROOT -u FLOPPY_RUN bash "$ROOT/scripts/run" env 2>&1)"; rc=$? +assert_rc "env runs with FLOPPY_ROOT unset" 0 "$rc" +assert_contains "and roots itself where it was called from" \ + "FLOPPY_ROOT=$ROOT" "$out" +assert_contains "FLOPPY_RUN names the dispatcher that answered" \ + "FLOPPY_RUN=bash $ROOT/scripts/run" "$out" + +# A FLOPPY_ROOT naming another copy must not win: the verbs would run out of +# that copy while this one dispatches — shim/run's "a script that had just +# been fixed was reported as still broken". +out="$(cd "$repo" && FLOPPY_ROOT=/nonexistent bash "$ROOT/scripts/run" env 2>&1)" +assert_contains "an inherited FLOPPY_ROOT is overridden, not honoured" \ + "FLOPPY_ROOT=$ROOT" "$out" + +# ---------- 2. CDPATH does not leak into the derived root ---------- +# CLAUDE.md documents the relative form `bash scripts/run status`, and CDPATH +# is only consulted for a path that is neither absolute nor ./-prefixed — so +# this is the one spelling that was exposed. +out="$(cd "$ROOT" && CDPATH=".:$work" bash scripts/run env 2>&1)"; rc=$? +assert_rc "a relative call under CDPATH still runs" 0 "$rc" +assert_contains "and its root is one line, not two" "FLOPPY_ROOT=$ROOT" "$out" +case "$out" in + *"No such file or directory"*) + fail "CDPATH does not break the config parser" "no such error" "$out" ;; + *) + ok "CDPATH does not break the config parser" ;; +esac + +# ---------- 3. a hint is pasteable from a path with a space ---------- +plugin="$work/pl ugin" +mkdir -p "$plugin" +cp -R "$ROOT/scripts" "$plugin/scripts" + +out="$(cd "$repo" && bash "$plugin/scripts/run" env 2>&1)" +assert_contains "a spaced plugin path is quoted in FLOPPY_RUN" \ + "FLOPPY_RUN=bash \"$plugin/scripts/run\"" "$out" + +# ---------- 4. a verb reached directly still names a real file ---------- +# The fallback in each verb, for the calls that do not come through the +# dispatcher. It is what prints when a test — or a person — runs the file. +# Quoted unconditionally here, unlike the dispatcher's own hint: this branch +# is only reached by a direct call, and there the noise costs less than a hint +# that cannot be pasted back. +out="$(cd "$repo" && env -u FLOPPY_RUN bash "$ROOT/scripts/memory-heat.sh" 2>&1)" +assert_contains "a direct verb call names the dispatcher by path" \ + "bash \"$ROOT/scripts/run\" heat" "$out" + +out="$(cd "$repo" && env -u FLOPPY_RUN bash "$plugin/scripts/memory-heat.sh" 2>&1)" +assert_contains "and the path with a space is still one argument" \ + "bash \"$plugin/scripts/run\" heat" "$out" + +# The wrap rite prints the largest group of these hints, and a hint with an +# empty $floppy_run in it reads as ` check ` — a line that looks like a +# command and is not one. Measured 2026-09-25: blanking floppy_run in all +# eight verbs turned only two assertions red, both about memory wiring. +out="$(cd "$repo" && bash "$ROOT/scripts/run" check 2>&1)" +assert_contains "the wrap hint carries a runnable command" \ + "bash $ROOT/scripts/run check " "$out" + +summary diff --git a/tests/test-external-memory.sh b/tests/test-external-memory.sh index 24ce100..3877bb2 100644 --- a/tests/test-external-memory.sh +++ b/tests/test-external-memory.sh @@ -44,7 +44,7 @@ build() { git -C "$store" remote add origin "$storeremote" git -C "$store" push -q -u origin main - repo="$(sandbox)"; cp shim/run "$repo/.floppy/run" + repo="$(sandbox)" cat > "$repo/.floppy/config" <&1)" + bash "$ROOT/scripts/run" "$@" 2>&1)" RC=$? } @@ -85,7 +85,7 @@ assert_contains "external memory is detected" "FLOPPY_MEMORY_EXTERNAL=1" "$ assert_contains "the store's git root is resolved" "FLOPPY_MEMORY_STORE=$store" "$OUT" # ---------- and does not misfire on the ordinary layout ---------- -plain="$(sandbox)"; cp shim/run "$plain/.floppy/run" +plain="$(sandbox)" mkdir -p "$plain/.agent-memory" run_in "$plain" env assert_contains "in-repo memory is not called external" "FLOPPY_MEMORY_EXTERNAL=0" "$OUT" @@ -169,9 +169,9 @@ assert_eq "no memory file landed in the code repository" "" \ # Still an equality on that line — "contains free" would pass on any output # with the word in it, including "held ... this is not free". assert_eq "the lock is released" "free" \ - "$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lock status | head -1)" + "$(cd "$repo" && bash "$ROOT/scripts/run" lock status | head -1)" assert_contains "and status names the store as its scope" "$store" \ - "$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lock status)" + "$(cd "$repo" && bash "$ROOT/scripts/run" lock status)" # ---------- a session that wrote only memory still closes ---------- # Nothing changed in the code repository, so `git add` there would have nothing @@ -207,7 +207,7 @@ esac # store that merely happens to be quiet. loose="$(cd "$(mktemp -d)" && pwd -P)" seed_memory "$loose" -repo2="$(sandbox)"; cp shim/run "$repo2/.floppy/run" +repo2="$(sandbox)" printf 'memory_dir=.agent-memory\n' > "$repo2/.floppy/config" printf '/.agent-memory\n' > "$repo2/.gitignore" ln -s "$loose" "$repo2/.agent-memory" @@ -243,7 +243,6 @@ git -C "$B/seed" push -q origin main pr="$B/repo"; mkdir -p "$pr/.floppy" "$pr/.agent-memory/half" "$pr/docs/statuses" "$B/am" git -C "$pr" init -q -b main . -cp shim/run "$pr/.floppy/run" printf 'project_key=acme\nprivate_repo=%s\nagents_memory_dir=%s\nstatuses_now=docs/statuses/NOW.md\ncommit_push=auto\n' \ "$wpremote" "$B/am" > "$pr/.floppy/config" printf '# Index\n- [Half](half/INDEX.md) — pointer\n' > "$pr/.agent-memory/MEMORY.md" @@ -261,21 +260,21 @@ git -C "$pr" -c user.email=t@t -c user.name=t commit -qm base git init -q --bare -b main "$B/code.git" git -C "$pr" remote add origin "$B/code.git"; git -C "$pr" push -q -u origin main -(cd "$pr" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run workplace >/dev/null 2>&1) +(cd "$pr" && bash "$ROOT/scripts/run" workplace >/dev/null 2>&1) assert_eq "the private scope is wired as a symlink" "link" \ "$([[ -L "$pr/.agent-memory/private" ]] && echo link || echo no)" printf -- '---\nname: private-fact\ndescription: a private fact\nmetadata:\n type: project\n evidence: read\n---\nBody.\n' \ > "$pr/.agent-memory/private/private-fact.md" -outP="$(cd "$pr" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run check .agent-memory/private/private-fact.md 2>&1)"; rcP=$? +outP="$(cd "$pr" && bash "$ROOT/scripts/run" check .agent-memory/private/private-fact.md 2>&1)"; rcP=$? assert_rc "check accepts a note written into the private scope" 0 "$rcP" case "$outP" in *"not changed: wrong path"*) fail "and does not call it unchanged" "no such line" "$outP" ;; *) ok "and does not call it unchanged" ;; esac -outC="$(cd "$pr" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "a private fact" .agent-memory/private/private-fact.md 2>&1)"; rcC=$? +outC="$(cd "$pr" && bash "$ROOT/scripts/run" commit -m "a private fact" .agent-memory/private/private-fact.md 2>&1)"; rcC=$? assert_rc "commit closes the workplace repository (rc)" 0 "$rcC" assert_contains "and says which repository took it" "workplace memory" "$outC" assert_contains "and reports the push" "pushed" "$outC" @@ -290,7 +289,7 @@ assert_eq "and its tree is clean" "" \ # to the other machine or another session, and the guard has to see it — that # is the whole reason it asks that repository at all. printf 'stray\n' > "$pr/.agent-memory/private/not-mine.md" -outS="$(cd "$pr" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run guard .agent-memory/half/a-note.md 2>&1)"; rcS=$? +outS="$(cd "$pr" && bash "$ROOT/scripts/run" guard .agent-memory/half/a-note.md 2>&1)"; rcS=$? assert_rc "an unclaimed private note is caught (rc)" 1 "$rcS" assert_contains "and is named in the path the human typed" \ ".agent-memory/private/not-mine.md" "$outS" @@ -300,7 +299,7 @@ rm -f "$pr/.agent-memory/private/not-mine.md" printf -- '---\nname: second\ndescription: another private fact\nmetadata:\n type: project\n evidence: read\n---\nBody.\n' \ > "$pr/.agent-memory/private/second.md" printf '| Notes | 1 | 3 | up |\n' > "$pr/docs/statuses/NOW.md" -outM="$(cd "$pr" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "both at once" \ +outM="$(cd "$pr" && bash "$ROOT/scripts/run" commit -m "both at once" \ .agent-memory/private/second.md docs/statuses/NOW.md 2>&1)"; rcM=$? assert_rc "a mixed commit succeeds (rc)" 0 "$rcM" assert_contains "the private note is in the workplace remote" "private/projects/acme/second.md" \ @@ -339,7 +338,6 @@ git -C "$T/seed" push -q origin main tr1="$T/repo"; mkdir -p "$tr1/.floppy" "$tr1/docs/statuses" "$T/am" git -C "$tr1" init -q -b main . -cp shim/run "$tr1/.floppy/run" printf 'project_key=acme\npublic_repo=%s\nprivate_repo=%s\nagents_memory_dir=%s\nstatuses_now=docs/statuses/NOW.md\ncommit_push=auto\n' \ "$bothremote" "$bothremote" "$T/am" > "$tr1/.floppy/config" printf '| Notes | 1 | 2 | up |\n' > "$tr1/docs/statuses/NOW.md" @@ -398,7 +396,6 @@ assert_contains "and the public one too" "public/projects/acme/half/public-fact. # shape reaches that honestly: the link exists and points where no git covers. tr2="$T/repo2"; mkdir -p "$tr2/.floppy" "$tr2/docs/statuses" "$T/loose" git -C "$tr2" init -q -b main . -cp shim/run "$tr2/.floppy/run" printf 'project_key=acme\nstatuses_now=docs/statuses/NOW.md\n' > "$tr2/.floppy/config" mkdir -p "$tr2/.agent-memory" seed_memory "$tr2/.agent-memory" @@ -420,14 +417,14 @@ assert_contains "and names the path it resolves to" "$T/loose" "$OUT" # environment, so a derivation that pointed anywhere outside the private scope # fails here rather than passing against a path this test invented. This is the # question "what stays green if it is not wired?" asked of the split itself. -sp_rel="$(cd "$pr" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run env 2>&1 | sed -n 's/^FLOPPY_STATUSES_PERSONAL=//p')" +sp_rel="$(cd "$pr" && bash "$ROOT/scripts/run" env 2>&1 | sed -n 's/^FLOPPY_STATUSES_PERSONAL=//p')" assert_contains "the resolved personal status is inside the private scope" \ ".agent-memory/private/machines/" "$sp_rel" mkdir -p "$pr/$(dirname "$sp_rel")" printf '# Mid-way through\n\nThe branch is fresh-branch-push; the guard rewrite is half done.\n' \ > "$pr/$sp_rel" -outPS="$(cd "$pr" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "where I left off" "$sp_rel" 2>&1)"; rcPS=$? +outPS="$(cd "$pr" && bash "$ROOT/scripts/run" commit -m "where I left off" "$sp_rel" 2>&1)"; rcPS=$? assert_rc "a personal status commits through the same rite (rc)" 0 "$rcPS" assert_contains "and it is the workplace repository that takes it" "workplace memory" "$outPS" assert_contains "the personal status reached the workplace remote" \ @@ -440,12 +437,12 @@ assert_eq "and nothing of it landed in the code repository" "" \ # proves the status file did not read as a malformed note — but only for the # machine this run is on. Assert it directly too: a rc has one bit and this # claim is worth its own line. -outL="$(cd "$pr" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rcL=$? +outL="$(cd "$pr" && bash "$ROOT/scripts/run" lint 2>&1)"; rcL=$? assert_rc "the personal status does not turn the memory lint red" 0 "$rcL" # workstatus names it, so "where are we" does not have to guess whether a # working note was left behind. -outW="$(cd "$pr" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status 2>&1)" +outW="$(cd "$pr" && bash "$ROOT/scripts/run" status 2>&1)" assert_contains "status reports the personal slice" "personal, modified" "$outW" # ---------- the cross-project scope reaches the same rite ---------- @@ -470,14 +467,14 @@ assert_eq "and it points at the namespace's common, not the project's scope" \ printf -- '---\nname: a-shell-trap\ndescription: true in every project, not this one\nmetadata:\n type: reference\n evidence: measured\n---\nBody.\n' \ > "$pr/.agent-memory/common/private/a-shell-trap.md" -outCC="$(cd "$pr" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run check .agent-memory/common/private/a-shell-trap.md 2>&1)"; rcCC=$? +outCC="$(cd "$pr" && bash "$ROOT/scripts/run" check .agent-memory/common/private/a-shell-trap.md 2>&1)"; rcCC=$? assert_rc "check accepts a note written into the common scope" 0 "$rcCC" case "$outCC" in *"not changed: wrong path"*) fail "and does not call it unchanged" "no such line" "$outCC" ;; *) ok "and does not call it unchanged" ;; esac -outCM="$(cd "$pr" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "a shell trap" \ +outCM="$(cd "$pr" && bash "$ROOT/scripts/run" commit -m "a shell trap" \ .agent-memory/common/private/a-shell-trap.md 2>&1)"; rcCM=$? assert_rc "commit carries it (rc)" 0 "$rcCM" # The path in the remote is the whole point: private/common/, NOT @@ -491,7 +488,7 @@ assert_eq "and not in this repository" "" \ # The guard has to see the other direction too: a change in that scope which # this session did not claim belongs to another session or the other machine. printf 'stray\n' > "$pr/.agent-memory/common/private/not-mine.md" -outCS="$(cd "$pr" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run guard .agent-memory/half/a-note.md 2>&1)"; rcCS=$? +outCS="$(cd "$pr" && bash "$ROOT/scripts/run" guard .agent-memory/half/a-note.md 2>&1)"; rcCS=$? assert_rc "an unclaimed common note is caught (rc)" 1 "$rcCS" assert_contains "and is named in the path the human typed" \ ".agent-memory/common/private/not-mine.md" "$outCS" @@ -501,14 +498,14 @@ rm -f "$pr/.agent-memory/common/private/not-mine.md" # eight of the first fifteen turned out to carry no metadata.evidence. printf -- '---\nname: unchecked\ndescription: no evidence field\nmetadata:\n type: reference\n---\nBody.\n' \ > "$pr/.agent-memory/common/private/unchecked.md" -outCL="$(cd "$pr" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rcCL=$? +outCL="$(cd "$pr" && bash "$ROOT/scripts/run" lint 2>&1)"; rcCL=$? assert_rc "a common note missing evidence turns the lint red" 1 "$rcCL" assert_contains "and the note is named" "common/private/unchecked.md" "$outCL" rm -f "$pr/.agent-memory/common/private/unchecked.md" # ...and the clean line says the scope was checked, rather than reporting a # count that silently means something narrower. -outCL2="$(cd "$pr" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)" +outCL2="$(cd "$pr" && bash "$ROOT/scripts/run" lint 2>&1)" assert_contains "the clean line names what else it checked" "in common/" "$outCL2" # ---------- another project's dirt in the shared clone must not block this one ---------- @@ -531,14 +528,14 @@ printf 'their unfinished edit\n' >> "$wpclone/private/projects/other/note.md" # old single count invited exactly that. printf -- '---\nname: mine\ndescription: a fact of this project\nmetadata:\n type: project\n evidence: read\n---\nBody.\n' \ > "$pr/.agent-memory/private/mine.md" -outFS="$(cd "$pr" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run check .agent-memory/private/mine.md 2>&1)" +outFS="$(cd "$pr" && bash "$ROOT/scripts/run" check .agent-memory/private/mine.md 2>&1)" assert_contains "check counts only this project's files as committable" \ "1 uncommitted change(s)" "$outFS" assert_contains "and names the foreign dirt as another project's" \ "outside this project" "$outFS" # commit syncs and pushes past the foreign dirt instead of dying on it... -outFC="$(cd "$pr" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "a fact of this project" \ +outFC="$(cd "$pr" && bash "$ROOT/scripts/run" commit -m "a fact of this project" \ .agent-memory/private/mine.md 2>&1)"; rcFC=$? assert_rc "commit pushes despite another project's dirty file (rc)" 0 "$rcFC" assert_contains "and reports the push" "pushed" "$outFC" diff --git a/tests/test-heat.sh b/tests/test-heat.sh index ab30edb..35a7749 100644 --- a/tests/test-heat.sh +++ b/tests/test-heat.sh @@ -9,11 +9,11 @@ cd "$(dirname "$0")/.." . tests/lib.sh ROOT="$(pwd)" -repo="$(sandbox)"; cp shim/run "$repo/.floppy/run" +repo="$(sandbox)" today="$(date -u +%Y-%m-%d)" # 1. logging one note appends a dated line and succeeds -out="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run heat my-note 2>&1)"; rc=$? +out="$(cd "$repo" && bash "$ROOT/scripts/run" heat my-note 2>&1)"; rc=$? assert_rc "heat with one slug succeeds" 0 "$rc" log="$(cat "$repo/.floppy/heat.log" 2>/dev/null)" assert_contains "the line carries the UTC date" "$today my-note" "$log" @@ -25,16 +25,16 @@ assert_contains "the line carries the UTC date" "$today my-note" "$log" if (cd "$repo" && git check-ignore -q .floppy/heat.log); then ok "the log is ignored"; else fail "the log is ignored" "ignored" "not ignored"; fi if (cd "$repo" && git check-ignore -q .floppy/heat.log.tmp); then ok "the rotation temp file is ignored too"; else fail "the rotation temp file is ignored too" "ignored" "not ignored"; fi if [[ -f "$repo/.gitignore" ]]; then fail "heat does not create a .gitignore" "no .gitignore" "$(cat "$repo/.gitignore")"; else ok "heat does not create a .gitignore"; fi -(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run heat my-note >/dev/null 2>&1) +(cd "$repo" && bash "$ROOT/scripts/run" heat my-note >/dev/null 2>&1) ignores2="$(grep -c '\.floppy/heat\.log' "$repo/.git/info/exclude" 2>/dev/null)" assert_eq "a second call does not duplicate the exclude line" "1" "$ignores2" # 3. appending must not weld onto an exclude file with no trailing newline — # that would silently disable its last rule (same defect class as review # finding 1 had for .gitignore) -repo_nl="$(sandbox)"; cp shim/run "$repo_nl/.floppy/run" +repo_nl="$(sandbox)" printf '# local rules\nsecret.txt' > "$repo_nl/.git/info/exclude" -(cd "$repo_nl" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run heat my-note >/dev/null 2>&1) +(cd "$repo_nl" && bash "$ROOT/scripts/run" heat my-note >/dev/null 2>&1) if (cd "$repo_nl" && git check-ignore -q secret.txt); then ok "a no-final-newline exclude keeps its last rule"; else fail "a no-final-newline exclude keeps its last rule" "secret.txt still ignored" "$(cat "$repo_nl/.git/info/exclude")"; fi if (cd "$repo_nl" && git check-ignore -q .floppy/heat.log); then ok "and the log is ignored there as well"; else fail "and the log is ignored there as well" "ignored" "not ignored"; fi @@ -42,9 +42,9 @@ if (cd "$repo_nl" && git check-ignore -q .floppy/heat.log); then ok "and the log # rotation temp file (basename heat.log.tmp does not match). The exclude line # is still written so the temp file is covered, and the .gitignore stays # untouched (review finding 9). -repo_bp="$(sandbox)"; cp shim/run "$repo_bp/.floppy/run" +repo_bp="$(sandbox)" printf '*.log\n' > "$repo_bp/.gitignore" -(cd "$repo_bp" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run heat my-note >/dev/null 2>&1) +(cd "$repo_bp" && bash "$ROOT/scripts/run" heat my-note >/dev/null 2>&1) extra="$(grep -c '\.floppy/heat\.log' "$repo_bp/.gitignore")" assert_eq "the consumer's .gitignore is left alone" "0" "$extra" if (cd "$repo_bp" && git check-ignore -q .floppy/heat.log.tmp); then ok "the temp file is covered despite the broader pattern"; else fail "the temp file is covered despite the broader pattern" "ignored" "not ignored"; fi @@ -53,7 +53,7 @@ assert_contains "the log is still written under a broader pattern" "$today my-no # 5. several slugs in one call — one line each; paths and .md are normalized # to the bare slug, because the caller is an agent that just read a filename -(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run heat note-a half/note-b.md >/dev/null 2>&1) +(cd "$repo" && bash "$ROOT/scripts/run" heat note-a half/note-b.md >/dev/null 2>&1) log="$(cat "$repo/.floppy/heat.log")" assert_contains "a bare slug is logged as itself" "$today note-a" "$log" assert_contains "a path with .md is logged as its slug" "$today note-b" "$log" @@ -62,7 +62,7 @@ assert_contains "a path with .md is logged as its slug" "$today note-b" "$log" # logged: lint matches the second field of a line, so a two-word slug would be # permanently cold with both ends reporting ok (review finding 7); `--help` # would land in the log as data on GNU and as an error on BSD (finding 5) -out="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run heat good-note "two words" 2>&1)"; rc=$? +out="$(cd "$repo" && bash "$ROOT/scripts/run" heat good-note "two words" 2>&1)"; rc=$? assert_rc "a rejected slug fails the call" 1 "$rc" assert_contains "the rejected slug is named" "not a note slug" "$out" log="$(cat "$repo/.floppy/heat.log")" @@ -71,7 +71,7 @@ case "$log" in *"two words"*) fail "the whitespace slug never reaches the log" "no 'two words' line" "$log" ;; *) ok "the whitespace slug never reaches the log" ;; esac -out="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run heat --help 2>&1)"; rc=$? +out="$(cd "$repo" && bash "$ROOT/scripts/run" heat --help 2>&1)"; rc=$? assert_rc "a dash-leading argument is rejected" 1 "$rc" case "$(cat "$repo/.floppy/heat.log")" in *" --help"*) fail "--help never reaches the log" "no --help line" "tail: $(tail -n2 "$repo/.floppy/heat.log")" ;; @@ -79,14 +79,14 @@ case "$(cat "$repo/.floppy/heat.log")" in esac # 6. no arguments is a loud usage error, not a silent no-op -out="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run heat 2>&1)"; rc=$? +out="$(cd "$repo" && bash "$ROOT/scripts/run" heat 2>&1)"; rc=$? assert_rc "bare heat refuses" 2 "$rc" assert_contains "bare heat names its usage" "usage" "$out" # 7. a failed write is an error, not an `ok` (review finding 2) -repo_ro="$(sandbox)"; cp shim/run "$repo_ro/.floppy/run" +repo_ro="$(sandbox)" mkdir "$repo_ro/.floppy/heat.log" # a directory: appending fails even as root -out="$(cd "$repo_ro" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run heat my-note 2>&1)"; rc=$? +out="$(cd "$repo_ro" && bash "$ROOT/scripts/run" heat my-note 2>&1)"; rc=$? assert_rc "an unwritable log fails the verb" 1 "$rc" assert_contains "the failure is named, not ok'd" "could not write" "$out" case "$out" in @@ -104,7 +104,7 @@ if [[ -f "$repo_st/.floppy/heat.log" ]]; then fail "nothing is written outside t # 8. rotation: the log cannot become its own quota problem i=0 while [[ $i -lt 5100 ]]; do printf '2026-01-01 filler-%d\n' "$i"; i=$((i+1)); done >> "$repo/.floppy/heat.log" -(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run heat my-note >/dev/null 2>&1) +(cd "$repo" && bash "$ROOT/scripts/run" heat my-note >/dev/null 2>&1) lines="$(wc -l < "$repo/.floppy/heat.log" | tr -d ' ')" if [[ "$lines" -le 4001 ]]; then ok "an oversized log is trimmed ($lines lines)"; else fail "an oversized log is trimmed" "<= 4001 lines" "$lines lines"; fi tail1="$(tail -n1 "$repo/.floppy/heat.log")" @@ -112,11 +112,11 @@ assert_eq "the trim keeps the newest end" "$today my-note" "$tail1" # 8b. a rotation that cannot complete is named, not swallowed — a silent # failure here means the log grows past its cap forever (review finding 6) -repo_rf="$(sandbox)"; cp shim/run "$repo_rf/.floppy/run" +repo_rf="$(sandbox)" mkdir "$repo_rf/.floppy/heat.log.tmp" # a directory: the trim's redirect fails i=0 while [[ $i -lt 5100 ]]; do printf '2026-01-01 filler-%d\n' "$i"; i=$((i+1)); done >> "$repo_rf/.floppy/heat.log" -out="$(cd "$repo_rf" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run heat my-note 2>&1)"; rc=$? +out="$(cd "$repo_rf" && bash "$ROOT/scripts/run" heat my-note 2>&1)"; rc=$? assert_rc "the open is still recorded when only rotation fails" 0 "$rc" assert_contains "the failed rotation is named" "could not rotate" "$out" tail1="$(tail -n1 "$repo_rf/.floppy/heat.log")" @@ -126,7 +126,7 @@ assert_eq "the append preceded the failed trim" "$today my-note" "$tail1" # A memory with three notes; one logged as opened, one cold, one cold with a # regex metacharacter in its name. lint names the cold ones, does not name the # hot one, and does not fail over any of it. -repo2="$(sandbox)"; cp shim/run "$repo2/.floppy/run" +repo2="$(sandbox)" echo "memory_dir=brain" > "$repo2/.floppy/config" mkdir -p "$repo2/brain/half" cat > "$repo2/brain/MEMORY.md" <<'EOF' @@ -154,7 +154,7 @@ done printf 'chars_max=100000\nnote_chars_max=10000\npointers_max=40\ngrandfathered=\n' > "$repo2/brain/quota.lock" # no heat.log yet: lint stays silent about heat and stays green -out="$(cd "$repo2" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rc=$? +out="$(cd "$repo2" && bash "$ROOT/scripts/run" lint 2>&1)"; rc=$? assert_rc "lint without a heat log passes" 0 "$rc" case "$out" in *"heat"*) fail "lint without a heat log says nothing about heat" "no heat section" "$out" ;; @@ -163,23 +163,23 @@ esac # an empty log (touch, the obvious opt-in) is reported as such, not "since :" : > "$repo2/.floppy/heat.log" -out="$(cd "$repo2" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rc=$? +out="$(cd "$repo2" && bash "$ROOT/scripts/run" lint 2>&1)"; rc=$? assert_rc "lint with an empty heat log passes" 0 "$rc" assert_contains "an empty log is named as the reason" "heat log is empty" "$out" # a log holding only a blank line is empty in the same sense: `! -s` misses # it and the report would again stand on "since :" (review sub-finding) printf '\n' > "$repo2/.floppy/heat.log" -out="$(cd "$repo2" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rc=$? +out="$(cd "$repo2" && bash "$ROOT/scripts/run" lint 2>&1)"; rc=$? assert_rc "lint with a blank-line heat log passes" 0 "$rc" assert_contains "a blank-line log counts as empty" "heat log is empty" "$out" # with a log that has seen only hot-note, plus a line matching a.b-note only # as a regex (aXb-note): both cold notes named, run still green : > "$repo2/.floppy/heat.log" -(cd "$repo2" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run heat hot-note >/dev/null 2>&1) +(cd "$repo2" && bash "$ROOT/scripts/run" heat hot-note >/dev/null 2>&1) printf '%s aXb-note\n' "$today" >> "$repo2/.floppy/heat.log" -out="$(cd "$repo2" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rc=$? +out="$(cd "$repo2" && bash "$ROOT/scripts/run" lint 2>&1)"; rc=$? assert_rc "lint with a heat log still passes" 0 "$rc" assert_contains "the cold note is named" "cold-note" "$out" assert_contains "a dot in a slug is not a regex dot" "a.b-note" "$out" diff --git a/tests/test-init-bootstrap.sh b/tests/test-init-bootstrap.sh deleted file mode 100644 index 0df067a..0000000 --- a/tests/test-init-bootstrap.sh +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bash -# CRITICAL 1 regression: skills/init/SKILL.md step 2 tells the agent to run a -# small bash snippet that locates the plugin root before .floppy/run exists -# to do it. That snippet is not sourced from anywhere (see shim/run's comment -# on the duplication) — it is copied by hand into the markdown, so nothing -# guards it from drifting out of sync or being wrong in the first place. -# -# This test extracts the literal fenced code block from the skill file and -# runs it for real, in the five situations the search has to cover: the dev -# override, the Claude plugin-cache fallback with CLAUDE_PLUGIN_ROOT unset (the -# scenario CRITICAL 1 was filed about), Cursor's local symlink, Cursor's -# SHA-named cache, and nothing resolving at all. Testing the extracted block — -# not a hand-copied third version of the same nine lines — is what makes this -# catch drift instead of just agreeing with itself. -# -# The two Cursor cases were added 2026-09-09, after an audit found the block -# had been carrying four of the shim's six branches for as long as it had -# existed: it stopped at the Claude cache. A Cursor user whose harness had not -# exported CURSOR_PLUGIN_ROOT was told "plugin not found" by `init` for a -# plugin `.floppy/run` resolves through cursor_local — the branch that answers -# on the owner's machine, where the Cursor cache directory exists and is empty. -# Nothing failed, because the three cases here never entered a Cursor layout. -set -uo pipefail -cd "$(dirname "$0")/.." -. tests/lib.sh -ROOT="$(pwd)" - -skill="skills/init/SKILL.md" -block="$(sed -n '/^```bash$/,/^```$/p' "$skill" | sed '1d;$d')" -assert_eq "extracted a non-empty snippet from $skill" "0" "$([[ -n "$block" ]] && echo 0 || echo 1)" -assert_contains "snippet contains the harness variables" "CLAUDE_PLUGIN_ROOT" "$block" -assert_contains "snippet contains the cache fallback" "plugins/cache" "$block" -# Named branches, not just the string "plugins/cache": both Cursor layouts are -# reachable only through these two variables, and their absence is exactly the -# drift this file exists to catch. -assert_contains "snippet reaches Cursor's local symlink" "cursor_local" "$block" -assert_contains "snippet reaches Cursor's cache" "cursor_cache" "$block" - -# Fill in the two placeholders the way the agent would after asking the -# human, positionally (they need distinct values: a dir, then a language). -# Two plain (non-"g") substitutions piped in sequence, each eating the next -# remaining occurrence — `0,/re/` addressing is a GNU sed extension the -# macOS /usr/bin/sed target of this repo does not have. -filled="$(printf '%s\n' "$block" \ - | sed 's//.agent-memory/' \ - | sed 's//en/')" - -# ---------- 1. AI_FLOPPY_HOME set, CLAUDE_PLUGIN_ROOT unset ---------- -repo1="$(sandbox)"; rmdir "$repo1/.floppy" 2>/dev/null || true -out1="$(cd "$repo1" && CLAUDE_PLUGIN_ROOT= AI_FLOPPY_HOME="$ROOT" eval "$filled" 2>&1)"; rc1=$? -assert_rc "AI_FLOPPY_HOME branch: exits 0" 0 "$rc1" -assert_eq "AI_FLOPPY_HOME branch: shim placed" "0" "$([[ -f "$repo1/.floppy/run" ]] && echo 0 || echo 1)" -rm -rf "$repo1" - -# ---------- 2. neither var set, only the plugin cache has a checkout ---------- -# This is the exact scenario CRITICAL 1 is about: a normal install, no dev -# override, and (per the finding) no guarantee CLAUDE_PLUGIN_ROOT is set -# while a skill runs. -fake_home="$(mktemp -d)" -cache_dir="$fake_home/.claude/plugins/cache/example/floppy/0.1.0" -mkdir -p "$cache_dir" -cp -R "$ROOT"/. "$cache_dir"/ 2>/dev/null -rm -rf "$cache_dir/.git" - -repo2="$(sandbox)"; rmdir "$repo2/.floppy" 2>/dev/null || true -out2="$(cd "$repo2" && HOME="$fake_home" AI_FLOPPY_HOME= CLAUDE_PLUGIN_ROOT= eval "$filled" 2>&1)"; rc2=$? -assert_rc "cache-only branch: exits 0" 0 "$rc2" -assert_eq "cache-only branch: shim placed" "0" "$([[ -f "$repo2/.floppy/run" ]] && echo 0 || echo 1)" -rm -rf "$repo2" "$fake_home" - -# ---------- 3. Cursor's local symlink, with no Claude cache in reach ---------- -# Symlinked rather than copied, unlike case 2: the copy there is what proves a -# real cache directory with real files resolves, and repeating it twice more -# would only repeat the cost. A symlink to this checkout is enough to prove the -# branch is entered at all, which is what was missing. -cursor_home="$(mktemp -d)" -mkdir -p "$cursor_home/.cursor/plugins/local" -ln -s "$ROOT" "$cursor_home/.cursor/plugins/local/floppy" - -repo4="$(sandbox)"; rmdir "$repo4/.floppy" 2>/dev/null || true -out4="$(cd "$repo4" && HOME="$cursor_home" AI_FLOPPY_HOME= CLAUDE_PLUGIN_ROOT= CURSOR_PLUGIN_ROOT= eval "$filled" 2>&1)"; rc4=$? -assert_rc "cursor local branch: exits 0" 0 "$rc4" -assert_eq "cursor local branch: shim placed" "0" "$([[ -f "$repo4/.floppy/run" ]] && echo 0 || echo 1)" -rm -rf "$repo4" "$cursor_home" - -# ---------- 4. Cursor's cache, whose last segment is a commit SHA ---------- -cursor_home2="$(mktemp -d)" -mkdir -p "$cursor_home2/.cursor/plugins/cache/floppy/floppy" -ln -s "$ROOT" "$cursor_home2/.cursor/plugins/cache/floppy/floppy/ed18232fd3b616d570a707fb8464b678b8542dbf" - -repo5="$(sandbox)"; rmdir "$repo5/.floppy" 2>/dev/null || true -out5="$(cd "$repo5" && HOME="$cursor_home2" AI_FLOPPY_HOME= CLAUDE_PLUGIN_ROOT= CURSOR_PLUGIN_ROOT= eval "$filled" 2>&1)"; rc5=$? -assert_rc "cursor cache branch: exits 0" 0 "$rc5" -assert_eq "cursor cache branch: shim placed" "0" "$([[ -f "$repo5/.floppy/run" ]] && echo 0 || echo 1)" -rm -rf "$repo5" "$cursor_home2" - -# ---------- 5. nothing resolves: loud failure naming the install command ---------- -empty_home="$(mktemp -d)" -repo3="$(sandbox)"; rmdir "$repo3/.floppy" 2>/dev/null || true -out3="$(cd "$repo3" && HOME="$empty_home" AI_FLOPPY_HOME= CLAUDE_PLUGIN_ROOT= eval "$filled" 2>&1)"; rc3=$? -assert_rc "nothing resolves: exits nonzero" 1 "$rc3" -assert_contains "nothing resolves: names the install command" "plugin install floppy" "$out3" -assert_eq "nothing resolves: does not create .floppy/run" "1" "$([[ -f "$repo3/.floppy/run" ]] && echo 0 || echo 1)" -rm -rf "$repo3" "$empty_home" - -summary diff --git a/tests/test-init.sh b/tests/test-init.sh index 8b92979..3ef96c4 100644 --- a/tests/test-init.sh +++ b/tests/test-init.sh @@ -12,7 +12,10 @@ rmdir "$repo/.floppy" 2>/dev/null || true # sandbox() pre-creates .floppy/; in bash scripts/init.sh --repo "$repo" --memory-dir .agent-memory --language en >/dev/null -assert_eq "shim placed" "0" "$([[ -f "$repo/.floppy/run" ]] && echo 0 || echo 1)" +# 0.26.0: init writes data into the repository and no code. The runner it used +# to copy here is gone, and its absence is asserted rather than assumed — a +# `cp` left in by accident would otherwise pass every other check in this file. +assert_eq "no runner placed" "1" "$([[ -f "$repo/.floppy/run" ]] && echo 0 || echo 1)" assert_eq "config placed" "0" "$([[ -f "$repo/.floppy/config" ]] && echo 0 || echo 1)" assert_eq "router placed" "0" "$([[ -f "$repo/.agent-memory/MEMORY.md" ]] && echo 0 || echo 1)" assert_eq "no quota.lock" "1" "$([[ -f "$repo/.agent-memory/quota.lock" ]] && echo 0 || echo 1)" @@ -66,7 +69,7 @@ case "$(printf '%s\n' "$agents_content" | grep -v 'floppy:agents-section')" in *) ok "AGENTS.md section has no floppy: prefixed skill reference" ;; esac -out="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rc=$? +out="$(cd "$repo" && bash "$ROOT/scripts/run" lint 2>&1)"; rc=$? assert_rc "lint is green on empty memory" 0 "$rc" assert_contains "missing ratchet warns, not fails" "quota.lock" "$out" case "$out" in @@ -77,7 +80,7 @@ esac # git-init the repo so `status` below has a HEAD to read git/origin state # from — it must not need that to find the current-state file it was seeded. git -C "$repo" add -A && git -C "$repo" -c user.email=t@t -c user.name=t commit -qm seed -status_out="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status 2>&1)" +status_out="$(cd "$repo" && bash "$ROOT/scripts/run" status 2>&1)" case "$status_out" in *"nothing for /start to read"*) fail "status finds the seeded current-state file" "no 'nothing for /start to read'" "$status_out" ;; @@ -85,7 +88,7 @@ case "$status_out" in esac # ---------- idempotence: full status + checksums of every touched file ---------- -touched="$repo/.floppy/run $repo/.floppy/config $repo/.agent-memory/MEMORY.md $repo/.gitignore $repo/AGENTS.md $repo/docs/statuses/NOW.md" +touched="$repo/.floppy/config $repo/.agent-memory/MEMORY.md $repo/.gitignore $repo/AGENTS.md $repo/docs/statuses/NOW.md" before_status="$(cd "$repo" && git status --porcelain)" before_sums="" for f in $touched; do before_sums="$before_sums$(md5_of "$f")"; done @@ -139,7 +142,7 @@ assert_contains "custom dir: config carries brain" "memory_dir=brain" "$( assert_contains "custom dir: config carries language" "memory_language=ru" "$(cat "$repo3/.floppy/config")" assert_contains "custom dir: gitignore uses brain" "/brain/private" "$(cat "$repo3/.gitignore")" -out3="$(cd "$repo3" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rc3=$? +out3="$(cd "$repo3" && bash "$ROOT/scripts/run" lint 2>&1)"; rc3=$? assert_rc "custom dir: lint is green" 0 "$rc3" rm -rf "$repo3" @@ -245,4 +248,37 @@ assert_contains "and init says why it wrote nothing" "already ignored whole" "$o # by the exact-line assertion near the top of this file, against $repo. rm -rf "$repoI" +# ---------- a repository initialized before 0.26.0 ---------- +# Both reminders are promised in prose — docs/guide/install.md for the stale +# AGENTS.md line, the changelog for the leftover file — and neither had a test, +# so either could stop printing without anything going red. init also has to +# leave the file alone: it is committed, and something of the reader's may +# still call it. +repoM="$(sandbox)" +rmdir "$repoM/.floppy" 2>/dev/null || true +mkdir -p "$repoM/.floppy" +printf '#!/usr/bin/env bash\n' > "$repoM/.floppy/run" +printf '%s\n' '' '## Agent memory' \ + 'The entry point is `.floppy/run`.' > "$repoM/AGENTS.md" + +outM="$(bash scripts/init.sh --repo "$repoM" 2>&1)" +assert_contains "a leftover runner is named" ".floppy/run is left over" "$outM" +assert_contains "and the command that drops it" "git rm .floppy/run" "$outM" +assert_contains "a stale AGENTS.md line is named" "AGENTS.md still names .floppy/run" "$outM" +assert_contains "and the call to write in its place" "bash /scripts/run " "$outM" +assert_eq "init leaves the leftover file where it is" "0" \ + "$([[ -f "$repoM/.floppy/run" ]] && echo 0 || echo 1)" +rm -rf "$repoM" + +# The AGENTS.md reminder does not hang off the section marker: a repository +# whose stale mention sits anywhere else in the file gets a fresh section +# appended and still carries the old instruction above it. +repoN="$(sandbox)" +rmdir "$repoN/.floppy" 2>/dev/null || true +printf '%s\n' '# Notes' 'Run `bash .floppy/run status` first.' > "$repoN/AGENTS.md" +outN="$(bash scripts/init.sh --repo "$repoN" 2>&1)" +assert_contains "a mention outside the floppy section is caught too" \ + "AGENTS.md still names .floppy/run" "$outN" +rm -rf "$repoN" + summary diff --git a/tests/test-interpreter.sh b/tests/test-interpreter.sh index 679b841..ff1c9e5 100644 --- a/tests/test-interpreter.sh +++ b/tests/test-interpreter.sh @@ -7,7 +7,7 @@ # Four levels of indirection stand between the command line and a verb: # 1. the workflow calls /bin/bash tests/run.sh — always was pinned # 2. run.sh hands each test file "$BASH" — pinned since 0.16 -# 3. a test file calls `bash .floppy/run ...` — ~180 call sites +# 3. a test file calls `bash /scripts/run ...` — ~180 call sites # 4. shim/run and scripts/run `exec bash` the next file — in the product # Levels 3 and 4 are covered by run.sh putting a `bash` that IS the interpreter # under test at the front of PATH, and by both execs using "$BASH". This file @@ -32,9 +32,13 @@ assert_eq "a bare \`bash\` resolves to the interpreter under test" \ # ---------- level 4: both dispatchers pass their interpreter on ---------- # Behavioural, not textual: build a plugin whose `lint` verb reports the -# interpreter it was given, then call the shim with a SECOND bash and require -# the verb to report that second one. A dispatcher that says `exec bash` sends -# back whatever PATH offers, which is the pinned one, and the assert fails. +# interpreter it was given, then call it with a SECOND bash and require the +# verb to report that second one. A dispatcher that says `exec bash` sends back +# whatever PATH offers, which is the pinned one, and the assert fails. +# +# Both hops are exercised, shim included: the shim is no longer on the path a +# skill takes (0.26.0), but it still ships and a consumer who kept their copy +# still runs both. The dispatcher's own hop is covered by the same call. # # It needs two different bashes on the machine. The macOS runner has exactly # that — /bin/bash 3.2 and Homebrew 5.x — which is the environment the defect diff --git a/tests/test-memory-dirs.sh b/tests/test-memory-dirs.sh index 4a9b913..50e7059 100644 --- a/tests/test-memory-dirs.sh +++ b/tests/test-memory-dirs.sh @@ -36,10 +36,10 @@ mk_remote() { # bare-path name rm -rf "$s" } -# A consumer repository carrying the shim and a config body. Echoes its path. +# A consumer repository with a config body and no runner of its own — what +# `init` lays down since 0.26.0. Echoes its path. mk_consumer() { # config-body local d; d="$(sandbox)" - cp "$ROOT/shim/run" "$d/.floppy/run" printf '%s\n' "$1" > "$d/.floppy/config" printf 'x\n' > "$d/README.md" git -C "$d" add -A @@ -50,7 +50,7 @@ mk_consumer() { # config-body run_verb() { # repo home verb... local r="$1" h="$2"; shift 2 OUT="$(cd "$r" && HOME="$h" AI_FLOPPY_HOME="$ROOT" CLAUDE_PLUGIN_ROOT= \ - git_author=t bash .floppy/run "$@" 2>&1)" + git_author=t bash "$ROOT/scripts/run" "$@" 2>&1)" RC=$? } @@ -194,7 +194,7 @@ agents_memory_dir=$H6/agents_memory private_repo=$url workplace_project_key=acme")" got="$(cd "$r" && HOME="$H6" AI_FLOPPY_HOME="$ROOT" CLAUDE_PLUGIN_ROOT= \ - bash .floppy/run env 2>/dev/null | sed -n 's/^FLOPPY_WORKPLACE_MEMORY_DIR=//p')" + bash "$ROOT/scripts/run" env 2>/dev/null | sed -n 's/^FLOPPY_WORKPLACE_MEMORY_DIR=//p')" assert_eq "checkout for $url is named $want" "$H6/agents_memory/.clones/$want" "$got" done diff --git a/tests/test-memory-link.sh b/tests/test-memory-link.sh index e8f6b86..8ebd72d 100644 --- a/tests/test-memory-link.sh +++ b/tests/test-memory-link.sh @@ -4,16 +4,16 @@ cd "$(dirname "$0")/.." . tests/lib.sh ROOT="$(pwd)" -repo="$(sandbox)"; cp shim/run "$repo/.floppy/run" +repo="$(sandbox)" echo "memory_dir=brain" > "$repo/.floppy/config" mkdir -p "$repo/brain" home="$(mktemp -d)" -out="$(cd "$repo" && HOME="$home" AI_FLOPPY_HOME="$ROOT" bash .floppy/run link --check 2>&1)"; rc=$? +out="$(cd "$repo" && HOME="$home" bash "$ROOT/scripts/run" link --check 2>&1)"; rc=$? assert_rc "unwired machine fails" 1 "$rc" assert_contains "unwired names the fix" "run link" "$out" -out2="$(cd "$repo" && HOME="$home" AI_FLOPPY_HOME="$ROOT" bash .floppy/run link 2>&1)" +out2="$(cd "$repo" && HOME="$home" bash "$ROOT/scripts/run" link 2>&1)" assert_contains "wiring reports success" "ok" "$out2" # IMPORTANT 7: this script wires Claude Code's own memory path specifically @@ -21,7 +21,7 @@ assert_contains "wiring reports success" "ok" "$out2" # so rather than run silently as if it were harness-agnostic. assert_contains "wiring says it is Claude-Code-specific" "Cursor" "$out2" -out3="$(cd "$repo" && HOME="$home" AI_FLOPPY_HOME="$ROOT" bash .floppy/run link --check 2>&1)"; rc3=$? +out3="$(cd "$repo" && HOME="$home" bash "$ROOT/scripts/run" link --check 2>&1)"; rc3=$? assert_rc "wired machine passes" 0 "$rc3" # forked memory: a real directory where the symlink belongs @@ -44,15 +44,15 @@ assert_rc "wired machine passes" 0 "$rc3" # The comment at the bottom of this file called the recomputation deliberate, # on the grounds that it "only needs to agree with the script". It stopped # agreeing, silently, in the one release that changed the rule. -repo2="$(sandbox)"; cp shim/run "$repo2/.floppy/run"; mkdir -p "$repo2/.agent-memory" +repo2="$(sandbox)"; mkdir -p "$repo2/.agent-memory" home2="$(mktemp -d)" -(cd "$repo2" && HOME="$home2" AI_FLOPPY_HOME="$ROOT" bash .floppy/run link >/dev/null 2>&1) +(cd "$repo2" && HOME="$home2" bash "$ROOT/scripts/run" link >/dev/null 2>&1) proj2="$(ls "$home2/.claude/projects" 2>/dev/null)" assert_eq "the fork setup wired exactly one project directory" \ "1" "$(printf '%s\n' "$proj2" | grep -c .)" fork="$home2/.claude/projects/$proj2/memory" rm -f "$fork"; mkdir -p "$fork" -out4="$(cd "$repo2" && HOME="$home2" AI_FLOPPY_HOME="$ROOT" bash .floppy/run link --check 2>&1)"; rc4=$? +out4="$(cd "$repo2" && HOME="$home2" bash "$ROOT/scripts/run" link --check 2>&1)"; rc4=$? assert_rc "forked memory fails" 1 "$rc4" assert_contains "forked memory is named" "real directory" "$out4" @@ -82,11 +82,10 @@ rm -rf "$repo" "$repo2" "$home" "$home2" repoU="$(mktemp -d)/my_project" mkdir -p "$repoU/.floppy" "$repoU/brain" git -C "$(dirname "$repoU")" init -q -b main "$repoU" 2>/dev/null || git init -q -b main "$repoU" -cp shim/run "$repoU/.floppy/run" echo "memory_dir=brain" > "$repoU/.floppy/config" homeU="$(mktemp -d)" -outU="$(cd "$repoU" && HOME="$homeU" AI_FLOPPY_HOME="$ROOT" bash .floppy/run link 2>&1)" +outU="$(cd "$repoU" && HOME="$homeU" bash "$ROOT/scripts/run" link 2>&1)" assert_contains "an underscore path wires without complaint" "ok" "$outU" projU="$(ls "$homeU/.claude/projects" 2>/dev/null)" @@ -102,7 +101,7 @@ esac # And the checker agrees with what was just made — the report is the half that # was lying, not the link. -outU2="$(cd "$repoU" && HOME="$homeU" AI_FLOPPY_HOME="$ROOT" bash .floppy/run link --check 2>&1)"; rcU=$? +outU2="$(cd "$repoU" && HOME="$homeU" bash "$ROOT/scripts/run" link --check 2>&1)"; rcU=$? assert_rc "the checker sees the link it just made" 0 "$rcU" rm -rf "$(dirname "$repoU")" "$homeU" @@ -116,17 +115,17 @@ rm -rf "$(dirname "$repoU")" "$homeU" # sides have to be resolved; in the ordinary layout $mem is a real directory # and resolving it changes nothing, which is what the assertions above keep # pinned. -repoS="$(sandbox)"; cp shim/run "$repoS/.floppy/run" +repoS="$(sandbox)" echo "memory_dir=brain" > "$repoS/.floppy/config" storeS="$(mktemp -d)/store"; mkdir -p "$storeS" ln -s "$storeS" "$repoS/brain" homeS="$(mktemp -d)" -outS="$(cd "$repoS" && HOME="$homeS" AI_FLOPPY_HOME="$ROOT" bash .floppy/run link 2>&1)"; rcS=$? +outS="$(cd "$repoS" && HOME="$homeS" bash "$ROOT/scripts/run" link 2>&1)"; rcS=$? assert_rc "a symlinked memory_dir wires" 0 "$rcS" assert_contains "and reports the link it created" "symlink created" "$outS" -outS2="$(cd "$repoS" && HOME="$homeS" AI_FLOPPY_HOME="$ROOT" bash .floppy/run link --check 2>&1)"; rcS2=$? +outS2="$(cd "$repoS" && HOME="$homeS" bash "$ROOT/scripts/run" link --check 2>&1)"; rcS2=$? assert_rc "and the checker calls it wired" 0 "$rcS2" case "$outS2" in *"points elsewhere"*) fail "and does not call its own link foreign" "no 'points elsewhere'" "$outS2" ;; @@ -134,8 +133,8 @@ case "$outS2" in esac # Running it twice is the case a consumer actually hits, since `store` prints -# `next: bash .floppy/run link` and `status` asks the same question afterwards. -outS3="$(cd "$repoS" && HOME="$homeS" AI_FLOPPY_HOME="$ROOT" bash .floppy/run link 2>&1)"; rcS3=$? +# `next: bash "$ROOT/scripts/run" link` and `status` asks the same question afterwards. +outS3="$(cd "$repoS" && HOME="$homeS" bash "$ROOT/scripts/run" link 2>&1)"; rcS3=$? assert_rc "a second run is idempotent, not a refusal" 0 "$rcS3" assert_contains "and says it was already configured" "already configured" "$outS3" @@ -151,7 +150,7 @@ projS="$(ls "$homeS/.claude/projects" 2>/dev/null)" assert_eq "the store layout wired exactly one project directory" \ "1" "$(printf '%s\n' "$projS" | grep -c .)" ln -sfn "$elsewhere" "$homeS/.claude/projects/$projS/memory" -outS4="$(cd "$repoS" && HOME="$homeS" AI_FLOPPY_HOME="$ROOT" bash .floppy/run link --check 2>&1)"; rcS4=$? +outS4="$(cd "$repoS" && HOME="$homeS" bash "$ROOT/scripts/run" link --check 2>&1)"; rcS4=$? assert_rc "a link to another directory is still refused" 1 "$rcS4" assert_contains "and says where it actually points" "points elsewhere" "$outS4" @@ -160,11 +159,11 @@ rm -rf "$repoS" "$(dirname "$storeS")" "$homeS" "$elsewhere" # workplace: no project defaults left. An unset workplace_project_key must # refuse loudly before anything is cloned, symlinked, or moved — a check that # fires after a side effect is worse than none. -repo3="$(sandbox)"; cp shim/run "$repo3/.floppy/run" +repo3="$(sandbox)" mkdir -p "$repo3/.agent-memory" home3="$(mktemp -d)" -out5="$(cd "$repo3" && HOME="$home3" AI_FLOPPY_HOME="$ROOT" bash .floppy/run workplace 2>&1)"; rc5=$? +out5="$(cd "$repo3" && HOME="$home3" bash "$ROOT/scripts/run" workplace 2>&1)"; rc5=$? assert_rc "workplace refuses without project key" 1 "$rc5" assert_contains "refusal names the config key" "workplace_project_key" "$out5" assert_eq "nothing cloned before the refusal" "absent" "$([[ -e "$home3/agents_memory" ]] && echo exists || echo absent)" @@ -173,7 +172,7 @@ assert_eq "no local symlink created either" "absent" "$([[ -e "$rep # even with a project key, an unset repository URL must also refuse before # any clone — the second required value guards a different side effect. echo "workplace_project_key=test-project" > "$repo3/.floppy/config" -out6="$(cd "$repo3" && HOME="$home3" AI_FLOPPY_HOME="$ROOT" bash .floppy/run workplace 2>&1)"; rc6=$? +out6="$(cd "$repo3" && HOME="$home3" bash "$ROOT/scripts/run" workplace 2>&1)"; rc6=$? assert_rc "workplace refuses without repo url" 1 "$rc6" assert_contains "refusal names the repo config key" "private_repo" "$out6" assert_eq "still nothing cloned" "absent" "$([[ -e "$home3/agents_memory" ]] && echo exists || echo absent)" diff --git a/tests/test-memory-lint.sh b/tests/test-memory-lint.sh index bb16262..2eaccd2 100755 --- a/tests/test-memory-lint.sh +++ b/tests/test-memory-lint.sh @@ -5,7 +5,7 @@ cd "$(dirname "$0")/.." ROOT="$(pwd)" # memory in a non-default directory, to catch any surviving ".agent-memory" -repo="$(sandbox)"; cp shim/run "$repo/.floppy/run" +repo="$(sandbox)" echo "memory_dir=brain" > "$repo/.floppy/config" mkdir -p "$repo/brain/half" cat > "$repo/brain/MEMORY.md" <<'EOF' @@ -28,7 +28,7 @@ Body. EOF printf 'notes_max=10\nchars_max=100000\nnote_chars_max=10000\npointers_max=40\ngrandfathered=\n' > "$repo/brain/quota.lock" -out="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rc=$? +out="$(cd "$repo" && bash "$ROOT/scripts/run" lint 2>&1)"; rc=$? assert_rc "clean memory in a custom dir passes" 0 "$rc" assert_contains "counts are reported" "1 notes" "$out" case "$out" in *".agent-memory"*) fail "no hardcoded .agent-memory" "absent" "$out";; *) ok "no hardcoded .agent-memory";; esac @@ -36,7 +36,7 @@ case "$out" in *".agent-memory"*) fail "no hardcoded .agent-memory" "absent" "$o # positive control: a note nobody points at must be caught cp "$repo/brain/half/a-note.md" "$repo/brain/half/orphan.md" sed -i.bak 's/^name: a-note/name: orphan/' "$repo/brain/half/orphan.md" && rm -f "$repo/brain/half/orphan.md.bak" -out2="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rc2=$? +out2="$(cd "$repo" && bash "$ROOT/scripts/run" lint 2>&1)"; rc2=$? assert_rc "orphan note fails the run" 1 "$rc2" assert_contains "orphan note is named" "orphan.md" "$out2" @@ -65,7 +65,7 @@ if [[ -z "$store" ]]; then echo none; fi ``` EOF rm -f "$repo/brain/half/orphan.md" -outB="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rcB=$? +outB="$(cd "$repo" && bash "$ROOT/scripts/run" lint 2>&1)"; rcB=$? assert_rc "a note quoting a bash condition passes" 0 "$rcB" case "$outB" in *"resolves to nothing"*) fail "and the condition is not read as a link" "no such line" "$outB" ;; @@ -74,7 +74,7 @@ esac # positive control: a real link that resolves nowhere must still be caught printf 'And a link to [[no-such-note]].\n' >> "$repo/brain/half/a-note.md" -outC="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rcC=$? +outC="$(cd "$repo" && bash "$ROOT/scripts/run" lint 2>&1)"; rcC=$? assert_rc "a dangling [[slug]] still fails the run" 1 "$rcC" assert_contains "and is named" "[[no-such-note]]" "$outC" @@ -116,7 +116,7 @@ metadata: --- Body. EOF -outc="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rcc=$? +outc="$(cd "$repo" && bash "$ROOT/scripts/run" lint 2>&1)"; rcc=$? assert_rc "a common note needs no pointer in this project's index" 0 "$rcc" assert_contains "and is not counted into this project's corpus" "1 notes" "$outc" assert_contains "but the run says it checked it" "1 in common/" "$outc" @@ -132,7 +132,7 @@ metadata: --- Body. EOF -outc2="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rcc2=$? +outc2="$(cd "$repo" && bash "$ROOT/scripts/run" lint 2>&1)"; rcc2=$? assert_rc "a common note missing evidence fails the run" 1 "$rcc2" assert_contains "and is named by its path in the scope" "common/shared/undated.md" "$outc2" rm -f "$repo/brain/common/shared/undated.md" @@ -141,7 +141,7 @@ rm -f "$repo/brain/common/shared/undated.md" # so the link is dead for anyone who has not wired it. Same rule the private # scope has carried since 0.4.0, and the same reason. printf 'See [the trap](common/shared/a-trap.md).\n' >> "$repo/brain/half/a-note.md" -outc3="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rcc3=$? +outc3="$(cd "$repo" && bash "$ROOT/scripts/run" lint 2>&1)"; rcc3=$? assert_rc "a committed note linking into common/ fails" 1 "$rcc3" assert_contains "and says why the link is dead" "has not wired that scope" "$outc3" @@ -151,7 +151,7 @@ rm -rf "$repo" # The index became a three-level tree on 2026-08-25 (root -> half -> sub-index # -> note), after sdk/ hit the 60-pointer cap and the ratchet said to split the # half rather than raise the number. A clean tree at that depth must pass. -repo3="$(sandbox)"; cp shim/run "$repo3/.floppy/run" +repo3="$(sandbox)" echo "memory_dir=brain" > "$repo3/.floppy/config" mkdir -p "$repo3/brain/half/sub" cat > "$repo3/brain/MEMORY.md" <<'EOF' @@ -178,7 +178,7 @@ Body. EOF printf 'chars_max=100000\nnote_chars_max=10000\npointers_max=40\ngrandfathered=\n' > "$repo3/brain/quota.lock" -out3="$(cd "$repo3" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rc3=$? +out3="$(cd "$repo3" && bash "$ROOT/scripts/run" lint 2>&1)"; rc3=$? assert_rc "three-level tree passes clean" 0 "$rc3" assert_contains "three indexes are counted" "3 indexes" "$out3" @@ -203,7 +203,7 @@ metadata: Body. EOF -out4="$(cd "$repo3" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rc4=$? +out4="$(cd "$repo3" && bash "$ROOT/scripts/run" lint 2>&1)"; rc4=$? assert_rc "orphan sub-index fails the run" 1 "$rc4" assert_contains "orphan sub-index is named" "half/orphan-sub/INDEX.md" "$out4" assert_contains "orphan sub-index names its own parent" "not linked from half/INDEX.md" "$out4" @@ -211,7 +211,7 @@ assert_contains "orphan sub-index names its own parent" "not linked from half/I rm -rf "$repo3" # ---------- note nested past three levels ---------- -repo5="$(sandbox)"; cp shim/run "$repo5/.floppy/run" +repo5="$(sandbox)" echo "memory_dir=brain" > "$repo5/.floppy/config" mkdir -p "$repo5/brain/half/sub/toodeep" cat > "$repo5/brain/MEMORY.md" <<'EOF' @@ -248,7 +248,7 @@ Body. EOF printf 'chars_max=100000\nnote_chars_max=10000\npointers_max=40\ngrandfathered=\n' > "$repo5/brain/quota.lock" -out5="$(cd "$repo5" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rc5=$? +out5="$(cd "$repo5" && bash "$ROOT/scripts/run" lint 2>&1)"; rc5=$? assert_rc "note past three levels fails the run" 1 "$rc5" assert_contains "deep note is named" "half/sub/toodeep/deep-note.md" "$out5" assert_contains "depth-limit message" "index tree stops at three levels" "$out5" @@ -259,7 +259,7 @@ rm -rf "$repo5" # Previously an unguarded non-numeric value in quota.lock printed its own # message but also let later comparisons run on the bad value, spilling raw # bash diagnostics ahead of it. The current script validates before using. -repo6="$(sandbox)"; cp shim/run "$repo6/.floppy/run" +repo6="$(sandbox)" echo "memory_dir=brain" > "$repo6/.floppy/config" mkdir -p "$repo6/brain/half" cat > "$repo6/brain/MEMORY.md" <<'EOF' @@ -282,7 +282,7 @@ Body. EOF printf 'chars_max=abc\nnote_chars_max=10000\npointers_max=40\ngrandfathered=\n' > "$repo6/brain/quota.lock" -out6="$(cd "$repo6" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rc6=$? +out6="$(cd "$repo6" && bash "$ROOT/scripts/run" lint 2>&1)"; rc6=$? assert_rc "non-numeric quota value fails the run" 1 "$rc6" assert_contains "non-numeric value is named" "chars_max is 'abc'" "$out6" case "$out6" in @@ -297,8 +297,8 @@ rm -rf "$repo6" # in a harness that can have several open at once (Cursor especially). In its # old form it read as "your setup is broken"; naming the repository turns it # into "you are in the wrong place". -repo7="$(sandbox)"; cp shim/run "$repo7/.floppy/run" -out7="$(cd "$repo7" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rc7=$? +repo7="$(sandbox)" +out7="$(cd "$repo7" && bash "$ROOT/scripts/run" lint 2>&1)"; rc7=$? assert_rc "no memory layout: exits 2" 2 "$rc7" assert_contains "no memory layout: message names the repository path" "$repo7" "$out7" assert_contains "no memory layout: still explains what's missing" \ @@ -311,7 +311,7 @@ rm -rf "$repo7" # is a fact about this corpus's writing convention, like pointers_max beside # it, so it lives in quota.lock. Merging them into one file would make every # project re-measure somebody else's tool. -repo8="$(sandbox)"; cp shim/run "$repo8/.floppy/run" +repo8="$(sandbox)" echo "memory_dir=brain" > "$repo8/.floppy/config" mkdir -p "$repo8/brain/half" cat > "$repo8/brain/half/a-note.md" <<'EOFN' @@ -329,7 +329,7 @@ printf '# Half\n- [A note](a-note.md) — pointer\n' > "$repo8/brain/half/INDEX. long_line="- [x](half/INDEX.md) $(printf 'y%.0s' $(seq 1 179))" printf '# Index\n%s\n' "$long_line" > "$repo8/brain/MEMORY.md" -lint8() { OUT8="$(cd "$repo8" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; RC8=$?; } +lint8() { OUT8="$(cd "$repo8" && bash "$ROOT/scripts/run" lint 2>&1)"; RC8=$?; } lint8 assert_rc "default pointer_line_max catches a long line" 1 "$RC8" @@ -365,7 +365,7 @@ rm -rf "$repo8" # the rule "committed memory must not link into the local scope" is what # protects against links that are dead on a second machine, and under any other # name it silently applied to nothing. -repo9="$(sandbox)"; cp shim/run "$repo9/.floppy/run" +repo9="$(sandbox)" printf 'memory_dir=brain\nmemory_private_dir=mine\n' > "$repo9/.floppy/config" mkdir -p "$repo9/brain/half" "$repo9/brain/mine" printf '# Index\n- [Half](half/INDEX.md) — pointer\n' > "$repo9/brain/MEMORY.md" @@ -396,7 +396,7 @@ metadata: Body. EOFP -lint9() { OUT9="$(cd "$repo9" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; RC9=$?; } +lint9() { OUT9="$(cd "$repo9" && bash "$ROOT/scripts/run" lint 2>&1)"; RC9=$?; } lint9 assert_rc "a renamed private scope is not held to the index rules" 0 "$RC9" assert_contains "and only the committed note is counted" "1 notes" "$OUT9" @@ -479,7 +479,7 @@ rm -rf "$repo9/brain/mine/machines" # The quota belongs to the committed corpus, and is not borrowed for this one: # quota.lock is measured on the memory in THIS repository. -repoQ="$(sandbox)"; cp shim/run "$repoQ/.floppy/run" +repoQ="$(sandbox)" printf 'memory_dir=brain\nmemory_private_dir=mine\n' > "$repoQ/.floppy/config" mkdir -p "$repoQ/brain/half" "$repoQ/brain/mine" printf '# Index\n- [Half](half/INDEX.md) — pointer\n' > "$repoQ/brain/MEMORY.md" @@ -489,7 +489,7 @@ printf -- '---\nname: a-note\ndescription: a note\nmetadata:\n type: project\n printf 'notes_max=10\nchars_max=100000\nnote_chars_max=120\npointers_max=40\ngrandfathered=\n' > "$repoQ/brain/quota.lock" { printf -- '---\nname: big\ndescription: a long private note\nmetadata:\n type: project\n evidence: read\n---\n' for i in 1 2 3 4 5 6 7 8; do printf 'padding padding padding padding\n'; done; } > "$repoQ/brain/mine/big.md" -outQ="$(cd "$repoQ" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; rcQ=$? +outQ="$(cd "$repoQ" && bash "$ROOT/scripts/run" lint 2>&1)"; rcQ=$? assert_rc "the committed corpus's note cap is not applied to the private scope" 0 "$rcQ" case "$outQ" in *"mine/big.md"*) fail "and the private note is not named by the quota section" "not named" "$outQ" ;; @@ -517,7 +517,7 @@ rm -rf "$repo9" # is, and on a repository worked from two machines those are different people. # The per-half keys are optional: a corpus that sets none must behave exactly as # it did before, which is what the first assertion here pins down. -repoH="$(sandbox)"; cp shim/run "$repoH/.floppy/run" +repoH="$(sandbox)" echo "memory_dir=brain" > "$repoH/.floppy/config" mkdir -p "$repoH/brain/alpha" "$repoH/brain/beta" printf '# Index\n- [Alpha](alpha/INDEX.md) — pointer\n- [Beta](beta/INDEX.md) — pointer\n- [Root note](root-note.md) — pointer\n' > "$repoH/brain/MEMORY.md" @@ -532,7 +532,7 @@ note_body small 50 > "$repoH/brain/beta/small.md" note_body root-note 50 > "$repoH/brain/root-note.md" printf 'chars_max=100000\nnote_chars_max=10000\npointers_max=40\ngrandfathered=\n' > "$repoH/brain/quota.lock" -lintH() { OUTH="$(cd "$repoH" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; RCH=$?; } +lintH() { OUTH="$(cd "$repoH" && bash "$ROOT/scripts/run" lint 2>&1)"; RCH=$?; } lintH assert_rc "a corpus with no per-half keys is unaffected" 0 "$RCH" @@ -584,7 +584,7 @@ rm -rf "$repoH" # 79 + length(name) + padding characters, so big is 2082 and small is 134, and # the pair is 2216. A band assertion is only worth anything if the corpus really # sits inside the band, so these are placed by arithmetic rather than by eye. -repoW="$(sandbox)"; cp shim/run "$repoW/.floppy/run" +repoW="$(sandbox)" echo "memory_dir=brain" > "$repoW/.floppy/config" mkdir -p "$repoW/brain/alpha" "$repoW/brain/beta" printf '# Index\n- [Alpha](alpha/INDEX.md) — pointer\n- [Beta](beta/INDEX.md) — pointer\n' > "$repoW/brain/MEMORY.md" @@ -593,7 +593,7 @@ printf '# Beta\n- [Small](small.md) — pointer\n' > "$repoW/brain/beta/INDEX.md note_body big 2000 > "$repoW/brain/alpha/big.md" # 2082 characters note_body small 50 > "$repoW/brain/beta/small.md" # 134 characters -lintW() { OUTW="$(cd "$repoW" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; RCW=$?; } +lintW() { OUTW="$(cd "$repoW" && bash "$ROOT/scripts/run" lint 2>&1)"; RCW=$?; } # Far below every ceiling: still silent. Without this the assertions after it # would pass just as well on a script that warns about everything always. @@ -682,7 +682,7 @@ day_offset() { # $1 = signed days, e.g. +1 or -400 } TODAY="$(date -u +%Y-%m-%d)" -repoA="$(sandbox)"; cp shim/run "$repoA/.floppy/run" +repoA="$(sandbox)" echo "memory_dir=brain" > "$repoA/.floppy/config" mkdir -p "$repoA/brain" cat > "$repoA/brain/MEMORY.md" <<'EOF' @@ -697,7 +697,7 @@ mknote() { # $1 = slug, $2 = as_of line (may be empty) printf -- '---\nBody.\n' } > "$repoA/brain/$1.md" } -lintA() { OUTA="$(cd "$repoA" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lint 2>&1)"; RCA=$?; } +lintA() { OUTA="$(cd "$repoA" && bash "$ROOT/scripts/run" lint 2>&1)"; RCA=$?; } # 1. A dated note and an undated one together: the run passes, and the undated # one is counted rather than named. Counted, because naming each of eight diff --git a/tests/test-memory-store.sh b/tests/test-memory-store.sh index 3a4e175..a71c98e 100644 --- a/tests/test-memory-store.sh +++ b/tests/test-memory-store.sh @@ -28,7 +28,7 @@ make_store_remote() { run_store() { # repo, then args local r="$1"; shift - OUT="$(cd "$r" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run store "$@" 2>&1)" + OUT="$(cd "$r" && bash "$ROOT/scripts/run" store "$@" 2>&1)" RC=$? } @@ -36,7 +36,7 @@ run_store() { # repo, then args # Half a configuration is not a layout. A store with no scope has nowhere to # put this project's notes, and defaulting one would write them into whatever # repository happened to be configured. -repo="$(sandbox)"; cp shim/run "$repo/.floppy/run" +repo="$(sandbox)" printf 'memory_dir=.agent-memory\n' > "$repo/.floppy/config" run_store "$repo" assert_eq "no store configured: refuses" "2" "$RC" @@ -68,7 +68,7 @@ assert_eq "it points into the project's own scope" "$checkout/public/projects/ac assert_eq "the code repository ignores it" "0" \ "$(cd "$repo" && git check-ignore -q -- .agent-memory; echo $?)" # The shim must now derive the external layout from that symlink alone. -env_out="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run env 2>&1)" +env_out="$(cd "$repo" && bash "$ROOT/scripts/run" env 2>&1)" assert_contains "the shim derives the external layout" "FLOPPY_MEMORY_EXTERNAL=1" "$env_out" # ---------- the cross-project scope, wired beside the project's own ---------- @@ -112,7 +112,7 @@ assert_contains "and names the link target" "public/projects/acme" "$OUT # ---------- it never decides the fate of memory somebody wrote ---------- # A real directory where the symlink belongs is the lagging-machine case, and # those notes may be the only copies in existence. -repo2="$(sandbox)"; cp shim/run "$repo2/.floppy/run" +repo2="$(sandbox)" cat > "$repo2/.floppy/config" < "$repo3/.floppy/config" printf '/.agent-memory\n' > "$repo3/.gitignore" mkdir -p "$repo3/.agent-memory" "$repo3/docs" @@ -140,7 +140,7 @@ printf 'x\n' > "$repo3/docs/a.md" git -C "$repo3" add -- .gitignore .floppy docs git -C "$repo3" -c user.email=t@t -c user.name=t commit -qm base printf 'y\n' >> "$repo3/docs/a.md" -guard_out="$(cd "$repo3" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run guard docs/a.md 2>&1)" +guard_out="$(cd "$repo3" && bash "$ROOT/scripts/run" guard docs/a.md 2>&1)" guard_rc=$? assert_eq "ignored-but-not-external fails the guard" "1" "$guard_rc" assert_contains "and names the real cause" "nothing will ever commit these notes" "$guard_out" @@ -153,14 +153,14 @@ assert_contains "and says why store is not it" "without both keys" "$guard_o # With a destination configured the other half becomes actionable, and the # message offers it. Same guard, same broken state — only the config differs. printf 'memory_dir=.agent-memory\nwatched_dirs=docs\npublic_repo=%s\nproject_key=acme\n' "$remote" > "$repo3/.floppy/config" -guard_out="$(cd "$repo3" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run guard docs/a.md 2>&1)" +guard_out="$(cd "$repo3" && bash "$ROOT/scripts/run" guard docs/a.md 2>&1)" assert_contains "a configured repository is sent to store" "run store" "$guard_out" printf 'memory_dir=.agent-memory\nwatched_dirs=docs\n' > "$repo3/.floppy/config" # A memory that is neither ignored nor external — the ordinary layout — must # not trip it. Without this the check above could be passing on everything. rm -f "$repo3/.gitignore" -guard_out="$(cd "$repo3" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run guard docs/a.md 2>&1)" +guard_out="$(cd "$repo3" && bash "$ROOT/scripts/run" guard docs/a.md 2>&1)" case "$guard_out" in *"nothing will ever commit these notes"*) fail "the ordinary layout is not flagged" "no such error" "$guard_out" ;; *) ok "the ordinary layout is not flagged" ;; diff --git a/tests/test-skills.sh b/tests/test-skills.sh index 9a604b1..e932dba 100755 --- a/tests/test-skills.sh +++ b/tests/test-skills.sh @@ -36,4 +36,62 @@ done hits="$(grep -rln 'allowed-tools' skills/ 2>/dev/null || true)" assert_eq "no file in skills/ contains allowed-tools" "" "$hits" +# ---------- how a skill spells the dispatcher (0.26.0) ---------- +# `.floppy/run` is no longer written into a consumer repository, so a skill +# that names it sends the agent at a file that exists only where an older +# `init` has been run. The call is the plugin's own dispatcher, reached from +# the base directory the harness states when it loads the skill. +# +# This is the guard that replaced tests/test-init-bootstrap.sh, which used to +# extract the init skill's hand copy of the plugin search and run it. That copy +# is gone (skills/init/SKILL.md §2), and what is left to protect is the +# spelling: a skill that names the old path, or one that uses the `` +# placeholder without ever saying where it comes from, both leave the agent +# with a path it cannot resolve. +run_hits="$(grep -rln '\.floppy/run' skills/ 2>/dev/null || true)" +assert_eq "no skill names the consumer's .floppy/run" "" "$run_hits" + +# Newlines folded to spaces before the match: the quoted line is prose and +# wraps at the file's margin, so the phrase is regularly split across two +# lines and a plain grep for it finds nothing. +# +# Three things are asserted of a skill that uses the placeholder, because two +# of them were measured as mutations the suite did not notice (2026-09-25): +# `/run` instead of `/scripts/run`, and "one directory above" +# instead of "two", each of which sends the agent at a path that does not +# exist while every test stays green. Resolving the placeholder against this +# checkout is as close to executing the instruction as prose allows. +for f in "${skills[@]}"; do + case "$(cat "$f")" in + *''*) ;; + *) continue ;; + esac + folded="$(tr '\n' ' ' < "$f")" + + case "$folded" in + *'Base directory for this skill'*) + ok "$f: says where comes from" ;; + *) + fail "$f: says where comes from" \ + "the base-directory line the harness prints" "no such line" ;; + esac + + case "$folded" in + *'two directories above'*|*'two directories up'*) + ok "$f: says how far above the base directory the plugin is" ;; + *) + fail "$f: says how far above the base directory the plugin is" \ + "two directories" "$(printf '%s' "$folded" | grep -o '[a-z]* director[a-z]* \(above\|up\)' | head -n1)" ;; + esac + + missing="" + while IFS= read -r ref; do + [[ -n "$ref" ]] || continue + rel="${ref#/}" + rel="${rel%.}" + [[ -e "$rel" ]] || missing="$missing $ref" + done < <(grep -o '/[A-Za-z0-9_./-]*' "$f" | sort -u) + assert_eq "$f: every /… path it names exists in the plugin" "" "$missing" +done + summary diff --git a/tests/test-workstatus.sh b/tests/test-workstatus.sh index bd601be..f6bb4d4 100644 --- a/tests/test-workstatus.sh +++ b/tests/test-workstatus.sh @@ -4,11 +4,11 @@ cd "$(dirname "$0")/.." . tests/lib.sh ROOT="$(pwd)" -repo="$(sandbox)"; cp shim/run "$repo/.floppy/run" +repo="$(sandbox)" : > "$repo/.floppy/config" # 1. no hook: generic sections print, no project section appears. -out="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status 2>&1)" +out="$(cd "$repo" && bash "$ROOT/scripts/run" status 2>&1)" assert_contains "core sections are printed" "-- git" "$out" assert_contains "origin section is printed" "-- origin" "$out" assert_contains "status slice section is printed" "-- status slice" "$out" @@ -29,7 +29,7 @@ cat > "$repo/.floppy/workstatus-project.sh" <<'EOF' echo " corpora: 3" EOF chmod +x "$repo/.floppy/workstatus-project.sh" -out2="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status 2>&1)" +out2="$(cd "$repo" && bash "$ROOT/scripts/run" status 2>&1)" assert_contains "hook section header prints" "-- project" "$out2" assert_contains "hook output is included" "corpora: 3" "$out2" @@ -42,7 +42,7 @@ echo " partial output before the crash" exit 3 EOF chmod +x "$repo/.floppy/workstatus-project.sh" -out3="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status 2>&1)"; rc3=$? +out3="$(cd "$repo" && bash "$ROOT/scripts/run" status 2>&1)"; rc3=$? assert_rc "broken hook does not fail the report" 0 "$rc3" assert_contains "broken hook's partial output still shows" "partial output before the crash" "$out3" # Both halves of the warning, not the sentence. The reader's next move is to run @@ -63,7 +63,7 @@ EOF # only rewrites content, not permissions — so the exec bit must be dropped # explicitly, not just "not added", or this step silently tests the wrong thing. chmod -x "$repo/.floppy/workstatus-project.sh" -out4="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status 2>&1)"; rc4=$? +out4="$(cd "$repo" && bash "$ROOT/scripts/run" status 2>&1)"; rc4=$? assert_rc "non-executable hook does not fail the report" 0 "$rc4" assert_contains "non-executable hook is reported" "not executable" "$out4" case "$out4" in @@ -73,17 +73,17 @@ esac rm -f "$repo/.floppy/workstatus-project.sh" # 5. --flow prints the process-half block; the default run does not. -out5="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status 2>&1)" +out5="$(cd "$repo" && bash "$ROOT/scripts/run" status 2>&1)" case "$out5" in *"-- process:"*) fail "default run has no process-half block" "absent" "$out5";; *) ok "default run has no process-half block";; esac -out6="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status --flow 2>&1)" +out6="$(cd "$repo" && bash "$ROOT/scripts/run" status --flow 2>&1)" assert_contains "--flow prints the memory sub-section" "-- process: memory" "$out6" assert_contains "--flow prints the lock/worktree sub-section" "-- process: lock and worktrees" "$out6" assert_contains "--flow prints the recent-edits sub-section" "-- process: recent edits" "$out6" # 6. private_repo configured: the section reappears (even unwired). printf 'private_repo=git@example.com:workplace/agents-memory.git\nworkplace_project_key=test\n' > "$repo/.floppy/config" -out7="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status 2>&1)" +out7="$(cd "$repo" && bash "$ROOT/scripts/run" status 2>&1)" assert_contains "workplace section appears once private_repo is set" "-- workplace memory" "$out7" rm -rf "$repo" @@ -98,7 +98,7 @@ rm -rf "$repo" # that dies before reaching it satisfies every "does not say X" check for the # worst possible reason — which is exactly what the first draft of this block # did, having forgotten to copy the shim into the sandbox. -repoP="$(sandbox)"; cp shim/run "$repoP/.floppy/run" +repoP="$(sandbox)" # The checkout path is derived, not given: agents_memory_dir/.clones/. # Building it by hand here (rather than passing a directory) keeps the test # honest about the layout the verb actually wires. @@ -109,7 +109,7 @@ printf 'private_repo=git@example.com:workplace/agents-memory.git\nproject_key=te "$homeP/agents_memory" > "$repoP/.floppy/config" mkdir -p "$repoP/.agent-memory" ln -s "$wpP" "$repoP/.agent-memory/private" -outP="$(cd "$repoP" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status 2>&1)" +outP="$(cd "$repoP" && bash "$ROOT/scripts/run" status 2>&1)" assert_contains "wired private scope: the section runs at all" "-- workplace memory" "$outP" case "$outP" in *"is not a symlink"*) fail "wired private scope is not reported as unwired" "no nudge" "$outP" ;; @@ -119,7 +119,7 @@ esac # The nudge still appears when the link genuinely is missing, and it names the # configured directory rather than the old one. rm "$repoP/.agent-memory/private" -outP2="$(cd "$repoP" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status 2>&1)" +outP2="$(cd "$repoP" && bash "$ROOT/scripts/run" status 2>&1)" assert_contains "missing private link is still reported" "is not a symlink" "$outP2" assert_contains "the nudge names the private scope" ".agent-memory/private" "$outP2" case "$outP2" in @@ -131,10 +131,10 @@ esac # follow it rather than fall back to the new default and cry wolf. # # The script is called directly here, with the environment such a shim would -# leave. Going through `.floppy/run` cannot express this: the shim recomputes -# both variables from the config and overwrites whatever the caller exported, -# so the case it is meant to reproduce — an OLD shim in front of NEW scripts — -# is unreachable through it. +# leave. Going through the dispatcher cannot express this: it recomputes both +# variables from the config and overwrites whatever the caller exported, so the +# case it is meant to reproduce — an OLD consumer in front of NEW scripts — is +# unreachable through it. ln -s "$wpP" "$repoP/.agent-memory/local" outP3="$(cd "$repoP" && FLOPPY_REPO="$repoP" FLOPPY_MEMORY_DIR=.agent-memory \ FLOPPY_WORKPLACE_REPO=git@example.com:workplace/agents-memory.git \ @@ -152,13 +152,13 @@ rm -rf "$repoP" "$homeP" # read" when absent, because /start genuinely needs it. The personal one is # optional: a repository nobody has left a working note in has nothing to # report, and a warning there would read as a step somebody skipped. -repoN="$(sandbox)"; cp shim/run "$repoN/.floppy/run" +repoN="$(sandbox)" printf 'memory_dir=brain\nstatuses_now=state/NOW.md\n' > "$repoN/.floppy/config" mkdir -p "$repoN/state" "$repoN/brain" printf '| Notes | 1 | 2 | up |\n' > "$repoN/state/NOW.md" git -C "$repoN" add -A git -C "$repoN" -c user.email=t@t -c user.name=t commit -qm base -outN="$(cd "$repoN" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status 2>&1)"; rcN=$? +outN="$(cd "$repoN" && bash "$ROOT/scripts/run" status 2>&1)"; rcN=$? assert_rc "an absent personal status does not fail the report" 0 "$rcN" case "$outN" in *personal*) fail "an absent personal status is not mentioned at all" "no 'personal' line" "$outN" ;; @@ -202,14 +202,14 @@ done repoB="$tmpB/work" git clone -q "$originB" "$repoB" -mkdir -p "$repoB/.floppy"; cp shim/run "$repoB/.floppy/run" +mkdir -p "$repoB/.floppy" : > "$repoB/.floppy/config" # The clone has both remote-tracking refs. Now one branch goes away on the # remote — exactly what `gh pr merge --delete-branch` does. git -C "$seedB" push -q origin --delete gone-branch -outB="$(cd "$repoB" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status 2>&1)" +outB="$(cd "$repoB" && bash "$ROOT/scripts/run" status 2>&1)" assert_contains "deleted-branch case: the origin section runs at all" "-- origin" "$outB" assert_contains "a branch still on the remote is reported" "live-branch" "$outB" case "$outB" in @@ -231,11 +231,11 @@ esac # A name with two dots is not a translation. The gate and translation-check.py # have to agree on that: docs/CHANGELOG.old.md in a repository that never # translated anything used to raise the whole section. -repoF="$(sandbox)"; cp shim/run "$repoF/.floppy/run" +repoF="$(sandbox)" : > "$repoF/.floppy/config" mkdir -p "$repoF/docs" printf '# Changelog\n\nv1\n' > "$repoF/docs/CHANGELOG.old.md" -outF="$(cd "$repoF" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status --flow 2>&1)" +outF="$(cd "$repoF" && bash "$ROOT/scripts/run" status --flow 2>&1)" rm -rf "$repoF" case "$outF" in *"process: translations"*) fail "a two-dot name that is not a translation raises no section" "no section" "$outF" ;; @@ -248,11 +248,11 @@ esac # python regex is a literal codepoint range that no locale affects. If a # collation ever makes the two disagree, this goes red on the runner that # disagrees. -repoU="$(sandbox)"; cp shim/run "$repoU/.floppy/run" +repoU="$(sandbox)" : > "$repoU/.floppy/config" printf '# Guide\n\nbody\n' > "$repoU/guide.md" printf '# Guide\n\nbody\n' > "$repoU/guide.RU.md" -outU="$(cd "$repoU" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status --flow 2>&1)" +outU="$(cd "$repoU" && bash "$ROOT/scripts/run" status --flow 2>&1)" rm -rf "$repoU" case "$outU" in *"process: translations"*) fail "an uppercase language tag is not a translation" "no section" "$outU" ;; @@ -261,11 +261,11 @@ esac # A directory wearing a translation's name is not a translation either. `ls` # lists a directory's contents, so this used to open the section. -repoD="$(sandbox)"; cp shim/run "$repoD/.floppy/run" +repoD="$(sandbox)" : > "$repoD/.floppy/config" mkdir -p "$repoD/docs/x.ru.md" printf 'hi\n' > "$repoD/docs/x.ru.md/a.md" -outD="$(cd "$repoD" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status --flow 2>&1)" +outD="$(cd "$repoD" && bash "$ROOT/scripts/run" status --flow 2>&1)" rm -rf "$repoD" case "$outD" in *"process: translations"*) fail "a directory named like a translation is not a translation" "no section" "$outD" ;; @@ -275,12 +275,12 @@ esac # And a translation at the repository root is found, not only one under docs/. # translation-check.py scans both places, so a gate that scans one of them # reports nothing for a document that really has fallen behind. -repoR="$(sandbox)"; cp shim/run "$repoR/.floppy/run" +repoR="$(sandbox)" : > "$repoR/.floppy/config" printf '# Guide\n\nbody\n' > "$repoR/guide.md" printf '\n\n# Гид\n' \ 0000000000000000000000000000000000000000 > "$repoR/guide.ru.md" -outR="$(cd "$repoR" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status --flow 2>&1)" +outR="$(cd "$repoR" && bash "$ROOT/scripts/run" status --flow 2>&1)" rm -rf "$repoR" if command -v python3 >/dev/null 2>&1; then # Two assertions, and the first is the one that reproduces the defect: with a @@ -300,14 +300,14 @@ fi # every unrelated section in between. Built here rather than read out of this # repository, so the test says the wiring works rather than that this repository # happens to contain a translation. -repoT="$(sandbox)"; cp shim/run "$repoT/.floppy/run" +repoT="$(sandbox)" : > "$repoT/.floppy/config" mkdir -p "$repoT/docs" printf '# Doc\n\nbody\n' > "$repoT/docs/x.md" # A blob sha no content produces, so the translation is behind by construction. printf '\n\n# Док\n' \ 0000000000000000000000000000000000000000 > "$repoT/docs/x.ru.md" -outT="$(cd "$repoT" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status --flow 2>&1)" +outT="$(cd "$repoT" && bash "$ROOT/scripts/run" status --flow 2>&1)" rm -rf "$repoT" if command -v python3 >/dev/null 2>&1; then assert_contains "--flow prints the translations sub-section" "-- process: translations" "$outT" @@ -322,13 +322,13 @@ fi # on the branch that split the guide out of README.md; deleting those two # glob terms leaves this whole suite green, so the guard has to be a case that # goes red on that deletion, not prose. -repoG="$(sandbox)"; cp shim/run "$repoG/.floppy/run" +repoG="$(sandbox)" : > "$repoG/.floppy/config" mkdir -p "$repoG/docs/guide" printf '# Doc\n\nbody\n' > "$repoG/docs/guide/x.md" printf '\n\n# Док\n' \ 0000000000000000000000000000000000000000 > "$repoG/docs/guide/x.ru.md" -outG="$(cd "$repoG" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status --flow 2>&1)" +outG="$(cd "$repoG" && bash "$ROOT/scripts/run" status --flow 2>&1)" rm -rf "$repoG" if command -v python3 >/dev/null 2>&1; then assert_contains "--flow prints the translations sub-section for docs/guide/" \ @@ -347,13 +347,13 @@ if command -v python3 >/dev/null 2>&1; then # 1. A dotfile translation. Matched by the checker's regex from the start and # invisible to every shell glob the gate used to run, so this repository had a # translation and no section about it. - repoH="$(sandbox)"; cp shim/run "$repoH/.floppy/run" + repoH="$(sandbox)" : > "$repoH/.floppy/config" mkdir -p "$repoH/docs" printf '# Doc\n\nbody\n' > "$repoH/docs/.hidden.md" printf '\n\n# Док\n' \ 0000000000000000000000000000000000000000 > "$repoH/docs/.hidden.ru.md" - outH="$(cd "$repoH" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status --flow 2>&1)" + outH="$(cd "$repoH" && bash "$ROOT/scripts/run" status --flow 2>&1)" rm -rf "$repoH" assert_contains "a dotfile translation raises the section" \ "-- process: translations" "$outH" @@ -364,11 +364,11 @@ if command -v python3 >/dev/null 2>&1; then # loose — and the checker lists nothing, so there must be no section at all. # A "clean" heading here would be a statement about a feature this repository # does not use. - repoU="$(sandbox)"; cp shim/run "$repoU/.floppy/run" + repoU="$(sandbox)" : > "$repoU/.floppy/config" printf '# Guide\n\nbody\n' > "$repoU/guide.md" printf '# Guide\n' > "$repoU/guide.RU.md" - outU="$(cd "$repoU" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run status --flow 2>&1)" + outU="$(cd "$repoU" && bash "$ROOT/scripts/run" status --flow 2>&1)" rm -rf "$repoU" case "$outU" in *"process: translations"*) fail "an uppercase tag raises no section" "no section" "$outU" ;; diff --git a/tests/test-wrap-flow.sh b/tests/test-wrap-flow.sh index d83e488..a642ea3 100755 --- a/tests/test-wrap-flow.sh +++ b/tests/test-wrap-flow.sh @@ -34,7 +34,7 @@ EOFM # A bare repository stands in for the remote. Never a real one: this script # pushes, and a test that reaches a real remote is a test that publishes. remote="$(cd "$(mktemp -d)" && pwd -P)"; git init -q --bare -b main "$remote" -repo="$(sandbox)"; cp shim/run "$repo/.floppy/run" +repo="$(sandbox)" cat > "$repo/.floppy/config" <<'EOF2' memory_dir=brain statuses_now=state/NOW.md @@ -75,7 +75,7 @@ remote_before="$(git -C "$remote" rev-parse refs/heads/main)" # repository where everything is otherwise in order. printf 'edit\n' >> "$repo/state/NOW.md" before="$(git -C "$repo" rev-parse HEAD)" -out="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run check state/NOW.md 2>&1)"; rc1=$? +out="$(cd "$repo" && bash "$ROOT/scripts/run" check state/NOW.md 2>&1)"; rc1=$? after="$(git -C "$repo" rev-parse HEAD)" assert_rc "check succeeds" 0 "$rc1" assert_eq "check does not commit" "$before" "$after" @@ -84,8 +84,8 @@ assert_eq "check does not push" "$remote_before" "$(git -C "$remo assert_contains "check reports the file list" "NOW.md" "$out" # IMPORTANT 4: no private_repo in this repo's config — wrap-check.sh must -# skip the "workplace memory" section entirely rather than nudge "bash -# .floppy/run workplace", which then dead-ends on a missing project key. +# skip the "workplace memory" section entirely rather than nudge the +# `workplace` verb, which then dead-ends on a missing project key. case "$out" in *"-- workplace memory"*) fail "check: no workplace section without private_repo" "section absent" "$out" ;; *) ok "check: no workplace section without private_repo" ;; @@ -93,7 +93,7 @@ esac # check refuses a file outside the watched paths, and refuses loudly printf 'x\n' > "$repo/stray.txt" -out2="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run check stray.txt 2>&1)"; rc2=$? +out2="$(cd "$repo" && bash "$ROOT/scripts/run" check stray.txt 2>&1)"; rc2=$? assert_rc "check refuses an unwatched file" 1 "$rc2" # commit refuses too, when the guard rejects the file list: nothing staged, @@ -103,7 +103,7 @@ assert_rc "check refuses an unwatched file" 1 "$rc2" # sibling script must fail loudly here rather than let the commit through # ungated. before_gate="$(git -C "$repo" rev-parse HEAD)" -out_gate="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "should not land" stray.txt 2>&1)"; rc_gate=$? +out_gate="$(cd "$repo" && bash "$ROOT/scripts/run" commit -m "should not land" stray.txt 2>&1)"; rc_gate=$? after_gate="$(git -C "$repo" rev-parse HEAD)" assert_rc "commit refuses a file the guard rejects (rc)" 1 "$rc_gate" assert_eq "gate failure: nothing committed" "$before_gate" "$after_gate" @@ -115,7 +115,7 @@ rm -f "$repo/stray.txt" # "moved past base" checks matter as much as the equality: without them this # assertion would pass just as well if the command failed outright and left # both sides sitting at the unchanged base commit. -out3="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "note update" state/NOW.md 2>&1)"; rc3=$? +out3="$(cd "$repo" && bash "$ROOT/scripts/run" commit -m "note update" state/NOW.md 2>&1)"; rc3=$? after_commit="$(git -C "$repo" rev-parse HEAD)" assert_rc "commit succeeds" 0 "$rc3" case "$after_commit" in @@ -138,7 +138,7 @@ rm -rf "$repo" "$remote" # first, and this session's edit is still sitting uncommitted when `commit` # runs. remote2="$(cd "$(mktemp -d)" && pwd -P)"; git init -q --bare -b main "$remote2" -repoA="$(sandbox)"; cp shim/run "$repoA/.floppy/run" +repoA="$(sandbox)" cat > "$repoA/.floppy/config" <<'EOF3' memory_dir=brain statuses_now=state/NOW.md @@ -184,7 +184,7 @@ git -C "$repoB" push -q origin main # own yet, exactly the shape the trap needs. printf 'line1\nline2\nline3-from-this-session\n' > "$repoA/state/NOW.md" -out4="$(cd "$repoA" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "this session's edit" state/NOW.md 2>&1)"; rc4=$? +out4="$(cd "$repoA" && bash "$ROOT/scripts/run" commit -m "this session's edit" state/NOW.md 2>&1)"; rc4=$? assert_rc "pull-after-commit: commit succeeds despite the remote having diverged" 0 "$rc4" assert_eq "pull-after-commit: local head reaches the remote" \ "$(git -C "$repoA" rev-parse HEAD)" "$(git -C "$remote2" rev-parse refs/heads/main)" @@ -202,7 +202,7 @@ rm -rf "$repoA" "$repoB" "$remote2" # repository has the identical shape whenever a session edited product code # before wrapping. remote3="$(cd "$(mktemp -d)" && pwd -P)"; git init -q --bare -b main "$remote3" -repoC="$(sandbox)"; cp shim/run "$repoC/.floppy/run" +repoC="$(sandbox)" cat > "$repoC/.floppy/config" <<'EOFC' memory_dir=brain statuses_now=state/NOW.md @@ -220,7 +220,7 @@ git -C "$repoC" push -q -u origin main printf 'half-done product edit\n' >> "$repoC/src/app.txt" printf '| Notes | 1 | 3 | up |\n' > "$repoC/state/NOW.md" -outPD="$(cd "$repoC" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "status update" state/NOW.md 2>&1)"; rcPD=$? +outPD="$(cd "$repoC" && bash "$ROOT/scripts/run" commit -m "status update" state/NOW.md 2>&1)"; rcPD=$? assert_rc "product dirt: commit still syncs and pushes (rc)" 0 "$rcPD" assert_eq "product dirt: local head reaches the remote" \ "$(git -C "$repoC" rev-parse HEAD)" "$(git -C "$remote3" rev-parse refs/heads/main)" @@ -243,7 +243,7 @@ rm -rf "$repoC" "$remote3" # leak into a real 30-minute block on every failed gate. Assert on the actual # `lock status` text, not just on exit code: a test that only checks rc would # not have caught this. -repoL="$(sandbox)"; cp shim/run "$repoL/.floppy/run" +repoL="$(sandbox)" cat > "$repoL/.floppy/config" <<'EOFLa' memory_dir=brain statuses_now=state/NOW.md @@ -257,31 +257,31 @@ git -C "$repoL" add -A git -C "$repoL" -c user.email=t@t -c user.name=t commit -qm base # 1. guard-red path: an unwatched file makes wrap-guard.sh fail. -acqL1="$(cd "$repoL" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lock acquire "guard-red-test" 2>&1)" +acqL1="$(cd "$repoL" && bash "$ROOT/scripts/run" lock acquire "guard-red-test" 2>&1)" assert_contains "lock-release setup: lock acquired before guard-red run" "ok lock acquired" "$acqL1" printf 'x\n' > "$repoL/stray.txt" -outL1="$(cd "$repoL" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "should fail" stray.txt 2>&1)"; rcL1=$? +outL1="$(cd "$repoL" && bash "$ROOT/scripts/run" commit -m "should fail" stray.txt 2>&1)"; rcL1=$? assert_rc "guard-red commit fails" 1 "$rcL1" -statusL1="$(cd "$repoL" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lock status 2>&1)" +statusL1="$(cd "$repoL" && bash "$ROOT/scripts/run" lock status 2>&1)" assert_contains "guard-red path: lock is free afterwards" "free" "$statusL1" rm -f "$repoL/stray.txt" # 2. memory-lint-red path: an index pointer to a note that no longer exists. rm -f "$repoL/brain/half/a-note.md" -acqL2="$(cd "$repoL" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lock acquire "lint-red-test" 2>&1)" +acqL2="$(cd "$repoL" && bash "$ROOT/scripts/run" lock acquire "lint-red-test" 2>&1)" assert_contains "lock-release setup: lock acquired before lint-red run" "ok lock acquired" "$acqL2" -outL2="$(cd "$repoL" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "should fail" state/NOW.md 2>&1)"; rcL2=$? +outL2="$(cd "$repoL" && bash "$ROOT/scripts/run" commit -m "should fail" state/NOW.md 2>&1)"; rcL2=$? assert_rc "memory-lint-red commit fails" 1 "$rcL2" -statusL2="$(cd "$repoL" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lock status 2>&1)" +statusL2="$(cd "$repoL" && bash "$ROOT/scripts/run" lock status 2>&1)" assert_contains "memory-lint-red path: lock is free afterwards" "free" "$statusL2" rm -rf "$repoL" # 3. the success path still releases exactly once, and still tells the human. remoteL="$(cd "$(mktemp -d)" && pwd -P)"; git init -q --bare -b main "$remoteL" -repoL2="$(sandbox)"; cp shim/run "$repoL2/.floppy/run" +repoL2="$(sandbox)" cat > "$repoL2/.floppy/config" <<'EOFLb' memory_dir=brain statuses_now=state/NOW.md @@ -297,14 +297,14 @@ git -C "$repoL2" remote add origin "$remoteL" git -C "$repoL2" push -q -u origin main printf 'edit\n' >> "$repoL2/state/NOW.md" -acqL3="$(cd "$repoL2" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lock acquire "success-test" 2>&1)" +acqL3="$(cd "$repoL2" && bash "$ROOT/scripts/run" lock acquire "success-test" 2>&1)" assert_contains "lock-release setup: lock acquired before success run" "ok lock acquired" "$acqL3" -outL3="$(cd "$repoL2" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "ok" state/NOW.md 2>&1)"; rcL3=$? +outL3="$(cd "$repoL2" && bash "$ROOT/scripts/run" commit -m "ok" state/NOW.md 2>&1)"; rcL3=$? assert_rc "success path commit succeeds" 0 "$rcL3" n_reports="$(printf '%s\n' "$outL3" | grep -c 'ok lock released')" assert_eq "success path reports the release exactly once" "1" "$n_reports" -statusL3="$(cd "$repoL2" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lock status 2>&1)" +statusL3="$(cd "$repoL2" && bash "$ROOT/scripts/run" lock status 2>&1)" assert_contains "success path: lock is free afterwards" "free" "$statusL3" rm -rf "$repoL2" "$remoteL" @@ -320,7 +320,7 @@ cp -R "$ROOT"/. "$fake_plugin"/ 2>/dev/null rm -rf "$fake_plugin/.git" rm -f "$fake_plugin/scripts/wrap-guard.sh" -repoG="$(sandbox)"; cp shim/run "$repoG/.floppy/run" +repoG="$(sandbox)" cat > "$repoG/.floppy/config" <<'EOFG' memory_dir=brain statuses_now=state/NOW.md @@ -334,7 +334,7 @@ git -C "$repoG" add -A git -C "$repoG" -c user.email=t@t -c user.name=t commit -qm base printf 'edit\n' >> "$repoG/state/NOW.md" -outG="$(cd "$repoG" && AI_FLOPPY_HOME="$fake_plugin" bash .floppy/run commit -m "should fail" state/NOW.md 2>&1)"; rcG=$? +outG="$(cd "$repoG" && bash "$fake_plugin/scripts/run" commit -m "should fail" state/NOW.md 2>&1)"; rcG=$? assert_rc "missing wrap-guard.sh: commit refuses (rc)" 1 "$rcG" assert_contains "missing wrap-guard.sh: names it as a broken installation" \ "broken plugin installation" "$outG" @@ -353,7 +353,7 @@ rm -rf "$repoG" "$fake_plugin" # half). commit_push=never in .floppy/config opts a repository like that out # of the whole pull+push tail. Deliberately no `git remote add` here — this # is the "no upstream" repository the finding was about. -repoNR="$(sandbox)"; cp shim/run "$repoNR/.floppy/run" +repoNR="$(sandbox)" cat > "$repoNR/.floppy/config" <<'EOFNR' memory_dir=brain statuses_now=state/NOW.md @@ -369,7 +369,7 @@ git -C "$repoNR" -c user.email=t@t -c user.name=t commit -qm base printf 'edit\n' >> "$repoNR/state/NOW.md" before_nr="$(git -C "$repoNR" rev-parse HEAD)" -outNR="$(cd "$repoNR" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "local only" state/NOW.md 2>&1)"; rcNR=$? +outNR="$(cd "$repoNR" && bash "$ROOT/scripts/run" commit -m "local only" state/NOW.md 2>&1)"; rcNR=$? after_nr="$(git -C "$repoNR" rev-parse HEAD)" assert_rc "commit_push=never: commit succeeds with no remote at all" 0 "$rcNR" case "$after_nr" in @@ -387,7 +387,7 @@ rm -rf "$repoNR" # human reading only the failure learns nothing about which repository it was # about to touch. Assert on ORDER, not just presence: the "-- target" section # must come before "-- gates" in the actual output. -repoT="$(sandbox)"; cp shim/run "$repoT/.floppy/run" +repoT="$(sandbox)" cat > "$repoT/.floppy/config" <<'EOFT' memory_dir=brain statuses_now=state/NOW.md @@ -401,7 +401,7 @@ git -C "$repoT" add -A git -C "$repoT" -c user.email=t@t -c user.name=t commit -qm base printf 'x\n' > "$repoT/stray.txt" -outT="$(cd "$repoT" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "should fail" stray.txt 2>&1)"; rcT=$? +outT="$(cd "$repoT" && bash "$ROOT/scripts/run" commit -m "should fail" stray.txt 2>&1)"; rcT=$? assert_rc "target-naming: the gate still rejects the commit (rc)" 1 "$rcT" assert_contains "target-naming: names the resolved repository" "repo: $repoT" "$outT" assert_contains "target-naming: names the branch" "branch: main" "$outT" @@ -426,7 +426,7 @@ rm -rf "$repoT" # with "did not match any files" and takes the whole commit down. Measured on # this machine's git: `git add -A -- ` fails identically, so widening the # add is not the fix. A deletion staged by `git rm` needs no staging at all. -repoD="$(sandbox)"; cp shim/run "$repoD/.floppy/run" +repoD="$(sandbox)" cat > "$repoD/.floppy/config" <<'EOFD' memory_dir=brain statuses_now=state/NOW.md @@ -442,7 +442,7 @@ git -C "$repoD" add -A git -C "$repoD" -c user.email=t@t -c user.name=t commit -qm base git -C "$repoD" rm -q state/OLD.md -outD="$(cd "$repoD" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "drop the superseded note" state/OLD.md 2>&1)"; rcD=$? +outD="$(cd "$repoD" && bash "$ROOT/scripts/run" commit -m "drop the superseded note" state/OLD.md 2>&1)"; rcD=$? assert_rc "git rm: the commit goes through (rc)" 0 "$rcD" case "$outD" in *"git add failed"*) fail "git rm: staging does not fail on the deleted path" "no add failure" "$outD" ;; @@ -458,7 +458,7 @@ assert_eq "git rm: nothing left uncommitted" "" \ # A path that never existed must still fail — the skip is for a staged # deletion, not a blanket "ignore what git cannot find". printf 'x\n' > "$repoD/state/NEW.md" -outD2="$(cd "$repoD" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "typo in the list" state/NEW.md state/nosuch.md 2>&1)"; rcD2=$? +outD2="$(cd "$repoD" && bash "$ROOT/scripts/run" commit -m "typo in the list" state/NEW.md state/nosuch.md 2>&1)"; rcD2=$? assert_rc "git rm: a typo in the file list still fails (rc)" 1 "$rcD2" rm -rf "$repoD" @@ -471,7 +471,7 @@ rm -rf "$repoD" # a branch created for it, and such a branch never has an upstream on its # first commit — so this is now every close, not an occasional one. remoteB="$(cd "$(mktemp -d)" && pwd -P)"; git init -q --bare -b main "$remoteB" -repoB="$(sandbox)"; cp shim/run "$repoB/.floppy/run" +repoB="$(sandbox)" cat > "$repoB/.floppy/config" <<'EOFB' memory_dir=brain statuses_now=state/NOW.md @@ -489,7 +489,7 @@ git -C "$repoB" push -q -u origin main # the wrap branch: created here, unknown to the remote, no upstream git -C "$repoB" switch -q -c wrap-branch printf 'edit\n' >> "$repoB/state/NOW.md" -outB="$(cd "$repoB" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "close on a fresh branch" state/NOW.md 2>&1)"; rcB=$? +outB="$(cd "$repoB" && bash "$ROOT/scripts/run" commit -m "close on a fresh branch" state/NOW.md 2>&1)"; rcB=$? assert_rc "fresh branch: the whole tail succeeds (rc)" 0 "$rcB" assert_eq "fresh branch: the remote received it" \ "$(git -C "$repoB" rev-parse HEAD)" "$(git -C "$remoteB" rev-parse refs/heads/wrap-branch 2>/dev/null)" @@ -507,7 +507,7 @@ esac # must not be a way of skipping it. Second commit on the same branch — the # upstream is set now, so the pull runs again. printf 'more\n' >> "$repoB/state/NOW.md" -outB2="$(cd "$repoB" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "second close" state/NOW.md 2>&1)"; rcB2=$? +outB2="$(cd "$repoB" && bash "$ROOT/scripts/run" commit -m "second close" state/NOW.md 2>&1)"; rcB2=$? assert_rc "upstream exists: still succeeds (rc)" 0 "$rcB2" assert_eq "upstream exists: the remote received it too" \ "$(git -C "$repoB" rev-parse HEAD)" "$(git -C "$remoteB" rev-parse refs/heads/wrap-branch 2>/dev/null)" @@ -526,7 +526,7 @@ rm -rf "$repoB" "$remoteB" # push", which is advice that cannot work. A pre-receive hook stands in for # the branch rule: this test must never reach a real remote. remoteP="$(cd "$(mktemp -d)" && pwd -P)"; git init -q --bare -b main "$remoteP" -repoP="$(sandbox)"; cp shim/run "$repoP/.floppy/run" +repoP="$(sandbox)" cat > "$repoP/.floppy/config" <<'EOFP' memory_dir=brain statuses_now=state/NOW.md @@ -548,7 +548,7 @@ EOFPH chmod +x "$remoteP/hooks/pre-receive" printf 'edit\n' >> "$repoP/state/NOW.md" -outP="$(cd "$repoP" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "refused by the rule" state/NOW.md 2>&1)"; rcP=$? +outP="$(cd "$repoP" && bash "$ROOT/scripts/run" commit -m "refused by the rule" state/NOW.md 2>&1)"; rcP=$? assert_rc "branch rule: the call still fails (rc)" 1 "$rcP" assert_contains "branch rule: names the rule, not the network" "branch rule" "$outP" assert_contains "branch rule: names the branch that is protected" "main is protected" "$outP" @@ -572,7 +572,7 @@ exit 1 EOFPH2 chmod +x "$remoteP/hooks/pre-receive" printf 'again\n' >> "$repoP/state/NOW.md" -outP2="$(cd "$repoP" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run commit -m "refused for another reason" state/NOW.md 2>&1)"; rcP2=$? +outP2="$(cd "$repoP" && bash "$ROOT/scripts/run" commit -m "refused for another reason" state/NOW.md 2>&1)"; rcP2=$? assert_rc "ordinary push failure: still fails (rc)" 1 "$rcP2" assert_contains "ordinary push failure: keeps the generic advice" "Retry: git push" "$outP2" case "$outP2" in @@ -588,7 +588,7 @@ rm -rf "$repoP" "$remoteP" # mapped every non-zero code to its problem-counting branch, and since a # refusal prints no " x" lines the human got "MEMORY LINT IS RED, 0 # problem(s)" and nothing else — red with no reason given. -repoR="$(sandbox)"; cp shim/run "$repoR/.floppy/run" +repoR="$(sandbox)" cat > "$repoR/.floppy/config" <<'EOFR' memory_dir=brain statuses_now=state/NOW.md @@ -600,7 +600,7 @@ git -C "$repoR" add -A git -C "$repoR" -c user.email=t@t -c user.name=t commit -qm base printf '| Notes | 1 | 3 | up |\n' > "$repoR/state/NOW.md" -outR="$(cd "$repoR" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run check state/NOW.md 2>&1)"; rcR=$? +outR="$(cd "$repoR" && bash "$ROOT/scripts/run" check state/NOW.md 2>&1)"; rcR=$? assert_rc "lint refusal: check is still red (rc)" 1 "$rcR" assert_contains "lint refusal: the linter's own reason is shown" \ "does not use this memory layout" "$outR" diff --git a/tests/test-wrap-guard.sh b/tests/test-wrap-guard.sh index 49a74f8..886756e 100644 --- a/tests/test-wrap-guard.sh +++ b/tests/test-wrap-guard.sh @@ -4,7 +4,7 @@ cd "$(dirname "$0")/.." . tests/lib.sh ROOT="$(pwd)" -repo="$(sandbox)"; cp shim/run "$repo/.floppy/run" +repo="$(sandbox)" cat > "$repo/.floppy/config" <<'EOF' memory_dir=brain statuses_now=state/NOW.md @@ -18,7 +18,7 @@ git -C "$repo" add -A && git -C "$repo" -c user.email=t@t -c user.name=t commit # 1. journal touched, NOW.md not in the list printf 'more\n' >> "$repo/state/2026-01-01_status.md" -out="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run guard state/2026-01-01_status.md 2>&1)" +out="$(cd "$repo" && bash "$ROOT/scripts/run" guard state/2026-01-01_status.md 2>&1)" # Check the configured path itself, not the bare "NOW.md" substring: the # section header "-- journal without NOW.md" always contains that substring, # even when the guard fails to catch anything, so a weaker check would pass @@ -27,24 +27,24 @@ assert_contains "journal without NOW is caught" "state/NOW.md is not in your lis # 2. a trend row disappeared printf '' > "$repo/state/NOW.md" -out2="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run guard state/NOW.md state/2026-01-01_status.md 2>&1)" +out2="$(cd "$repo" && bash "$ROOT/scripts/run" guard state/NOW.md state/2026-01-01_status.md 2>&1)" assert_contains "dropped trend row is caught" "Notes" "$out2" # 3. the cap, and it comes from config not from a constant printf '| Notes | 1 | 2 | up |\n' > "$repo/state/NOW.md" head -c 400 /dev/zero | tr '\0' 'x' >> "$repo/state/NOW.md" -out3="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run guard state/NOW.md 2>&1)" +out3="$(cd "$repo" && bash "$ROOT/scripts/run" guard state/NOW.md 2>&1)" assert_contains "cap is read from config" "200" "$out3" # 4. a claimed file outside every watched path -out4="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run guard outside/file.txt 2>&1)"; rc4=$? +out4="$(cd "$repo" && bash "$ROOT/scripts/run" guard outside/file.txt 2>&1)"; rc4=$? assert_rc "outside-watch file is refused (rc)" 1 "$rc4" assert_contains "outside-watch file is named" "outside/file.txt" "$out4" # 5. a claimed file that was not actually modified printf '| Notes | 1 | 2 | up |\n' > "$repo/state/NOW.md" git -C "$repo" add -A && git -C "$repo" -c user.email=t@t -c user.name=t commit -qm settle -out5="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run guard state/NOW.md 2>&1)"; rc5=$? +out5="$(cd "$repo" && bash "$ROOT/scripts/run" guard state/NOW.md 2>&1)"; rc5=$? assert_rc "unchanged claimed file is refused (rc)" 1 "$rc5" assert_contains "unchanged claimed file is named" "not changed" "$out5" @@ -56,7 +56,7 @@ printf '| Metric | before | after |\n|---|---|---|\n| Latency | 10ms | 8ms |\n| git -C "$repo" add -A && git -C "$repo" -c user.email=t@t -c user.name=t commit -qm "table base" printf '| KPI | before | after |\n|---|---|---|\n| Latency | 10ms | 8ms |\n| Errors | 3 | 1 |\n' > "$repo/state/NOW.md" -out6="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run guard state/NOW.md 2>&1)"; rc6=$? +out6="$(cd "$repo" && bash "$ROOT/scripts/run" guard state/NOW.md 2>&1)"; rc6=$? assert_rc "renamed header column is not flagged (rc)" 0 "$rc6" case "$out6" in *"dropped from NOW.md"*) fail "renamed header column is not flagged" "no dropped-row error" "$out6" ;; @@ -64,7 +64,7 @@ case "$out6" in esac printf '| KPI | before | after |\n|---|---|---|\n| Latency | 10ms | 8ms |\n' > "$repo/state/NOW.md" -out7="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run guard state/NOW.md 2>&1)"; rc7=$? +out7="$(cd "$repo" && bash "$ROOT/scripts/run" guard state/NOW.md 2>&1)"; rc7=$? assert_rc "actual dropped data row is still caught (rc)" 1 "$rc7" assert_contains "actual dropped data row is named" "Errors" "$out7" @@ -79,7 +79,7 @@ git -C "$repo" add -A && git -C "$repo" -c user.email=t@t -c user.name=t commit # an improved row and a one-time "done" row may both go printf '| KPI | before | after | dir |\n|---|---|---|---|\n| Errors | 1 | 3 | worse |\n' > "$repo/state/NOW.md" -out8="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run guard state/NOW.md 2>&1)"; rc8=$? +out8="$(cd "$repo" && bash "$ROOT/scripts/run" guard state/NOW.md 2>&1)"; rc8=$? assert_rc "unmarked rows may be dropped (rc)" 0 "$rc8" case "$out8" in *"dropped from NOW.md"*) fail "unmarked rows may be dropped" "no dropped-row error" "$out8" ;; @@ -88,14 +88,14 @@ esac # the row marked as a regression may not, and the guard still names it printf '| KPI | before | after | dir |\n|---|---|---|---|\n| Latency | 10ms | 8ms | better |\n| Shipped | no | yes | done |\n' > "$repo/state/NOW.md" -out9="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run guard state/NOW.md 2>&1)"; rc9=$? +out9="$(cd "$repo" && bash "$ROOT/scripts/run" guard state/NOW.md 2>&1)"; rc9=$? assert_rc "dropped regression row is still caught (rc)" 1 "$rc9" assert_contains "dropped regression row is named" "Errors" "$out9" # a row that stopped being a regression counts as present, not as dropped: # the new side is unfiltered on purpose printf '| KPI | before | after | dir |\n|---|---|---|---|\n| Errors | 3 | 1 | better |\n' > "$repo/state/NOW.md" -out10="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run guard state/NOW.md 2>&1)"; rc10=$? +out10="$(cd "$repo" && bash "$ROOT/scripts/run" guard state/NOW.md 2>&1)"; rc10=$? assert_rc "recovered row is not read as dropped (rc)" 0 "$rc10" rm -rf "$repo" diff --git a/tests/test-wrap-lock.sh b/tests/test-wrap-lock.sh index d023e2b..5d7a03e 100644 --- a/tests/test-wrap-lock.sh +++ b/tests/test-wrap-lock.sh @@ -4,7 +4,7 @@ cd "$(dirname "$0")/.." . tests/lib.sh ROOT="$(pwd)" -repo="$(sandbox)"; cp shim/run "$repo/.floppy/run" +repo="$(sandbox)" # 1. acquire, then immediately try to acquire again from the same sandbox: # the second attempt must refuse, and must not claim the lock is abandoned. @@ -12,11 +12,11 @@ repo="$(sandbox)"; cp shim/run "$repo/.floppy/run" # report): a lock acquired one instant earlier read as ~57 years old there, # because `stat -c %Y` does not exist on macOS and the `|| echo 0` fallback # made every mtime read as the epoch. -out1="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lock acquire "first" 2>&1)"; rc1=$? +out1="$(cd "$repo" && bash "$ROOT/scripts/run" lock acquire "first" 2>&1)"; rc1=$? assert_rc "first acquire succeeds" 0 "$rc1" assert_contains "first acquire reports ok" "ok lock acquired" "$out1" -out2="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lock acquire "second" 2>&1)"; rc2=$? +out2="$(cd "$repo" && bash "$ROOT/scripts/run" lock acquire "second" 2>&1)"; rc2=$? assert_rc "second immediate acquire refuses" 1 "$rc2" assert_contains "second acquire names the holder" "another session holds the wrap lock" "$out2" case "$out2" in @@ -25,7 +25,7 @@ case "$out2" in esac # 2. status on a fresh lock reports it as held, not as abandoned. -out3="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lock status 2>&1)"; rc3=$? +out3="$(cd "$repo" && bash "$ROOT/scripts/run" lock status 2>&1)"; rc3=$? assert_rc "status on fresh lock reports held (rc)" 1 "$rc3" assert_contains "status on fresh lock says held" "held, younger than" "$out3" case "$out3" in @@ -33,27 +33,27 @@ case "$out3" in *) ok "status on fresh lock does not say abandoned" ;; esac -out4="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lock release 2>&1)" +out4="$(cd "$repo" && bash "$ROOT/scripts/run" lock release 2>&1)" assert_contains "release clears the lock" "ok lock released" "$out4" # 3. a lock whose owner file is genuinely old IS taken over, and the takeover # is announced. Backdate with touch -t rather than sleeping. -out5="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lock acquire "stale-owner" 2>&1)" +out5="$(cd "$repo" && bash "$ROOT/scripts/run" lock acquire "stale-owner" 2>&1)" assert_contains "setup: stale-owner lock acquired" "ok lock acquired" "$out5" gitdir="$(cd "$repo" && git rev-parse --absolute-git-dir)" owner="$gitdir/wrap.lock/owner" old_stamp="$(date -v-60M +%Y%m%d%H%M.%S 2>/dev/null || date -d '60 minutes ago' +%Y%m%d%H%M.%S)" touch -t "$old_stamp" "$owner" -out6="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lock acquire "new-owner" 2>&1)"; rc6=$? +out6="$(cd "$repo" && bash "$ROOT/scripts/run" lock acquire "new-owner" 2>&1)"; rc6=$? assert_rc "stale lock is taken over (rc)" 0 "$rc6" assert_contains "takeover is announced" "Taking it over" "$out6" assert_contains "takeover names the previous owner" "was: owner=stale-owner" "$out6" -out7="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lock status 2>&1)" +out7="$(cd "$repo" && bash "$ROOT/scripts/run" lock status 2>&1)" assert_contains "status after takeover names the new owner" "owner=new-owner" "$out7" -out8="$(cd "$repo" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lock status 2>&1)" +out8="$(cd "$repo" && bash "$ROOT/scripts/run" lock status 2>&1)" assert_contains "status names what the lock covers" "covers:" "$out8" assert_contains "memory inside the repository: the scope is this working copy" \ "this working copy only" "$out8" @@ -71,7 +71,7 @@ git init -q -b main "$storeS" mkdir -p "$storeS/projects/acme/memory" "$storeS/projects/other/memory" git -C "$storeS" commit -q --allow-empty -m base -repoS="$(sandbox)"; cp shim/run "$repoS/.floppy/run" +repoS="$(sandbox)" cat > "$repoS/.floppy/config" <&1)"; rcS1=$? +outS1="$(cd "$repoS" && bash "$ROOT/scripts/run" lock acquire "session A" 2>&1)"; rcS1=$? assert_rc "store layout: acquire succeeds" 0 "$rcS1" assert_contains "store layout: the scope is the store, not the clone" \ "every session on this machine writing $storeS" "$outS1" @@ -92,20 +92,20 @@ assert_eq "store layout: and NOT in this repository's git dir" \ # The regression itself: a second worktree of the same clone. .floppy/ is # tracked so it arrives with the checkout; the memory symlink is gitignored and -# is created per worktree, exactly as `bash .floppy/run store` does. +# is created per worktree, exactly as `bash "$ROOT/scripts/run" store` does. wtS="$(cd "$(mktemp -d)" && pwd -P)/wt" git -C "$repoS" worktree add -q "$wtS" -b second 2>/dev/null ln -s "$storeS/projects/acme/memory" "$wtS/.agent-memory" assert_eq "setup: the two worktrees really do have different git dirs" "1" \ "$([[ "$(cd "$repoS" && git rev-parse --absolute-git-dir)" == "$(cd "$wtS" && git rev-parse --absolute-git-dir)" ]] && echo 0 || echo 1)" -outS2="$(cd "$wtS" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lock acquire "session B" 2>&1)"; rcS2=$? +outS2="$(cd "$wtS" && bash "$ROOT/scripts/run" lock acquire "session B" 2>&1)"; rcS2=$? assert_rc "store layout: a second worktree is refused (rc)" 1 "$rcS2" assert_contains "store layout: and told which session holds it" "owner=session A" "$outS2" # One store holds many projects, and wrapping one must not block the others: # the lock is named for the project key. -repoO="$(sandbox)"; cp shim/run "$repoO/.floppy/run" +repoO="$(sandbox)" cat > "$repoO/.floppy/config" <&1)"; rcO=$? +outO="$(cd "$repoO" && bash "$ROOT/scripts/run" lock acquire "other project" 2>&1)"; rcO=$? assert_rc "store layout: another project in the same store is not blocked" 0 "$rcO" assert_eq "store layout: it takes a lock of its own" "0" \ "$([[ -d "$storeS/.git/wrap-other.lock" ]] && echo 0 || echo 1)" # Releasing from the second worktree releases the one lock they share — the # same-resource claim has to hold in both directions, or "covers" is a story. -outR="$(cd "$wtS" && AI_FLOPPY_HOME="$ROOT" bash .floppy/run lock release 2>&1)" +outR="$(cd "$wtS" && bash "$ROOT/scripts/run" lock release 2>&1)" assert_contains "store layout: the other worktree can release the shared lock" \ "ok lock released" "$outR" assert_eq "store layout: and the lock is gone from the store" "1" \