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)`); + } +}