Files
platform/docs/claude-md-touchpoints.md
T
pastilhasandClaude Opus 5 be210c3ed4 a plugin declares its chat directory; email stops being hardcoded here
ChatPanelWrapper had one plugin's screen id compiled into a shared platform
package: `workspace.id === 'email'` → `{ context: 'email' }`. its own comment
said the general fix was a way for a plugin to DECLARE its chat context, and
that a second hardcoded id would be the signal it was needed. that fix exists
(`declared`), the email plugin now passes it, so the branch is gone.

it also could not fire any more, which is the part worth keeping. a plugin's
workspace key is `screens/plugin/<name>`, so email's id became `plugin/email`
the day it was extracted and the test silently stopped matching. a hardcoded id
does not fail loudly when the thing it names moves — it just stops being true,
and nothing said so for eight days.

two things on the backend half:

resolveEmailCwd now installs the email plugin's chat/CLAUDE.md. an email chat
runs in the account's directory, not the plugin's chats directory, so the
`plugin` branch never reaches it — the brief follows the chat rather than the
chat moving to the brief. a second plugin wanting this is the signal that
"which directory does this chat run in" belongs in the manifest.

and it verifies the mailbox exists before joining it. `accountEmail` comes from
the client (the mailbox picker's `?account=`) and this function ends in mkdirSync
— so an unrecognised value did not fail, it CREATED a directory named after
whatever arrived and ran the turn in it. the list's unified view uses the
sentinel `all`, which is the value most likely to show up by accident. the panel
drops it; this is the half that does not depend on the panel remembering to.

docs/claude-md-touchpoints.md updated: the copier now has two callers.

unrelated and NOT from this change: ModelSelector's "an unstarted composer gets
a default" fails on master too — verified by stashing these files and rerunning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 18:43:13 +00:00

20 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, plus one per email account every plugin-panel chat start, and every email 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.

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() from two branches, and the second one is the interesting case:

  • msg.context === 'plugin' — the plugin's own chats directory. The ordinary path.
  • msg.context === 'email' — the account's directory, the one holding emails.db.

Never for a commander and never for an ordinary /chat session.

Email is the exception because an email chat does not run in the plugin's chats directory: it runs beside the database it exists to answer from, which is the entire point of that context. So the brief follows the chat rather than the chat moving to the brief, and plugins/email/chat/CLAUDE.md lands in email_accounts/<address>/. A second plugin wanting this would be the signal that "which directory does this plugin's chat run in" belongs in the manifest rather than in a branch here. 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 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/<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() 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.