diff --git a/scripts/backfill-commander-credentials.ts b/scripts/backfill-commander-credentials.ts index c4e6b05d..58a211e8 100644 --- a/scripts/backfill-commander-credentials.ts +++ b/scripts/backfill-commander-credentials.ts @@ -64,7 +64,15 @@ for (const row of rows) { continue; } - const claudeMd = join(dir, 'CLAUDE.md'); + /* + AGENTS.md where it exists, CLAUDE.md where it does not. + + Since 2026-08-27 a commander's brief is written as AGENTS.md with a CLAUDE.md pointing at it, because + opencode discovers only the first name. Commanders created BEFORE that have the content in CLAUDE.md + and no AGENTS.md at all. Appending to the wrong one is the quiet failure this avoids: on a new + commander it would bury a credential section inside a six-line pointer the agent never reads. + */ + const brief = existsSync(join(dir, 'AGENTS.md')) ? join(dir, 'AGENTS.md') : join(dir, 'CLAUDE.md'); /** * Append a section only when it is not already described. @@ -73,10 +81,10 @@ for (const row of rows) { * describe the same thing does not end up with it twice. */ const appendIfMissing = async (marker: string, lines: string[]): Promise => { - const existing = existsSync(claudeMd) ? readFileSync(claudeMd, 'utf8') : ''; + const existing = existsSync(brief) ? readFileSync(brief, 'utf8') : ''; if (existing.includes(marker)) return false; - appendFileSync(claudeMd, `\n${lines.join('\n')}`); - await giveToAccount(row.osUser, claudeMd); + appendFileSync(brief, `\n${lines.join('\n')}`); + await giveToAccount(row.osUser, brief); return true; }; diff --git a/src/servers/agent-brief.test.ts b/src/servers/agent-brief.test.ts new file mode 100644 index 00000000..a9de4c46 --- /dev/null +++ b/src/servers/agent-brief.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test, afterAll } from 'bun:test'; +import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { writeAgentBrief } from './agent-brief'; + +/** + * Both harnesses have to find the brief. + * + * Claude Code discovers `CLAUDE.md`, opencode discovers `AGENTS.md` and nothing else. Every brief the + * platform wrote was `CLAUDE.md`, so under opencode a commander, a bot and a plugin chat were each + * briefed with nothing — silently, which is why it lasted. + */ + +const root = mkdtempSync(join(tmpdir(), 'agent-brief-')); +afterAll(() => rmSync(root, { recursive: true, force: true })); + +const dir = (name: string) => { + const d = join(root, name); + rmSync(d, { recursive: true, force: true }); + Bun.spawnSync(['mkdir', '-p', d]); + return d; +}; + +describe('writeAgentBrief', () => { + test('writes the content as AGENTS.md and a POINTER as CLAUDE.md', async () => { + const d = dir('both'); + await writeAgentBrief(d, '# Brief\n\nthe real instructions\n'); + + expect(readFileSync(join(d, 'AGENTS.md'), 'utf8')).toContain('the real instructions'); + const pointer = readFileSync(join(d, 'CLAUDE.md'), 'utf8'); + expect(pointer).toContain('AGENTS.md'); + // The pointer must NOT be a second copy — two copies of a file rewritten every turn will disagree, + // and the stale one is the one somebody reads. + expect(pointer).not.toContain('the real instructions'); + }); + + test('is idempotent — an unchanged brief writes nothing', async () => { + const d = dir('idempotent'); + expect(await writeAgentBrief(d, 'same\n')).toBe(true); + expect(await writeAgentBrief(d, 'same\n')).toBe(false); + }); + + test('rewrites when the content changes, because that is how a template reaches a bot', async () => { + const d = dir('rewrite'); + await writeAgentBrief(d, 'v1\n'); + expect(await writeAgentBrief(d, 'v2\n')).toBe(true); + expect(readFileSync(join(d, 'AGENTS.md'), 'utf8')).toBe('v2\n'); + }); + + test('onlyIfAbsent never touches a brief the agent has extended', async () => { + // A commander writes its own job into this file over days. Rewriting it because the template + // changed would delete what it agreed with the owner. + const d = dir('extended'); + await writeAgentBrief(d, 'generated\n'); + writeFileSync(join(d, 'AGENTS.md'), 'generated\n\n## JOB\n\nwatch the thing\n'); + + expect(await writeAgentBrief(d, 'a new template\n', { onlyIfAbsent: true })).toBe(false); + expect(readFileSync(join(d, 'AGENTS.md'), 'utf8')).toContain('watch the thing'); + }); + + test('onlyIfAbsent still creates both files the first time', async () => { + const d = dir('first'); + expect(await writeAgentBrief(d, 'generated\n', { onlyIfAbsent: true })).toBe(true); + expect(existsSync(join(d, 'AGENTS.md'))).toBe(true); + expect(existsSync(join(d, 'CLAUDE.md'))).toBe(true); + }); +}); diff --git a/src/servers/agent-brief.ts b/src/servers/agent-brief.ts new file mode 100644 index 00000000..6ca977e3 --- /dev/null +++ b/src/servers/agent-brief.ts @@ -0,0 +1,79 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { writeAtomic } from './write-atomic'; + +/** + * Writing a brief where BOTH harnesses will find it. + * + * ── The bug this exists to remove ── + * + * Claude Code discovers `CLAUDE.md` by walking up from its working directory. opencode discovers + * `AGENTS.md` and nothing else — verified in the binary, whose only target is + * `H.up({ targets: ["AGENTS.md"], start, stop })`. There is no overlap. + * + * Every brief the platform writes was named `CLAUDE.md`, so under opencode a commander got none of its + * standing brief, a bot none of its arming instructions, and a plugin chat none of its plugin's + * guidance. Nothing failed and nothing was logged: the agent simply knew nothing, which is + * indistinguishable from the feature not existing and is why it survived this long. + * + * ── The shape, and why the pointer rather than two copies ── + * + * `AGENTS.md` holds the content; `CLAUDE.md` is six lines pointing at it. That is the same arrangement + * the repository's own docs use, and it is chosen over writing the text twice for one reason: two copies + * of a file that is REWRITTEN ON EVERY TURN will eventually disagree, and the one that is stale is the + * one somebody reads. + * + * A harness that discovers neither name still gets nothing, and that is honest — it is a fact about the + * harness rather than something this can paper over. + */ + +const POINTER = `# CLAUDE.md + +**The instructions live in [\`AGENTS.md\`](AGENTS.md). Read it first — this file only points there.** + +Written by the platform so that agents looking for \`AGENTS.md\` by convention and agents looking for +\`CLAUDE.md\` find the same guidance, without the two drifting apart. +`; + +/** + * Put `content` in `dir` under both names. + * + * Content-diffed, so a directory's mtimes do not churn on every turn — these are rewritten per message + * in the bots' case. Returns false when nothing was written. + * + * Best effort by contract: every caller must still work if this throws. An agent that knows less is + * where all of them were before any of this existed. + */ +export async function writeAgentBrief( + dir: string, + content: string, + /** + * Never overwrite an existing `AGENTS.md`. + * + * For a brief the AGENT extends rather than one the platform owns. A commander's file is generated + * once at creation and then edited by the commander over its whole life — rewriting it because the + * template changed would silently delete the job it had agreed. The platform's own briefs (bots, + * plugin chats) are the opposite: rewritten every turn ON PURPOSE, so a change reaches them. + */ + opts: { onlyIfAbsent?: boolean } = {}, +): Promise { + let wrote = false; + + const agents = join(dir, 'AGENTS.md'); + if (opts.onlyIfAbsent && existsSync(agents)) return false; + if (!existsSync(agents) || (await Bun.file(agents).text()) !== content) { + await writeAtomic(agents, content); + wrote = true; + } + + const claude = join(dir, 'CLAUDE.md'); + if (!existsSync(claude) || (await Bun.file(claude).text()) !== POINTER) { + await writeAtomic(claude, POINTER); + wrote = true; + } + + return wrote; +} + +/** The pointer's text, for a caller that generates its own file listing and has to describe both. */ +export const AGENT_BRIEF_POINTER = POINTER; diff --git a/src/servers/api/bots/brief.ts b/src/servers/api/bots/brief.ts index e5cb7f6b..33faabb1 100644 --- a/src/servers/api/bots/brief.ts +++ b/src/servers/api/bots/brief.ts @@ -1,7 +1,7 @@ import { join } from 'node:path'; import { mkdirSync } from 'node:fs'; import { getBotsDir } from '@@/data-path'; -import { writeAtomic } from '@@/write-atomic'; +import { writeAgentBrief } from '@@/agent-brief'; /** * The instructions every bot runs under. @@ -194,10 +194,8 @@ export async function ensureBotsBrief(email: string): Promise { mkdirSync(dir, { recursive: true }); try { - const target = join(dir, 'CLAUDE.md'); - // Skip the write when nothing changed, so the directory's mtimes do not churn on every message. - const file = Bun.file(target); - if (!(await file.exists()) || (await file.text()) !== BRIEF) await writeAtomic(target, BRIEF); + // Both names — a bot run under opencode was getting none of this. See `writeAgentBrief`. + await writeAgentBrief(dir, BRIEF); } catch (err) { console.error('[bots] could not write the brief:', err instanceof Error ? err.message : err); } diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 9c567cb0..c4e8725a 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -42,6 +42,7 @@ import { claudeLoginState } from '@@/os-user-claude'; import { mkdirSync, existsSync } from 'node:fs'; import { snapshotPlugins } from '@@/plugins/mount'; import { writeAtomic } from '@@/write-atomic'; +import { writeAgentBrief } from '@@/agent-brief'; import { ensureBotsBrief } from '../bots/brief'; import { logger } from './logger'; import { isSuperAdmin } from '@@/super-admin'; @@ -246,14 +247,18 @@ async function installPluginChatBrief(appName: string, dir: string): Promise s.plugin.appName === appName && s.install?.enabled); if (!state) return; - const source = join(state.plugin.dir, 'chat', 'CLAUDE.md'); - if (!existsSync(source)) return; + // `chat/AGENTS.md`, with the old name still accepted: a plugin is its own repository on its own + // release cadence, so the platform cannot rename a file inside one and assume every installed copy + // followed. Dropping the fallback would silently un-brief a plugin that had not been updated — + // silently, because a missing brief is indistinguishable from a plugin that ships none. + const source = ['AGENTS.md', 'CLAUDE.md'] + .map((name) => join(state.plugin.dir, 'chat', name)) + .find((path) => existsSync(path)); + if (!source) return; - const target = join(dir, 'CLAUDE.md'); - const next = await Bun.file(source).text(); - // Skip the write when nothing changed, so a directory's mtimes do not churn on every chat start. - if (existsSync(target) && (await Bun.file(target).text()) === next) return; - await writeAtomic(target, next); + // Both names, so opencode finds it too — see `writeAgentBrief`. Content-diffed inside, so a + // directory's mtimes do not churn on every chat start. + await writeAgentBrief(dir, await Bun.file(source).text()); } catch (err) { console.error(`[chat] could not install ${appName}'s chat brief:`, err instanceof Error ? err.message : err); } diff --git a/src/servers/api/command-hq/directory.ts b/src/servers/api/command-hq/directory.ts index 1045cd17..a6e8b046 100644 --- a/src/servers/api/command-hq/directory.ts +++ b/src/servers/api/command-hq/directory.ts @@ -1,4 +1,5 @@ import { mkdirSync, writeFileSync } from 'node:fs'; +import { writeAgentBrief } from '@@/agent-brief'; import { getUserByEmail } from 'officerdb'; import { lookupOsUser } from '@@/os-user'; import { join } from 'node:path'; @@ -10,10 +11,11 @@ import { writeCommanderCredential } from './credential'; A commander's own directory, created once, up front — `docs/commander-directory.md`. $DATA_PATH//commanders// - CLAUDE.md written here, extended by the commander as the job is agreed + AGENTS.md written here, extended by the commander as the job is agreed + CLAUDE.md a pointer at it, so both harnesses discover the same brief state/ INSTANCE data: what it spawned, its loop's pid. Empty at creation. - Both exist before the first turn spawns, which is the whole point: Claude Code reads a CLAUDE.md from the + Both exist before the first turn spawns, which is the whole point: a coding CLI reads its brief from the working directory at session start, so a commander told about itself here needs no per-turn injection — see the deleted `commander-block.ts` for what this replaced. */ @@ -365,7 +367,8 @@ export function commanderClaudeMd(params: CommanderClaudeMdParams): string { 'something broken.', '', '```', - "CLAUDE.md this file. the platform's. never edited.", + "AGENTS.md this file. the platform's, extended by you as the job is agreed.", + "CLAUDE.md a pointer at AGENTS.md. the platform's. never edited.", 'JOB.md what was agreed: what you watch for and what you do. yours, written once', ' the job is settled and kept true after that.', 'CLASSIFICATION.md the category rules, if it classifies. yours, and expected to change.', @@ -671,8 +674,14 @@ export async function createCommanderDirectory( credentialError = err instanceof Error ? err.message : 'could not mint a credential'; } - writeFileSync( - join(dir, 'CLAUDE.md'), + // AGENTS.md with the brief in it, CLAUDE.md pointing at it — both, because opencode discovers only + // the first and Claude Code only the second, and a commander can run under either. + // + // `onlyIfAbsent`, and that flag is the whole reason this is not the same call the bots make: a + // commander EXTENDS this file over its life. Rewriting it when the template changes would delete the + // job it had agreed with the owner. + await writeAgentBrief( + dir, commanderClaudeMd({ name: params.name, referencePath: params.referencePath, @@ -680,6 +689,7 @@ export async function createCommanderDirectory( spawnedPath: spawnedRecordPath(email, dirName), credentialPath, }), + { onlyIfAbsent: true }, ); // Last, so it covers CLAUDE.md, officer.env and state/ as well as the directory itself.