248 lines
15 KiB
Markdown
248 lines
15 KiB
Markdown
# 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<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.
|
|
|
|
```ts
|
|
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.
|