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 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
80 lines
3.3 KiB
TypeScript
80 lines
3.3 KiB
TypeScript
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<boolean> {
|
|
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;
|