From 276b12607a24e5d9653806b43e03d4fbdae487cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 27 Aug 2026 07:18:31 +0000 Subject: [PATCH] every brief the platform writes, where both harnesses will find it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opencode discovers AGENTS.md and nothing else — its only target is H.up({targets:["AGENTS.md"]}), verified in the binary. claude code discovers CLAUDE.md. no overlap. every brief the platform wrote was 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 lasted this long. commanders can already run under opencode, so this was live. writeAgentBrief puts the content in AGENTS.md and a POINTER in CLAUDE.md. a pointer rather than two copies because these are rewritten every turn, two copies of that will eventually disagree, and the stale one is the one somebody reads. three writers, and the third needed a flag the others must not have: bots rewritten every turn ON PURPOSE, so a template change reaches them plugin chats same commanders onlyIfAbsent — a commander EXTENDS this file over its life, and rewriting it when the template changed would delete the job it had agreed with the owner two things that would have quietly defeated it. the file listing handed to a commander said "CLAUDE.md — this file", which would have had it editing the pointer. and backfill-commander-credentials appended to CLAUDE.md, which on a new commander means burying a credential section inside six lines nobody reads — it targets AGENTS.md where one exists, CLAUDE.md where it does not, so old commanders still work. plugins ship these too: email and proxy-manager's chat/CLAUDE.md are now chat/AGENTS.md. the platform accepts BOTH source names, because a plugin is its own repository on its own release cadence and dropping the old name would un-brief any installed copy that had not updated — silently, again. tested: both files written, pointer is not a second copy, idempotent, rewrites on change, and onlyIfAbsent leaves an extended brief alone. verified on disk after a restart. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- scripts/backfill-commander-credentials.ts | 16 +++-- src/servers/agent-brief.test.ts | 68 +++++++++++++++++++ src/servers/agent-brief.ts | 79 +++++++++++++++++++++++ src/servers/api/bots/brief.ts | 8 +-- src/servers/api/chat/websocket.ts | 19 ++++-- src/servers/api/command-hq/directory.ts | 20 ++++-- 6 files changed, 189 insertions(+), 21 deletions(-) create mode 100644 src/servers/agent-brief.test.ts create mode 100644 src/servers/agent-brief.ts 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.