skills: publish the index, leave the body on disk

the inverse of an affordance, and deliberately so:

  affordance   body always compiled in, description never sent
  skill        description always compiled in, body read on demand

an affordance is a fact about the surface — short, and every conversation
needs it. a skill is reference material that is dead weight in a conversation
about something else. so what reaches the prompt is the index: name, when to
reach for it, and the path. measured on the first one: 608 characters in the
prompt against 6,070 bytes left on disk.

no protocol. progressive disclosure is normally a harness feature — claude
code discovers .claude/skills, opencode has its own conventions, the next one
will have a third — and pointing at a file needs none of that, because every
harness worth running can already read a file. the cost is that the agent has
to CHOOSE to open it, which is what the description is for and why a vague one
makes a skill useless rather than merely verbose.

the first skill is the email database, moved out of tools/email-db/TOOL.md
where NOTHING has been reading it: the loader parses frontmatter and discards
the body, so that schema and its eight queries have never reached any claude
code session. written for pi, inert since pi left.

it carries two things the schema alone gets wrong, both measured against the
real 19,097-message database rather than copied from the old doc: `read` is
populated on 93 rows, so the documented unread query returns essentially the
whole mailbox; and `labels` is a comma-separated string with real values like
`updates,inbox`, so `labels = 'inbox'` misses most of the inbox.

plugins can ship skills, published and unpublished on install like affordances
and notification catalogues. flat namespace unlike those two — a skill's
directory name is the path an agent reads, and `officerdev/gitea/SKILL.md` in
a prompt is noise. a skill has no authority to usurp, so nothing needs
protecting.

wired to claude code. opencode still gets NOTHING — its runner sends no system
prompt at all, and session create takes only a directory. that needs its own
channel and a guess would be worse than the gap.
This commit is contained in:
2026-08-20 01:09:07 +00:00
parent fe7fe49cee
commit 49f7761f53
6 changed files with 260 additions and 3 deletions
+11
View File
@@ -18,6 +18,7 @@ import { revokeGrantsForPermissions } from 'officerdb';
import { publishAssets, unpublishAssets } from '../app-store/assets';
import { publishAffordances, unpublishAffordances } from '../affordances/publish';
import { publishNotifications, unpublishNotifications } from '../notifications/publish';
import { publishSkills, unpublishSkills } from '../skills/publish';
import { refreshPluginMounts, snapshotPlugins } from './mount';
import { generatePluginSchemas, pushPluginSchema } from './schema';
import { declaredTables, dropTables, existingTables } from './tables';
@@ -373,6 +374,15 @@ export async function installPlugin(
await step(steps, onStep, `notifications: NOT published — ${err instanceof Error ? err.message : err}`);
}
// Same bargain again: a plugin that cannot publish its skills still installs. The agent simply does
// not learn the reference exists, which is the state every plugin was in before this.
try {
const published = await publishSkills(plugin);
if (published) await step(steps, onStep, `skills: ${published} published to capabilities/skills/`);
} catch (err) {
await step(steps, onStep, `skills: NOT published — ${err instanceof Error ? err.message : err}`);
}
// The tile, for the person who just installed it. `plugin.web` because `pluginDockManifests` only
// offers a tile for a plugin that has a frontend — a backend-only plugin has no screen to pin.
if (plugin.web && forUserId !== undefined) {
@@ -465,6 +475,7 @@ export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?:
// And its notification codes, which stop resolving rather than resolving to something stale. A device
// still holding a queued push renders the unknown-code fallback, which is a defined state.
if (plugin) await unpublishNotifications(plugin).catch(() => {});
if (plugin) await unpublishSkills(plugin).catch(() => {});
await step(steps, onStep, 'tables and data: untouched (published assets, affordances and notifications removed)');
return { ok: true, appName, steps };
}