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
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { loadSkillsAcross } from './skills/load';
import { loadAffordancesAcross } from './affordances/load';
import { loadPluginEntries } from './notifications/catalogue';
/**
* A plugin's capabilities are READ where they live rather than copied into `capabilities/`
* (docs/plugin-capabilities.md). These pin the three properties that were not obvious.
*
* `capabilityRoots` itself is not tested here — it reads the real database and the real plugin
* directory, so a test of it would assert whatever this machine happens to have installed. What is worth
* pinning is what the loaders do with a list once they have one, and that is pure.
*/
const seed = (files: Record<string, string>): string => {
const dir = mkdtempSync(join(tmpdir(), 'zz-caps-'));
for (const [rel, body] of Object.entries(files)) {
const abs = join(dir, rel);
mkdirSync(join(abs, '..'), { recursive: true });
writeFileSync(abs, body);
}
return dir;
};
const skill = (name: string, desc: string) => `---\nname: ${name}\ndescription: ${desc}\n---\n\nbody\n`;
describe('skills merge across sources', () => {
test('a plugin directory contributes its skills', () => {
const owner = seed({ 'mine/SKILL.md': skill('Mine', 'the owner wrote this') });
const plugin = seed({ 'theirs/SKILL.md': skill('Theirs', 'a plugin shipped this') });
try {
const ids = loadSkillsAcross([owner, plugin]).map((s) => s.id).sort();
expect(ids).toEqual(['mine', 'theirs']);
} finally {
rmSync(owner, { recursive: true, force: true });
rmSync(plugin, { recursive: true, force: true });
}
});
// Precedence, and the reason for it: a plugin must not be able to take over the meaning of a name its
// owner is already using, just by picking the same directory name.
test("the owner's own wins a name collision", () => {
const owner = seed({ 'deploy/SKILL.md': skill('Deploy', 'the owner version') });
const plugin = seed({ 'deploy/SKILL.md': skill('Deploy', 'the plugin version') });
try {
const found = loadSkillsAcross([owner, plugin]);
expect(found).toHaveLength(1);
expect(found[0]!.description).toBe('the owner version');
} finally {
rmSync(owner, { recursive: true, force: true });
rmSync(plugin, { recursive: true, force: true });
}
});
// Which is what makes disable mean disable: the caller simply stops passing that root.
test('dropping a source drops its skills', () => {
const owner = seed({ 'mine/SKILL.md': skill('Mine', 'kept') });
const plugin = seed({ 'theirs/SKILL.md': skill('Theirs', 'gone when disabled') });
try {
expect(loadSkillsAcross([owner, plugin])).toHaveLength(2);
expect(loadSkillsAcross([owner])).toHaveLength(1);
} finally {
rmSync(owner, { recursive: true, force: true });
rmSync(plugin, { recursive: true, force: true });
}
});
test('a source that does not exist is skipped, not fatal', () => {
const owner = seed({ 'mine/SKILL.md': skill('Mine', 'kept') });
try {
expect(loadSkillsAcross([owner, join(tmpdir(), 'zz-caps-nope')])).toHaveLength(1);
} finally {
rmSync(owner, { recursive: true, force: true });
}
});
});
describe('affordances are not de-duplicated', () => {
// Unlike skills: an affordance is guidance, several can be true at once, and two plugins describing the
// same surface differently is information rather than a collision.
test('both sources contribute even with the same file name', () => {
const a = seed({ 'x/one.md': '---\nname: A\ndescription: from a\n---\nbody a\n' });
const b = seed({ 'y/one.md': '---\nname: B\ndescription: from b\n---\nbody b\n' });
try {
expect(loadAffordancesAcross([a, b])).toHaveLength(2);
} finally {
rmSync(a, { recursive: true, force: true });
rmSync(b, { recursive: true, force: true });
}
});
});
describe('notification codes come from the manifest, not the path', () => {
// The whole reason loadPluginEntries exists. Copied to capabilities/ the path carried
// <publisher>/<appName>; read in place it carries nothing, so the identity has to travel separately.
test('a plugin in its own directory still gets a namespaced code', () => {
const dir = seed({ 'notifications/catalogue.yaml': "nowPlaying:\n title: 'Now playing'\n" });
try {
const entries = loadPluginEntries({ appName: 'music', dir, manifest: { publisher: 'officerdev' } });
expect(Object.keys(entries)).toEqual(['officerdev/music:nowPlaying']);
expect(entries['officerdev/music:nowPlaying']!.thread).toBe('officerdev/music');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// The property that must survive the move: a plugin cannot claim the platform's voice by writing a
// core-looking key into its own yaml.
test('a yaml key cannot claim another namespace', () => {
const dir = seed({ 'notifications/c.yaml': "officer/core:test:\n title: 'Nice try'\n" });
try {
const keys = Object.keys(loadPluginEntries({ appName: 'x', dir, manifest: { publisher: 'someone' } }));
expect(keys).toEqual(['someone/x:officer/core:test']);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});