diff --git a/TODO.md b/TODO.md index 751b3cf0..7a4a93e1 100644 --- a/TODO.md +++ b/TODO.md @@ -304,20 +304,25 @@ Agreed and written down in `plugins/CLAUDE.md`; **nothing is built**. An afforda on the other end offers the agent — declared by the code that implements it, read at runtime. The first two are image rendering (core) and gitea (`git` for git, `tea` for everything else). -To build: +Built 2026-08-19: `servers/affordances/load.ts` reads the tree and compiles one block, and +`claude-manager.ts` appends it to every Claude session via +`systemPrompt: { type: 'preset', preset: 'claude_code', append }`. Read per session, so a plugin +installed an hour ago is live on the next message. + +Still to build: - `capabilities/affordances/` created by the setup script, with `officer/core/` populated from an - officer-capabilities repository. The clone is future work; the directory is not. -- A compiler: parse the tree, take each body under its `name` as a heading, produce one small block. - Per session, not per boot — `capabilities/` is already read fresh each agent session, and a plugin - installed mid-day should be live on the next message. -- Delivery. Claude sessions take `systemPrompt: { type: 'preset', preset: 'claude_code', append }`, - which nothing passes today. **Opencode has its own driver and would need the same**, or the affordance - is a claim that only holds on one harness — which is the drift this exists to remove. A bare `claude` - run in a terminal on the host gets neither unless a file is also written. + officer-capabilities repository. The directory exists on this machine by hand; a fresh install gets + nothing, so the first affordance is currently a local artefact rather than a shipped one. +- **Opencode delivery.** It has its own driver and gets nothing, so image rendering is currently a claim + that holds on one harness — exactly the drift affordances exist to remove. Deliberate: opencode work is + paused pending its v2 migration and this would be thrown away. Revisit when v2 lands. +- A bare `claude` run in a terminal on the host gets nothing either, since it never passes through + `claude-manager`. Would need a generated file, which is the thing `user-instance.ts` refuses to write + for the owner. - Plugin contribution: an `affordances/` directory in the plugin repository, copied to `capabilities/affordances///` on install and removed on uninstall. Same shape as - `assets/` and `publishAssets`. + `assets/` and `publishAssets`. **Nothing does this yet** — gitea will be the first. Open, and deliberately deferred: diff --git a/src/servers/affordances/load.test.ts b/src/servers/affordances/load.test.ts new file mode 100644 index 00000000..751c3636 --- /dev/null +++ b/src/servers/affordances/load.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test, afterAll } from 'bun:test'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { loadAffordances, compileAffordances } from './load'; + +// The failure worth guarding is a file that silently does not reach the agent. An affordance that fails +// to load looks exactly like one that was never written — and the whole point of these is that the agent +// cannot otherwise know what it does not know. + +const root = join(tmpdir(), `aff-test-${process.pid}`); +const write = (rel: string, body: string) => { + const path = join(root, rel); + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, body); +}; + +afterAll(() => rmSync(root, { recursive: true, force: true })); + +describe('loading', () => { + test('reads the nested // tree', () => { + write('officer/core/image.md', '---\nname: Image rendering\nversion: 2\n---\n\nUse a reachable URL.\n'); + write('officer/gitea/gitea.md', '---\nname: Gitea\n---\n\nUse `tea` for pull requests.\n'); + const found = loadAffordances(root); + expect(found.map((a) => a.id).sort()).toEqual(['officer/core/image', 'officer/gitea/gitea']); + }); + + test('version is an integer and defaults to 1 — matching capabilities/agents/', () => { + const byId = Object.fromEntries(loadAffordances(root).map((a) => [a.id, a])); + expect(byId['officer/core/image']!.version).toBe(2); + expect(byId['officer/gitea/gitea']!.version).toBe(1); + }); + + test('a missing directory is the ordinary state of a fresh install, not an error', () => { + expect(loadAffordances(join(root, 'nope'))).toEqual([]); + }); + + test('a broken header keeps the body — the prose matters more than the name', () => { + write('officer/core/broken.md', '---\nname: [unclosed\n---\n\nThis still has to reach the agent.\n'); + const broken = loadAffordances(root).find((a) => a.id === 'officer/core/broken'); + expect(broken?.body).toContain('still has to reach the agent'); + }); + + test('an empty body is dropped rather than compiled as a bare heading', () => { + write('officer/core/empty.md', '---\nname: Nothing\n---\n\n'); + expect(loadAffordances(root).some((a) => a.id === 'officer/core/empty')).toBe(false); + }); +}); + +describe('compiling', () => { + test('nothing to say produces nothing, not an empty header', () => { + expect(compileAffordances([])).toBeNull(); + }); + + test('each body appears under its name', () => { + const block = compileAffordances(loadAffordances(root))!; + expect(block).toContain('## Image rendering'); + expect(block).toContain('Use a reachable URL.'); + expect(block).toContain('## Gitea'); + }); + + test('description is NOT sent — it says when to reach for one, and everything is already loaded', () => { + const block = compileAffordances([ + { id: 'x', name: 'X', description: 'DO-NOT-SEND-THIS', version: 1, body: 'body' }, + ])!; + expect(block).not.toContain('DO-NOT-SEND-THIS'); + }); + + test('the order is stable, so an unchanged tree produces an unchanged prompt', () => { + expect(compileAffordances(loadAffordances(root))).toBe(compileAffordances(loadAffordances(root))); + }); +}); diff --git a/src/servers/affordances/load.ts b/src/servers/affordances/load.ts new file mode 100644 index 00000000..be0db927 --- /dev/null +++ b/src/servers/affordances/load.ts @@ -0,0 +1,112 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { OFFICER_ITEMS_DIR } from '../data-path'; + +// What the platform tells an agent about the surface it is speaking through. +// +// ── The problem ── +// +// The platform hosts the agent and never introduces itself. An agent gets a shell, a filesystem and a +// prompt; everything about the CLIENT on the other end has to be guessed. It writes +// `![](/home/me/chart.png)`, nothing renders, and nothing tells it why — because an `` carries no +// bearer token, and it is the USER'S DEVICE that fetches the URL. +// +// That knowledge used to live as prose in a hand-written CLAUDE.md on one machine. This reads it from +// `capabilities/affordances/` instead, where it is versioned, inspectable, and contributable by a plugin. +// +// See `plugins/CLAUDE.md` for what an affordance is and what belongs in one. +// +// ── Deliberately dumb ── +// +// No filtering. Every affordance is compiled into every conversation, because the axis to filter on is +// not one dimension — for image rendering it is what the CLIENT renders, for gitea what TOOLING exists on +// the machine. Picking the wrong axis now and writing files against it costs more than loading two extra +// paragraphs. `TODO.md` carries the argument. + +const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML; + +export const AFFORDANCES_DIR = join(OFFICER_ITEMS_DIR, 'affordances'); + +export type Affordance = { + /** `//` — provenance, and unique. */ + id: string; + name: string; + description: string | null; + /** An INTEGER, matching `capabilities/agents/`. Not the semver a plugin carries. */ + version: number; + /** What is actually sent. */ + body: string; +}; + +function parse(raw: string): { fm: Record; body: string } { + const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); + if (!match) return { fm: {}, body: raw.trim() }; + try { + return { fm: (YAML.parse(match[1]!) as Record) ?? {}, body: match[2]!.trim() }; + } catch { + // A broken header is not a reason to lose the body. The agent needs the prose far more than the name, + // and a file that vanished over a stray colon would be very hard to notice. + return { fm: {}, body: match[2]!.trim() }; + } +} + +/** + * Every `.md` under `affordances/`, at any depth, sorted so the compiled block is stable. + * + * SYNCHRONOUS on purpose. `createSession` is not async, and making it so to read a few kilobytes would + * ripple through every caller for no benefit — this runs once per session, not per request. + */ +export function loadAffordances(root: string = AFFORDANCES_DIR): Affordance[] { + let entries: string[]; + try { + // `recursive` because the tree is `//.md` and neither level is known here. + entries = (readdirSync(root, { recursive: true }) as string[]).filter((f) => f.endsWith('.md')); + } catch { + // No directory yet is the ordinary state of a fresh install, not an error. + return []; + } + + const found = entries.sort().map((rel): Affordance | null => { + try { + const { fm, body } = parse(readFileSync(join(root, rel), 'utf8')); + if (!body) return null; + const versionRaw = fm.version; + return { + id: rel.replace(/\.md$/, ''), + // Falls back to the path rather than to "Untitled": a heading naming the file beats one that + // says nothing, and it makes a missing `name` visible without failing the load. + name: fm.name == null ? rel.replace(/\.md$/, '') : String(fm.name), + description: fm.description == null ? null : String(fm.description), + version: typeof versionRaw === 'number' ? versionRaw : Number(versionRaw) || 1, + body, + }; + } catch { + return null; + } + }); + + return found.filter((a): a is Affordance => a !== null); +} + +/** + * One block for the system prompt, or null when there is nothing to say. + * + * `description` is NOT included — it answers "when should I reach for this", which only matters when an + * agent chooses what to load. Everything here is already loaded, so it would be paid for and unread. + */ +export function compileAffordances(affordances: Affordance[]): string | null { + if (!affordances.length) return null; + return [ + '# This environment', + '', + 'What the surface you are talking through will and will not do. Not instructions — facts about the', + 'client on the other end, which you cannot discover from the filesystem.', + '', + ...affordances.map((a) => `## ${a.name}\n\n${a.body}`), + ].join('\n'); +} + +/** Read and compile in one step, for callers that only want the string. */ +export function affordanceBlock(root: string = AFFORDANCES_DIR): string | null { + return compileAffordances(loadAffordances(root)); +} diff --git a/src/servers/sidecar/claude/claude-manager.ts b/src/servers/sidecar/claude/claude-manager.ts index 863889f0..a45ba4b3 100644 --- a/src/servers/sidecar/claude/claude-manager.ts +++ b/src/servers/sidecar/claude/claude-manager.ts @@ -4,6 +4,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { query, type Query, type SpawnOptions, type SpawnedProcess } from '@anthropic-ai/claude-agent-sdk'; import type { ChatEvent, PromptImage } from '../../api/chat/types'; +import { affordanceBlock } from '../../affordances/load'; import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol'; import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state'; import { countsAsOutput, diagnoseExit, stderrReason } from './exit-diagnosis'; @@ -520,6 +521,10 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat `as=${params.member?.osUser ?? 'owner'} model=${params.model ?? 'default'}`, ); + // Read here rather than at module load: a plugin installed an hour ago should be live on the next + // message, and this costs a few kilobytes once per session. + const affordances = affordanceBlock(); + const q = query({ prompt: input.gen as AsyncIterable, options: { @@ -593,6 +598,27 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat spawnClaudeCodeProcess: watchChild(spawnClaudeAsOwner, session), }), settingSources: ['user', 'project', 'local'], + /** + * What the platform tells the agent about the surface it is speaking through. + * + * `preset` keeps Claude Code's own system prompt and APPENDS to it — replacing it would throw away + * everything the harness knows about its own tools. + * + * Passed here rather than written to a file because this is the one place every session goes + * through, owner and member alike. A generated CLAUDE.md cannot do that: `user-instance.ts` + * deliberately refuses to write one for the owner, since it would pollute the personal + * `~/.claude/CLAUDE.md` they maintain themselves. + * + * Read per session, not cached: `capabilities/` is already read fresh each agent session, so a + * plugin installed an hour ago is live on the next message without a restart. + * + * OPENCODE DOES NOT GET THIS, and that is a real gap rather than an oversight — an affordance that + * holds on one harness is the drift this exists to remove. Deliberate as of 2026-08-19: opencode + * development is paused pending its v2 migration, so the work would be thrown away. See TODO.md. + */ + ...(affordances + ? { systemPrompt: { type: 'preset' as const, preset: 'claude_code' as const, append: affordances } } + : {}), env: cleanEnv as Record, stderr: (d: string) => { if (!d.trim()) return;