diff --git a/CLAUDE.md b/CLAUDE.md index a2da7d4b..3b56e1e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -537,6 +537,10 @@ explaining why it was safe. - `docs/editor-buffer-corruption.md` — an open bug with a trap set for it: background-job output appearing inside a file open in the editor. Read it BEFORE investigating a report of that, and before re-testing anything it already rules out +- `docs/plugin-capabilities.md` — **designed, not built**: a plugin's tasks, skills and affordances are + READ where they live, never copied into `capabilities/`. Why copying breaks a forkable repository, why it + goes stale on update and survives a disable, and why separate directories beat namespaced paths — + a namespace makes provenance readable, separate directories make it structural - `docs/filesystem-quotas.md` — **planned, not done**: the host-side half of per-user and per-team storage limits. Why the kernel has to enforce it rather than the platform (members have `terminal` and can write past anything we count), why `usrquota`+`grpquota` and not project quotas, and the one thing easiest to diff --git a/docs/plugin-capabilities.md b/docs/plugin-capabilities.md new file mode 100644 index 00000000..fe28945c --- /dev/null +++ b/docs/plugin-capabilities.md @@ -0,0 +1,92 @@ +# A plugin's capabilities are LOADED, never copied + +**Status: designed, not built.** What exists today is the copying this replaces. + +## The problem, as it is on disk right now + +`capabilities/` is a git repository. The intent is that anyone can fork it, keep their own tasks, skills +and affordances in it, and pull core's on top. That only works if the directory contains exactly two +things: what core ships, and what the owner wrote. + +It does not. Four publishers copy a plugin's files into it at install time — assets, affordances, +notification catalogues and skills — so a fork accumulates files it did not author. On this machine today +`skills/email-db/` and `extensions/` sit untracked in that repository, published there by plugins. Commit +them and the fork carries another plugin's work; leave them and `git status` is permanently noisy. There +is no third answer, because nothing on disk says which files came from where. + +Copying is also wrong in a second way that has nothing to do with git. **A copy goes stale.** +`plugins:update` pulls new plugin code and the copied capabilities keep describing the old version until +something republishes them. And a copy survives `plugins:disable`, so a disabled plugin's skill stays in +the agent's index — the plugin is off and the agent still believes it is there. + +## The design + +**A plugin keeps its capabilities in its own directory, and the platform reads them where they are.** + +``` +capabilities/ the owner's fork: core's, plus their own. Nothing else, ever. + tasks/ skills/ affordances/ notifications/ + +plugins// the plugin's own repository, cloned on install + tasks/ skills/ affordances/ notifications/ +``` + +Nothing is copied. Nothing needs a namespace to keep the two apart, because they are already apart — +which is the whole reason this beats scoping the paths. A namespace makes provenance *readable*; separate +directories make it *structural*. + +Four consequences, and each of them is a bug that stops existing: + +- **A fork stays clean.** `capabilities/` contains only what its owner is responsible for, so pulling from + upstream merges without touching anything a plugin put there. +- **Capabilities update with the plugin.** `plugins:update` is a `git pull` in the plugin's directory, and + the capabilities are in that directory. There is nothing left to go stale. +- **Disable actually disables.** The load list is built from plugins that are installed AND enabled, so a + disabled plugin contributes nothing. Today its copies remain. +- **Uninstall needs no cleanup step.** The directory goes and its capabilities go with it. No list of + filenames to be careful with, and no way to delete the owner's own work by getting that list wrong. + +## What has to change + +**The loaders take a list of roots.** `loadSkills(root)` and `loadAffordances(root)` already take one with +a default, so this is a small change rather than a rewrite. Notifications the same. + +**The list is resolved per session, not at module load.** `affordanceBlock()` and `skillsBlock()` are +called once per session in `sidecar/claude/claude-manager.ts` — deliberately, so a capability written an +hour ago is offered on the next session. That is also why they can afford to ask the database which +plugins are installed: once per session, not once per turn. + +**Tasks are the awkward one, because that store is read-write.** `api/tasks/task-files.ts` creates, edits +and deletes as well as reads. Reading must span every source; writing must only ever touch the owner's +directory. A plugin's task is read-only by nature and the UI has to know, or it will offer an Edit that +silently writes into a directory the next `plugins:update` overwrites. + +A task's directory name is also stored data — `taskDirName` in `pipeline_jobs` — so lookup by name has to +search every source rather than assuming one root. + +**The four publishers are deleted**, along with their install and uninstall wiring: +`affordances/publish.ts`, `notifications/publish.ts`, `skills/publish.ts`, `api/tasks/publish.ts`. The +last was written on 2026-08-23 and is superseded before it ever shipped. + +`app-store/assets` is a different case and stays. Those are images served over HTTP from a +platform-managed path, not capabilities read from disk, and the copy is what makes them reachable. + +## The migration + +One-off, and it is deletion rather than movement. The copies already in `capabilities/` have nothing to +remove them once the publishers are gone — `skills/email-db/` and `extensions/` on this machine, and +whatever the equivalent is per host. They are copies of files that still exist in each plugin's own +directory, so deleting them loses nothing. + +Check each host rather than assuming this one is representative. + +## What this does not decide + +**Where the owner's own capabilities live inside their fork.** Flat at the top of each type directory is +what happens today and needs no change. Scoping them is a separate question and this design does not +force it either way. + +**Whether a plugin's capabilities should be namespaced among themselves.** Two plugins can still ship a +task with the same directory name, and the loader has to break that tie — first-come, as the items store +has always done, or by qualifying with the plugin name at read time. Worth settling when a second plugin +ships a task; not before.