15 KiB
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 (why a
commander's CLAUDE.md exists at all), 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
promptPrefixriding 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.
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().
async function installPluginChatBrief(appName: string, dir: string): Promise<void> {
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/<email>/commanders/<dirName>/, a plugin
chat's is $DATA_PATH/<email>/plugins/<appName>/chats/.
The source is <plugin-repo>/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.
const appendIfMissing = async (marker: string, lines: string[]): Promise<boolean> => {
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 assystemPrompt: { type: 'preset', preset: 'claude_code', append: preamble }to the Claude Code SDK'squery()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, becauseuser-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,HOMEpointed at their real home, so a generated containerCLAUDE.mdwould land inside~/.claude/CLAUDE.md— the file the owner maintains by hand and the terminalclaudealso 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 oldprovision.ts(deleted) used to seed shell rc files and write a generatedCLAUDE.md+settings.jsondescribing 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/<name>/ 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()indirectory.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 ownappendIfMissingcall, 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 ininstallPluginChatBrief, 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,mkdirSyncbefore 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.