From e4f1b75f9bf4c548ce55607d5e8bab81eea99004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 22 Aug 2026 16:48:31 +0000 Subject: [PATCH] a platform skill the agent can open, generated so it cannot rot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh agent answering "what is a commander, how do I start one" has nothing to answer from, and invents. The mechanism to fix that already existed and had one user: skills/load.ts compiles an INDEX into every session — name, when to reach for it, path — and the body costs nothing until the agent opens it. The exact inverse of an affordance, which sends its body always and its description never. So no affordance was added, and that is the correction worth recording: an affordance saying "there is a platform skill" would be a second copy of the index entry, paid for on every turn to say what is already there. Affordances are facts about the CLIENT; this is reference material. The platform skill is GENERATED, because a hand-written one rots and we measured it — Spotlight's settings list drifted in two days, CLAUDE.md claimed 27 permissions against 18 and twenty PM2 entries against thirteen. A wrong skill is worse than a wrong doc: an agent quotes it with confidence and the user cannot tell. Generated: every permission with its kind, label, description, API prefixes, sockets, screens, self-service routes and read-only-writes; and the keyboard shortcuts. Prose, kept beside the generator so the two cannot separate: what the platform is, what each KIND means for a user, and where to look next. It also says what it does NOT cover — installed plugins vary per machine so a generated list would be wrong on every other server, and request shapes are not in the registry — plus a closing instruction not to guess, on the grounds that a question the registry cannot answer is one this file cannot either. docs:check regenerates it into memory and fails if the committed file differs. Verified both directions: appending one line made it fail naming the file, and regenerating made it pass. Co-Authored-By: Claude Opus 5 --- package.json | 3 +- scripts/docs-check.ts | 21 +++++ scripts/gen-platform-skill.ts | 155 ++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 scripts/gen-platform-skill.ts diff --git a/package.json b/package.json index f0f6f8be..63d46271 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "format:check": "prettier --check \"src/**/*.{ts,tsx}\"", "setup": "bash scripts/install.sh", "check:theme": "bun scripts/check-theme-tokens.ts", - "docs:check": "bun run scripts/docs-check.ts" + "docs:check": "bun run scripts/docs-check.ts", + "skills:platform": "bun run scripts/gen-platform-skill.ts" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.41", diff --git a/scripts/docs-check.ts b/scripts/docs-check.ts index 08031127..21bedfdd 100644 --- a/scripts/docs-check.ts +++ b/scripts/docs-check.ts @@ -132,6 +132,27 @@ async function main(): Promise { }); } + // 4. The generated platform skill must match its generator. A stale SKILL.md is worse than a stale doc: + // an agent quotes it to a user with no way to tell it is out of date. + const { renderPlatformSkill, SKILL_PATH } = await import('./gen-platform-skill'); + const expected = await renderPlatformSkill(); + const onDisk = read(SKILL_PATH); + if (onDisk === null) { + all.push({ + file: 'capabilities/skills/platform/SKILL.md', + claim: 'generated platform skill', + documented: 'missing', + actual: 'run `bun run skills:platform`', + }); + } else if (onDisk !== expected) { + all.push({ + file: 'capabilities/skills/platform/SKILL.md', + claim: 'generated platform skill is stale', + documented: `${onDisk.split('\n').length} lines on disk`, + actual: `${expected.split('\n').length} lines from the generator — run \`bun run skills:platform\``, + }); + } + for (const note of skipped) console.log(` skipped: ${note}`); if (all.length === 0) { console.log('docs:check — every checkable claim matches the code'); diff --git a/scripts/gen-platform-skill.ts b/scripts/gen-platform-skill.ts new file mode 100644 index 00000000..895b01ab --- /dev/null +++ b/scripts/gen-platform-skill.ts @@ -0,0 +1,155 @@ +/* + Generates the PLATFORM skill — the reference an agent opens when a user asks what the platform can do. + + ── Why generated ── + + Because a hand-written one rots, and we measured it. Spotlight's settings list was hand-written and drifted + within two days: nine rows against twenty-one sections, and a route that does not exist. `CLAUDE.md` claimed + 27 permission entries against 18, and twenty PM2 processes against thirteen, both for over a week. + + A skill is worse than a doc when it is wrong, because an agent quotes it with confidence and the user has no + way to tell. So the parts a registry can answer are derived here, at generation time, from the registry — + and the prose is limited to what no registry can express. + + ── The split, and where the line is ── + + GENERATED permissions: key, label, kind, api prefixes, sockets, frontend routes + keyboard shortcuts + PROSE what the platform IS, what a permission KIND means for a user, where to look next + + Run `bun run skills:platform`. `bun run docs:check` regenerates into memory and fails if the committed file + differs, so a stale skill is a failed check rather than a confident wrong answer. +*/ + +import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const ROOT = resolve(import.meta.dir, '..'); +export const SKILL_PATH = join(ROOT, '..', 'capabilities', 'skills', 'platform', 'SKILL.md'); + +const KIND_MEANING: Record = { + core: 'every account has it and it cannot be denied — the caller acting on themselves', + app: 'grantable per role, at read or write', + confined: 'grantable, but only to an account that has its own Linux user; without one the grant resolves to nothing', + execution: 'runs as the server owner in the owner’s home. NEVER grantable, at any level, structurally', + admin: 'the server owner only', +}; + +export async function renderPlatformSkill(): Promise { + const { PERMISSIONS } = await import('../src/servers/permissions/registry'); + const { SHORTCUT_SECTIONS } = await import('../src/apps/officer-web/state/shortcuts'); + + const byKind = new Map(); + for (const p of PERMISSIONS) { + const list = byKind.get(p.kind) ?? []; + list.push(p); + byKind.set(p.kind, list as typeof PERMISSIONS); + } + + const lines: string[] = []; + const out = (s = '') => lines.push(s); + + out('---'); + out('name: Officer platform'); + // The description is the ENTIRE interface: the agent sees only this before deciding to open the body. + // Vague makes a skill useless rather than merely verbose — see skills/load.ts. + out('description: >-'); + out(' Use when the user asks what Officer can do, how to use a feature, what an endpoint is, why something is'); + out(' refused, or how permissions and accounts work — "how do I start a commander", "can a member see my files",'); + out(' "what is the API for tasks". Carries the permission registry, every API prefix and socket, the frontend'); + out(' routes and the keyboard shortcuts, derived from the code rather than written by hand.'); + out('version: 1'); + out('---'); + out(); + out(''); + out(); + out('# What Officer is'); + out(); + out('One self-hosted app for one person — the **owner** — that also admits additional accounts holding a'); + out('strict subset of it. The owner is user id 1, role `Super Admin`, and bypasses every permission check.'); + out('Everyone else gets what their ROLE is granted, and nothing else: grants are keyed on role, never on a'); + out('user, and **absence denies** — there is no row meaning "no".'); + out(); + out('So "can this account do X" has three possible answers, and the third is the one people miss: yes,'); + out('no, and *not shareable at all*.'); + out(); + out('# Permissions'); + out(); + out('The authority for this is `src/servers/permissions/registry.ts`. Mounting an API router without an'); + out('entry there makes the server **refuse to boot** — so this list is complete by construction, not by'); + out('diligence.'); + out(); + for (const [kind, meaning] of Object.entries(KIND_MEANING)) { + const entries = byKind.get(kind) ?? []; + if (!entries.length) continue; + out(`## \`${kind}\` — ${meaning}`); + out(); + for (const p of entries) { + const bits: string[] = []; + if (p.api?.length) bits.push(`API \`${p.api.join('`, `')}\``); + if (p.ws?.length) bits.push(`socket \`${p.ws.join('`, `')}\``); + if (p.routes?.length) bits.push(`screen \`${p.routes.join('`, `')}\``); + out(`- **${p.label}** (\`${p.key}\`) — ${p.description}`); + if (bits.length) out(` - ${bits.join(' · ')}`); + if (p.selfService?.length) { + out(` - acts on the caller themselves, allowed with no grant: \`${p.selfService.join('`, `')}\``); + } + if (p.ownerOnly?.length) out(` - owner only, even when granted: \`${p.ownerOnly.join('`, `')}\``); + if (p.readOnlyWrites?.length) { + out(` - a POST that is really a read, allowed at read level: \`${p.readOnlyWrites.join('`, `')}\``); + } + } + out(); + } + + out('# Keyboard shortcuts'); + out(); + out('Derived from `src/apps/officer-web/state/shortcuts.ts`. These have moved several times; quote this'); + out('list rather than remembering one.'); + out(); + for (const section of SHORTCUT_SECTIONS) { + out(`## ${section.title}`); + if (section.note) { + out(); + out(section.note); + } + out(); + for (const s of section.shortcuts) out(`- \`${s.keys}\` — ${s.description}`); + out(); + } + + out('# What is NOT in here'); + out(); + out('- **Installed plugins.** They vary per machine and per install, so a generated list would be wrong on'); + out(' every other server. Ask the platform: `GET /api/plugins` for what is installed here.'); + out('- **Request and response shapes.** This says which prefix a permission covers, not what a route'); + out(' accepts. Read the router under `src/servers/api//` when the shape matters.'); + out('- **Anything about a commander beyond its permission.** That has its own skill — open it for what a'); + out(' commander is, how one is started, and how to help somebody shape one.'); + out(); + out('# When you are about to guess'); + out(); + out('Do not. This file is generated from the registry, so a question it does not answer is a question the'); + out('registry does not answer either — say so, and read the code. Every documented count in this repository'); + out('that was maintained by hand has been wrong, and an agent quoting a stale number sounds exactly as'); + out('certain as one quoting a true one.'); + + return `${lines.join('\n')}\n`; +} + +if (import.meta.main) { + const body = await renderPlatformSkill(); + mkdirSync(join(SKILL_PATH, '..'), { recursive: true }); + const existing = existsSync(SKILL_PATH) ? readFileSync(SKILL_PATH, 'utf-8') : null; + if (existing === body) { + console.log('skills:platform — already current'); + } else { + writeFileSync(SKILL_PATH, body); + console.log(`skills:platform — wrote ${SKILL_PATH} (${body.split('\n').length} lines)`); + } +}