diff --git a/CLAUDE.md b/CLAUDE.md index 3661fac8..60d52916 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -541,6 +541,11 @@ explaining why it was safe. its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem, a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its conversation. The CLAUDE.md in its own working directory is the living document, for free +- `docs/claude-md-touchpoints.md` — **audit**: every place the platform writes, appends to or reads a + `CLAUDE.md` — three writers total (commander creation, the plugin chat-brief copier, and a one-off + credential-backfill migration) plus one read-only doc-drift check, and three places that deliberately + do NOT write one. Read before changing any part of the mechanism; includes a per-plugin table (only + `proxy-manager` ships a chat brief today) - `docs/plugin-capabilities.md` — **built**: a plugin's tasks, skills and affordances are READ where they live, never copied into `capabilities/`. Why copying breaks a forkable repository, why it goes stale on update and survives a disable, and why separate directories beat namespaced paths — diff --git a/docs/claude-md-touchpoints.md b/docs/claude-md-touchpoints.md new file mode 100644 index 00000000..b307b1fb --- /dev/null +++ b/docs/claude-md-touchpoints.md @@ -0,0 +1,247 @@ +# Every place the platform writes, appends to, or reads `CLAUDE.md` — an audit + +Written 2026-08-25, after a request to inventory the whole mechanism before changing any part of it. +The scope was `platform/src`, `platform/scripts`, and every plugin currently checked out under +`platform/plugins/`. Related design docs: [`commander-directory.md`](./commander-directory.md) (why a +commander's `CLAUDE.md` exists at all), [`plugin-capabilities.md`](./plugin-capabilities.md) (the +read-in-place rule this mechanism is the one deliberate exception to). + +## The shape of it + +Three writers, one read-only checker. That's the whole mechanism — smaller than the number of files +that merely *mention* `CLAUDE.md` in a comment suggests. + +| # | Site | Verb | Cardinality | Trigger | +|---|------|------|-------------|---------| +| 1 | `command-hq/directory.ts` → `createCommanderDirectory` | generate (write once) | one file per commander | commander creation | +| 2 | `api/chat/websocket.ts` → `installPluginChatBrief` | copy (overwrite if changed) | one file per plugin-chat directory | every plugin-panel chat start | +| 3 | `scripts/backfill-commander-credentials.ts` | append (idempotent) | existing commander files only | manual, one-off per deploy | +| 4 | `scripts/docs-check.ts` | read only | — | `bun docs:check` | + +Two things fell out of tracing this that are worth stating up front, because they explain why the +mechanism looks the way it does rather than something simpler: + +- **Nothing here is a per-turn cost.** All three writers touch disk once (creation, chat-start, + migration) rather than on every message. The alternative — a `promptPrefix` riding every turn — is + exactly what this replaced, twice (once for commanders, once for plugin chats), and the reason given + both times is the same: paid forever, invisible as a design artefact, and visible in the worst place + (a three-word message with a paragraph of preamble silently attached). +- **Two mechanisms that look similar are deliberately NOT this.** The owner's affordances/skills + preamble and a commander's old per-turn block go through the Claude Code SDK's + `systemPrompt: { preset, append }` option — in memory, per session, never touching disk. See + "Explicitly not written" below for why that line was drawn where it was. + +## The three writers + +### 1. Commander creation — the generator + +`src/servers/api/command-hq/directory.ts:653`, `createCommanderDirectory()`. + +Called once, from `command-hq/router.ts`, when `POST /command-hq/commanders` creates a commander — the +row, the directory, and this file, in that order, all before any turn is allowed to spawn. The ordering +is load-bearing: a turn that spawned before the directory existed used to recreate an *empty* one on a +race with deletion (`websocket.ts:300-308` describes the incident), so the row now has to exist first +and the directory has to exist before a turn can reach it. + +```ts +writeFileSync( + join(dir, 'CLAUDE.md'), + commanderClaudeMd({ + name: params.name, + referencePath: params.referencePath, + loopPidPath: loopPidPath(email, dirName), + spawnedPath: spawnedRecordPath(email, dirName), + credentialPath, + }), +); +``` + +`commanderClaudeMd()` (same file, line 173) is the template — the commander's name, the reference path +it was pointed at, where its loop pid lives, where its spawned-process record lives, and where its +platform credential is (or an explicit statement that minting one failed, so the commander is told +rather than left to discover the gap itself — the comment at line 662 is explicit that this ordering, +credential before `CLAUDE.md`, exists so the file can describe the credential truthfully). + +Plain `writeFileSync`, not atomic — defensible because it runs once, at creation, with nothing else able +to be reading the file yet. + +**What happens after creation is the interesting part: nothing does, from the platform's side.** The +file is described everywhere as "a living document its commander has been extending" — the commander +itself edits it during its own life, the same file a human would open in the file browser. The platform +never rewrites it. That is *why* item 3 below has to append rather than regenerate: a commander with +months of self-written notes in its `CLAUDE.md` cannot be treated as regeneratable state. + +### 2. Plugin chat briefs — the copier + +`src/servers/api/chat/websocket.ts:237`, `installPluginChatBrief()`. + +```ts +async function installPluginChatBrief(appName: string, dir: string): Promise { + try { + const { states } = await snapshotPlugins(); + const state = states.find((s) => s.plugin.appName === appName && s.install?.enabled); + if (!state) return; + + const source = join(state.plugin.dir, 'chat', 'CLAUDE.md'); + if (!existsSync(source)) return; + + const target = join(dir, 'CLAUDE.md'); + const next = await Bun.file(source).text(); + if (existsSync(target) && (await Bun.file(target).text()) === next) return; + await writeAtomic(target, next); + } catch (err) { + console.error(`[chat] could not install ${appName}'s chat brief:`, err instanceof Error ? err.message : err); + } +} +``` + +Called from `resolveChatCwd()` (line 332-336) exactly when `msg.context === 'plugin'` — never for a +commander, never for an ordinary `/chat` session, never for email. That branch also does the `mkdirSync` +for the chat directory itself, so the two writers (this one and the commander generator) can never +target the same directory: a commander's cwd is `$DATA_PATH//commanders//`, a plugin +chat's is `$DATA_PATH//plugins//chats/`. + +The source is `/chat/CLAUDE.md` — a file the plugin author ships in their own repository, +read fresh from `snapshotPlugins()` on every chat start rather than cached, so a `plugins:update` needs +no republish step for the brief to change. Content-compared before writing (`writeAtomic` only fires on +an actual diff) specifically so a chat directory's mtimes don't churn on every message — this is called +once per chat *start*, but a resumed conversation calls `resolveChatCwd` again each time it reconnects. + +Best-effort throughout, on purpose: a plugin with no `chat/CLAUDE.md` (the ordinary case — see the +per-plugin table below) is not an error, it is a chat that knows a bit less than one with a brief. A +write failure logs and continues rather than blocking the chat from starting. + +**Of the 19 plugins checked out on this machine today, exactly one ships a brief: `proxy-manager`.** +See "Plugins, one by one" below. + +### 3. The credential backfill — the appender + +`scripts/backfill-commander-credentials.ts`. + +Not part of the runtime — a manual migration, re-run by hand after a deploy that changes what a +commander's `CLAUDE.md` should say about itself (`bun run --env-file=.env scripts/backfill-commander-credentials.ts` +on each host). Exists because a commander predating some capability (first: platform credentials; later: +the notify route) has a `CLAUDE.md` that never mentions it, and deleting-and-recreating the directory to +fix that would destroy the one thing a commander actually is — a long-running, self-extended +conversation about a job. + +```ts +const appendIfMissing = async (marker: string, lines: string[]): Promise => { + const existing = existsSync(claudeMd) ? readFileSync(claudeMd, 'utf8') : ''; + if (existing.includes(marker)) return false; + appendFileSync(claudeMd, `\n${lines.join('\n')}`); + await giveToAccount(row.osUser, claudeMd); + return true; +}; +``` + +Idempotent by construction: each section carries a marker string (`officer.env` for the credential +section, `/api/notify/_officer/notify` for the notify section) and is only appended when that marker is +absent — so re-running the script, or running it against a `CLAUDE.md` a human hand-edited to already +describe the same thing, does not duplicate anything. Two sections are currently backfilled this way: +the credential path (added when `credential.ts` first shipped) and the notify route (added later, +separately, because "arrived later" — commanders that most need a credential already have one by the +time this runs). + +This is the only writer of the three that **mutates an existing file** rather than producing a +first-time one. Everything else in the mechanism either generates once (item 1) or overwrites wholesale +on a genuine content diff (item 2) — appending-if-missing is specific to this being a repair pass over +files a human may have already been editing by hand. + +## The one reader + +`scripts/docs-check.ts`, lines 92 and 108. Reads `platform/CLAUDE.md` and `officer-dev/CLAUDE.md` — not +a plugin's, not a commander's — to check a documented numeric claim (permission-registry entry count, +PM2 process count) against a value derived at run time, and fails the check on drift. This is `bun +docs:check`, required before every release. It never writes. + +Included here for completeness since the request was "writing OR touching" — this is the only touch +that is not a write. + +## Explicitly not written — and why, so the mechanism isn't mistaken for having a gap + +Three places came up in the trace that look like they should write a `CLAUDE.md` and deliberately don't. +Worth recording, because each one used to work differently and the comments at each site exist +specifically to stop a future change from "fixing" this back in. + +- **The owner's and every session's affordances/skills preamble** — + `src/servers/sidecar/claude/claude-manager.ts:779-798`. Passed as + `systemPrompt: { type: 'preset', preset: 'claude_code', append: preamble }` to the Claude Code SDK's + `query()` call, in memory, every session, never to a file. The comment there gives the reason this + differs from the commander/plugin mechanism above: this is "the one place every session goes through, + owner and member alike" — a file can't cover that, because `user-instance.ts` (next point) refuses to + write one for the owner at all, and a member's isolated home is a narrower audience than "every + session." This is also where a commander's own preamble used to live, as a per-turn block + (`commander-block.ts`, deleted 2026-08-24) — it could never resolve on a commander's first turn, since + the block was built once per session and the first turn has no transcript id yet. Moving that + information into a file the directory-creation step writes before any turn spawns (item 1 above) is + what fixed it. +- **The owner's own sessions** — `src/servers/sidecar/claude/user-instance.ts:235-237`. The owner runs + un-isolated, `HOME` pointed at their real home, so a generated container `CLAUDE.md` would land + *inside* `~/.claude/CLAUDE.md` — the file the owner maintains by hand and the terminal `claude` also + reads. Writing one there would silently mix generated content into a document that is supposed to be + entirely the owner's. +- **Per-user container provisioning** — `scripts/provision-user-dirs.ts:10-13`. The comment records that + the *old* `provision.ts` (deleted) used to seed shell rc files and write a generated `CLAUDE.md` + + `settings.json` describing a user's Docker container, as part of an architecture that no longer exists. + What survives from it is only the directory skeleton — a user gets a root and a home inside it, nothing + generated inside either. + +## Plugins, one by one + +`platform/plugins/CLAUDE.md` states the ground rule: **plugins are not part of this repository.** Every +`plugins//` directory is gitignored here and exists on disk only because `git clone` put it there +when it was installed on this machine — `/plugins/*/` in `.gitignore`. So "plugins, one by one" below +means the 19 currently checked out here, not a catalogue. + +The only convention a plugin can participate in is the one item 2 (above) reads: a `chat/CLAUDE.md` file +at the top of the plugin's own repository, picked up automatically the moment that plugin's chat panel +starts a conversation. There is no manifest field for this and nothing to register — the platform simply +checks whether the file exists at that fixed path within the plugin's directory on every chat start. + +| Plugin | `chat/CLAUDE.md` | Notes | +|---|---|---| +| `proxy-manager` | **yes** | The only one. `ChatPanel.tsx:22` documents the mechanism from the plugin's side — its own comment explains why the brief lives in a file rather than a `promptPrefix` constant. | +| `atelier` | no | | +| `bitcoin-wallet` | no | | +| `browser` | no | | +| `desktop` | no | Its `sidecar/protocol.ts` mentions `CLAUDE.md` once, but only as a comment citing the platform's frozen-lockfile rule — not a touch. | +| `email` | no | | +| `example` | no | | +| `gitea` | no | Ships an *affordance* (a different, unrelated mechanism — see `plugins/CLAUDE.md`'s worked example, which is this plugin's) but no chat brief. | +| `invoices` | no | | +| `iris` | no | | +| `jellyfin` | no | `db/schema.ts` cites `src/databases/CLAUDE.md` for the composite-key rule; not a touch. | +| `kiosk` | no | `db/schema.ts` cites `databases/CLAUDE.md` the same way. | +| `minesweeper` | no | | +| `music` | no | | +| `offscale` | no | | +| `photos` | no | | +| `soulseek` | no | | +| `transcribe` | no | `tasks/transcribe-audio-file/index.ts` cites `platform/CLAUDE.md` for the script-task self-containment rule; not a touch. | +| `transmission` | no | | +| `vault` | no | `db/schema.ts` cites `src/databases/CLAUDE.md`'s singleton rule; not a touch. | + +**No plugin writes, generates, or appends to a `CLAUDE.md` programmatically.** Every plugin-side hit that +mentioned the string was a comment pointing back at a platform doc for a rule the plugin's own code +follows (frozen lockfiles, composite keys, script-task self-containment) — not a filesystem touch. The +one exception, `proxy-manager`, participates in item 2 purely by shipping a static file at a path the +platform already knows to look for; it contains no code of its own that touches `CLAUDE.md`. + +## What this means for a future change + +Anyone changing this mechanism has exactly three call sites to reconcile, plus the two "explicitly not +written" boundaries above that a change should not cross without a deliberate decision to do so: + +- Change what a **new** commander is told → edit `commanderClaudeMd()` in `directory.ts`. Existing + commanders will not retroactively pick up the change; that is what the backfill script pattern (item 3) + is for, and a new field needs its own marker and its own `appendIfMissing` call, following the same + script. +- Change what a **plugin chat** is told → that lives entirely in the plugin's own `chat/CLAUDE.md`; the + platform's role is only the copy-if-changed mechanics in `installPluginChatBrief`, which no plugin + author needs to touch. +- Add the same brief mechanism to a **new surface** (not commander, not plugin chat) → follow the pattern + in `resolveChatCwd()`: a directory the surface owns, `mkdirSync` before the write, and a decision about + whether the content is generate-once (commander) or copy-and-diff (plugin) — the two existing writers + chose differently for reasons specific to who edits the file afterward, and a third surface should make + that choice deliberately rather than default to whichever is closer in the file.