capabilities: read a plugin's where they live, stop copying them in

Four publishers copied a plugin's affordances, notifications and skills into
capabilities/ at install. That directory is meant to be forked — core's work
plus the owner's — so a fork accumulated files it did not author with nothing on
disk saying which, and there was no good answer: commit them and carry another
plugin's work, leave them and git status stays dirty.

Copying was also wrong in two ways unrelated to git. A copy goes stale, because
plugins:update pulls the plugin's directory and the copy keeps describing the
old version. And a copy survived plugins:disable, so a disabled plugin's skill
stayed in the agent's index — off, and still being announced.

Now the loaders take a list of roots: the owner's store, plus each installed AND
enabled plugin's own directory. capability-sources.ts resolves that list. No
namespace is needed to keep them apart, because separate directories do it
structurally rather than by convention.

The owner's own win a skill name collision — their root is first and the merge
keeps the first of each id — so a plugin cannot take over a name already in use.
Affordances are deliberately NOT de-duplicated: several can be true at once.

Notifications needed more than a root list, and it would have failed silently.
Their codes were derived from the COPY'S PATH, <publisher>/<appName>/file.yaml.
Read in place the path is just notifications/catalogue.yaml and carries no
identity, so every plugin code would have vanished with nothing raised.
loadPluginEntries takes the identity from the manifest instead, which is better
provenance anyway — publisher and appName are validated at discovery, where a
path was only ever a directory somebody chose. A yaml key still cannot claim
another namespace, and that is pinned by test.

createSession is async now, only because resolving enabled plugins reads the
database. Once per session; its single caller already awaited.

Deletes all four publishers including api/tasks/publish.ts from earlier today,
superseded before it shipped. app-store/assets stays — images served over HTTP,
where the copy is what makes them reachable.

NOT done: tasks. That store is read-write, so reads must span sources while
writes stay in the owner's directory, and a plugin's task has to be read-only in
the UI. Next.

Two things the design predicted as orphans turned out not to be, and both were
checked before deleting rather than after. capabilities/extensions/tool-loader
is written by the platform's own bootstrap every boot, not published by a
plugin. And skills/email-db has no source to fall back on — the email plugin
ships no skills directory, so that file exists in exactly one place and was
hand-written. Neither was touched.

1422 pass, tsgo clean, officer restarts and serves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-23 23:19:11 +00:00
co-authored by Claude Opus 5
parent 6572fa90f0
commit 8770f98dcd
15 changed files with 335 additions and 490 deletions
+97
View File
@@ -0,0 +1,97 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { itemsDir, type ItemType } from './data-path';
import { snapshotPlugins } from './plugins/mount';
/**
* Where capabilities are read from — the owner's own store, plus every installed and enabled plugin's.
*
* ── Why plugins are read in place rather than copied in ──
*
* `capabilities/` is a git repository, and the point of it is that anybody can fork it: core's tasks and
* skills, plus their own on top. That only holds if the directory contains exactly two things, what core
* ships and what its owner wrote. Publishing a plugin's files into it broke that — a fork accumulated
* files it did not author, with nothing on disk saying which, so committing them carried another plugin's
* work and not committing them left `git status` permanently dirty.
*
* Copying was wrong a second way that has nothing to do with git. A copy goes STALE: `plugins:update` is
* a `git pull` in the plugin's directory, and a capability copied out of it keeps describing the previous
* version until something republishes. And a copy survives `plugins:disable`, so a disabled plugin's
* skill stayed in the agent's index — the plugin off, the agent still told it was there.
*
* Reading in place fixes all of that by construction, and needs no namespace to do it. Scoping the paths
* would have made provenance READABLE; separate directories make it STRUCTURAL, which is the stronger
* property and the cheaper one.
*
* See `docs/plugin-capabilities.md`.
*
* ── Enabled, not merely installed ──
*
* A disabled plugin contributes nothing. That is the whole meaning of disable — routes down, sidecar
* stopped — and a capability that outlived it was the one piece that did not comply.
*
* ── Cost ──
*
* This reads the database, so it is async, and it must not be called per turn. Its callers resolve it
* once per session (`skillsBlock`, `affordanceBlock`) or per request on surfaces that are already doing
* database work. A missing directory is skipped rather than erroring: most plugins ship no capabilities
* at all, and that is the ordinary case rather than a fault.
*/
export async function capabilityRoots(type: ItemType | 'affordances' | 'notifications'): Promise<string[]> {
const roots = [itemsDir(type as ItemType)];
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
try {
({ states } = await snapshotPlugins());
} catch {
// The owner's own capabilities still load. A database that cannot be read is a much louder problem
// somewhere else, and losing the agent's whole skill index to it would only obscure that.
return roots;
}
for (const state of states) {
if (!state.install?.enabled) continue;
const dir = join(state.plugin.dir, type);
if (existsSync(dir)) roots.push(dir);
}
return roots;
}
/**
* The same list, for a caller that already holds a snapshot.
*
* Exists so a request doing several of these — the items rescan reads five types — pays for one database
* read rather than five.
*/
export function capabilityRootsFrom(
states: Awaited<ReturnType<typeof snapshotPlugins>>['states'],
type: ItemType | 'affordances' | 'notifications',
): string[] {
const roots = [itemsDir(type as ItemType)];
for (const state of states) {
if (!state.install?.enabled) continue;
const dir = join(state.plugin.dir, type);
if (existsSync(dir)) roots.push(dir);
}
return roots;
}
/**
* Installed and enabled plugins, in the shape the notification catalogue wants.
*
* Notifications are the one capability whose namespace cannot be read off a path once the file stays in
* the plugin's own directory — see `notifications/catalogue.ts` → `loadPluginEntries`. So the source list
* carries the identity with it.
*/
export async function pluginCatalogueSources(): Promise<
{ appName: string; dir: string; manifest: { publisher: string } }[]
> {
try {
const { states } = await snapshotPlugins();
return states
.filter((s) => s.install?.enabled)
.map((s) => ({ appName: s.plugin.appName, dir: s.plugin.dir, manifest: { publisher: s.plugin.manifest.publisher } }));
} catch {
return [];
}
}