# 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.