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
+59 -2
View File
@@ -44,6 +44,8 @@ export type NotificationEntry = {
thread?: string;
};
export type PluginCatalogueSource = { appName: string; dir: string; manifest: { publisher: string } };
export type Catalogue = {
/**
* Changes whenever the aggregate does. A CONTENT HASH, not a counter.
@@ -195,6 +197,58 @@ export function loadPublishedEntries(root: string = NOTIFICATIONS_DIR): Record<s
return entries;
}
/**
* A single plugin's catalogue, read from its OWN directory.
*
* Separate from `loadPublishedEntries` because the namespace has to come from somewhere else. That
* function derives `<publisher>/<appName>` from the path, which worked while installing COPIED a
* plugin's yaml to `capabilities/notifications/<publisher>/<appName>/`. Read in place the path is just
* `notifications/catalogue.yaml` and carries no identity at all — so the codes would silently vanish,
* which is the failure this exists to prevent.
*
* The manifest supplies it instead, and that is strictly better provenance: `publisher` and `appName` are
* validated at discovery, where the path was only ever a directory somebody chose. The property that
* matters is unchanged — the platform imposes the namespace and the yaml cannot claim one. Writing
* `officer/core:test` as a key still produces `<you>/<yourplugin>:officer/core:test`.
*/
export function loadPluginEntries(plugin: {
appName: string;
dir: string;
manifest: { publisher: string };
}): Record<string, NotificationEntry> {
const root = join(plugin.dir, 'notifications');
let files: string[];
try {
files = (readdirSync(root, { recursive: true }) as string[]).filter((f) => /\.ya?ml$/.test(f));
} catch {
return {}; // shipping none is the ordinary case
}
const { publisher } = plugin.manifest;
const entries: Record<string, NotificationEntry> = {};
for (const rel of files.sort()) {
let doc: unknown;
try {
doc = YAML.parse(readFileSync(join(root, rel), 'utf8'));
} catch {
continue;
}
if (doc === null || typeof doc !== 'object') continue;
for (const [key, value] of Object.entries(doc as Record<string, unknown>)) {
const entry = coerce(value);
if (!entry) continue;
entries[`${publisher}/${plugin.appName}:${key}`] = {
...entry,
thread: entry.thread ?? `${publisher}/${plugin.appName}`,
};
}
}
return entries;
}
/**
* The whole catalogue: the platform's own plus everything installed plugins publish.
*
@@ -202,8 +256,11 @@ export function loadPublishedEntries(root: string = NOTIFICATIONS_DIR): Record<s
* from its install path — so this only matters if the store is edited by hand, and the platform's own
* notifications are the ones that must keep working when it is.
*/
export function loadCatalogue(root: string = NOTIFICATIONS_DIR): Catalogue {
const entries = { ...loadPublishedEntries(root), ...CORE_ENTRIES };
export function loadCatalogue(root: string = NOTIFICATIONS_DIR, plugins: PluginCatalogueSource[] = []): Catalogue {
// Plugin entries first so the owner's own store, then core, still win a collision — the same precedence
// as before, with plugins now read from their own directories rather than copies.
const fromPlugins = Object.assign({}, ...plugins.map(loadPluginEntries));
const entries = { ...fromPlugins, ...loadPublishedEntries(root), ...CORE_ENTRIES };
// Sorted so the hash depends on content, not on the order the filesystem happened to return.
const canonical = JSON.stringify(