From d9a3dd68b5b4058307a37e59f43c12f81e24a1b3 Mon Sep 17 00:00:00 2001 From: halaprix <6533433+halaprix@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:54:08 +0200 Subject: [PATCH] feat: scaffold layered agent instruction files from shipped templates Every assistant reads project law from a different filename in the repository root, so the law gets written three times and drifts. Ship one layered set instead: AGENTS.md is canonical and read natively by Codex and opencode, while CLAUDE.md and GEMINI.md import it and carry only assistant-specific mechanics. relay setup writes any of the three that are missing from templates/agent-instructions/ and adds all three to the project's local git exclude state; relay doctor reports which are present. An existing file is never touched - by then it is the project's own law and a template is only a starting point. The test covers both branches plus a rerun, since the project fixture already ships its own copies. The exclude entries and this repository's own .gitignore lines are root-anchored. An unanchored `AGENTS.md` matches at every depth, which would ignore both the shipped templates here and a package-level AGENTS.md an adopting project tracks. The templates carry only what is true for any project: the inherited-BEADS_DIR trap that silently routes bd calls into another project's store, verify-don't-trust delegation, no AI attribution, and Beads as the only durable task system. Project-specific content is left as explicit placeholders. The files stay local and uncommitted: they name per-machine tooling and model choices, and a project's rules are not Agent Relay's to version. Verified: 117 pass / 0 fail, lint and check green. --- .gitignore | 8 ++++ README.md | 14 ++++++ skills/relay/SKILL.md | 7 +++ src/lib/agent-instructions.mjs | 49 +++++++++++++++++++ src/lib/supervisor.mjs | 17 ++++++- templates/agent-instructions/AGENTS.md | 66 ++++++++++++++++++++++++++ templates/agent-instructions/CLAUDE.md | 18 +++++++ templates/agent-instructions/GEMINI.md | 21 ++++++++ test/supervisor.test.mjs | 51 ++++++++++++++++++++ 9 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 src/lib/agent-instructions.mjs create mode 100644 templates/agent-instructions/AGENTS.md create mode 100644 templates/agent-instructions/CLAUDE.md create mode 100644 templates/agent-instructions/GEMINI.md diff --git a/.gitignore b/.gitignore index f29c375..2a60bd8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,14 @@ test-output/ site/.cache/ .resources/ +# Agent instruction files: per-machine project law, scaffolded by `relay setup` +# from templates/agent-instructions/. Never committed - they name local tooling. +# Root-anchored: an unanchored pattern matches at any depth and would also ignore +# the shipped templates under templates/agent-instructions/. +/AGENTS.md +/CLAUDE.md +/GEMINI.md + # Beads / Dolt files (added by bd init) .dolt/ *.db diff --git a/README.md b/README.md index 38dccfc..ca15cd8 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,20 @@ Rules: `relay setup` creates the directory and a `README.md` restating these rules, and `relay doctor` reports `resourcesRoot` plus whether it is actually ignored. +## Agent instruction files (`AGENTS.md`, `CLAUDE.md`, `GEMINI.md`) + +Every assistant reads project law from a file in the repository root, and each one looks for a different name. Agent Relay ships one layered set so the law is written once: + +- `AGENTS.md` is canonical and holds every shared convention. Codex and opencode read it natively. +- `CLAUDE.md` and `GEMINI.md` import it (`@AGENTS.md`) and carry only assistant-specific mechanics. +- Shared conventions are edited in `AGENTS.md` and nowhere else. Duplicating them into the per-assistant files is how they drift. + +`relay setup` writes any of the three that are missing from the templates in `templates/agent-instructions/`, and adds all three to the project's local Git exclude state. An existing file is never touched — by then it is the project's own law, and a template is only a starting point. `relay doctor` reports which are present. + +They stay local and uncommitted on purpose: they name per-machine tooling and model choices, and a project's rules are not Agent Relay's to version. That also means `git clean -xfd` deletes them, so treat the templates as the recovery path. + +Adopting a project means filling in the placeholders: the architecture documents worth reading, the real gate commands, the invariants a newcomer would violate, and who merges. The template ships the parts that are true everywhere — the inherited-`BEADS_DIR` trap, verify-don't-trust delegation, no AI attribution, and Beads as the only durable task system. + ## Beads store The Beads store is project-local. `adapter.beads.requiredDir` defaults to `.beads`, resolved against the project root, which is exactly where `bd init` creates the embedded Dolt database (`.beads/embeddeddolt/`). Nothing depends on a shared or per-user store. diff --git a/skills/relay/SKILL.md b/skills/relay/SKILL.md index 059d872..dc7b4b9 100644 --- a/skills/relay/SKILL.md +++ b/skills/relay/SKILL.md @@ -16,6 +16,13 @@ Use `relay` when the project wants Claude, Codex, and agy to share the same Bead 5. Inspect checkpoints with `relay status [bead-id]`, `relay review `, and `relay gates [gate-name]`. 6. Use `relay cleanup ` only after the human has reviewed the retained worktree state. +## Agent instruction files + +- Project law lives in `AGENTS.md` at the repository root; `CLAUDE.md` and `GEMINI.md` import it and add only assistant-specific mechanics. Codex and opencode read `AGENTS.md` natively. +- `relay setup` scaffolds any that are missing from `templates/agent-instructions/` and keeps all three out of Git. It never overwrites an existing file. +- Edit shared conventions in `AGENTS.md` only. A convention copied into a per-assistant file will drift from the canonical one. +- Fill in the template placeholders before relying on them: architecture documents, gate commands, the invariants specific to the project, and the branch and merge rules. + ## Reference resources - Cache external documentation under the repository-root `.resources/` directory, one topic per subdirectory with a `SOURCE.md` naming the origin URL and fetch date. diff --git a/src/lib/agent-instructions.mjs b/src/lib/agent-instructions.mjs new file mode 100644 index 0000000..e00f4ec --- /dev/null +++ b/src/lib/agent-instructions.mjs @@ -0,0 +1,49 @@ +import path from "node:path"; +import { readFile, writeFile } from "node:fs/promises"; +import { pathExists } from "./fs.mjs"; +import { repoPath } from "./paths.mjs"; + +// The canonical file plus the two assistants that need an importer. Codex and +// opencode read AGENTS.md natively, so they get no file of their own. +export const AGENT_INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md", "GEMINI.md"]; + +function templatePath(fileName) { + return repoPath("templates", "agent-instructions", fileName); +} + +// Instruction files are per-machine project law, not deliverables: they name local +// tooling and may quote paths. Keeping them out of the index is what makes it safe +// to write them without asking. +// +// Root-anchored on purpose. A bare `AGENTS.md` pattern matches at every depth, so it +// would also ignore a package-level AGENTS.md the project does track. +export function agentInstructionExcludeMarkers() { + return AGENT_INSTRUCTION_FILES.map((fileName) => `/${fileName}`); +} + +// Writes any missing instruction file from its shipped template. An existing file +// is never touched - it is the project's own law by then, and a template is only a +// starting point. +export async function scaffoldAgentInstructions(projectRoot) { + const written = []; + const preserved = []; + for (const fileName of AGENT_INSTRUCTION_FILES) { + const destination = path.join(projectRoot, fileName); + if (await pathExists(destination)) { + preserved.push(fileName); + continue; + } + await writeFile(destination, await readFile(templatePath(fileName), "utf8"), "utf8"); + written.push(fileName); + } + return { written, preserved }; +} + +export async function agentInstructionStatus(projectRoot) { + const present = []; + const missing = []; + for (const fileName of AGENT_INSTRUCTION_FILES) { + (await pathExists(path.join(projectRoot, fileName)) ? present : missing).push(fileName); + } + return { present, missing }; +} diff --git a/src/lib/supervisor.mjs b/src/lib/supervisor.mjs index 7554f30..2e9890e 100644 --- a/src/lib/supervisor.mjs +++ b/src/lib/supervisor.mjs @@ -47,6 +47,11 @@ import { prepareIsolatedProviderRun } from "./containment.mjs"; import { classifyProviderFailure, parseWorkerReport, runProviderCommand, providerCommandFromConfig, providerVendor, providerStrength, validateRuntimeProviderConfig } from "./provider.mjs"; import { assertTeamFacingTextClean, sanitizeIssueForPrompt, sanitizePromptText, sanitizeTeamFacingText, slugifyTitle } from "./sanitize.mjs"; import { syncRoleBundles } from "./roles.mjs"; +import { + agentInstructionExcludeMarkers, + agentInstructionStatus, + scaffoldAgentInstructions +} from "./agent-instructions.mjs"; import { PROVIDERS } from "./providers/index.mjs"; import { validateReviewReport, validateWorkerReport } from "./validate.mjs"; @@ -272,7 +277,12 @@ async function ensureProjectState(projectRoot, adapterName, adapter = null) { const config = await readProjectConfig(projectRoot, adapterName); const resourcesRootName = resolveResourcesRootName(adapter); const resourcesRoot = await ensureResourcesRoot(projectRoot, resourcesRootName); - const markers = [".agents/agent-relay/", `${resourcesRootName}/`, beadsExcludeMarker(adapter)].filter(Boolean); + const markers = [ + ".agents/agent-relay/", + `${resourcesRootName}/`, + beadsExcludeMarker(adapter), + ...agentInstructionExcludeMarkers() + ].filter(Boolean); const excludePath = await ensureGitExclude(projectRoot, markers); const beadsDir = adapter ? resolveBeadsDir(adapter, projectRoot) : null; return { config, excludePath, stateRoot, resourcesRoot, resourcesRootName, beadsDir }; @@ -1702,7 +1712,8 @@ export async function doctor({ projectRoot, adapterName = "example-app", env = p beadsTracked: beadsStoreIsTracked(adapter), inheritedBeadsDirIgnored: inheritedBeadsDirIgnored(env, beadsDir), roles: roles.length, - adapters: adapters.length + adapters: adapters.length, + agentInstructions: await agentInstructionStatus(projectRoot) }; return problems.length > 0 ? result("project-misconfigured", "doctor", { problems, ...details }) @@ -1725,6 +1736,7 @@ export async function setup({ projectRoot, adapterName }) { const { adapter } = await loadAdapter(adapterName); const { config, excludePath, resourcesRoot, beadsDir } = await ensureProjectState(projectRoot, adapterName, adapter); const syncedRoles = await syncProjectRoles(projectRoot); + const agentInstructions = await scaffoldAgentInstructions(projectRoot); return ok("setup", { adapter: adapter.name, config, @@ -1735,6 +1747,7 @@ export async function setup({ projectRoot, adapterName }) { beadsTracked: beadsStoreIsTracked(adapter), beadsStorePresent: await pathExists(beadsDir), syncedRoles, + agentInstructions, localStateRoot: projectStateRoot(projectRoot) }); } diff --git a/templates/agent-instructions/AGENTS.md b/templates/agent-instructions/AGENTS.md new file mode 100644 index 0000000..7cab17b --- /dev/null +++ b/templates/agent-instructions/AGENTS.md @@ -0,0 +1,66 @@ +# Agent Instructions (canonical — ALL assistants) + +**This file is the single source of truth for AI-agent instructions in this repository.** `CLAUDE.md` and `GEMINI.md` import it and add only assistant-specific content; Codex and opencode read this file natively. **Edit shared conventions HERE and only here** — never duplicate them into the per-assistant files. + +These files are local: they are listed in `.git/info/exclude`, so they never reach a commit, a diff, or a PR. Write them for the machine you are on, but keep personal paths, emails, and hostnames out of them anyway — a gitignored file is one `git add -f` away from being public. + +## First action in every session + +1. **`bd where`** — confirm it prints this project's `.beads` directory and the expected issue prefix. + - If a `BEADS_DIR` environment variable is exported on this machine, it wins over repository discovery and will silently route every `bd` call into another project's store. Either unset it or pass `BEADS_DIR="$PWD/.beads"` explicitly on every `bd` invocation. + - **Never run `bd init`** when `bd where` looks wrong — you are in the wrong directory or inheriting the wrong environment. Report it; a second store is worse than a failed command. +2. **`bd prime`** — load the persistent memories. +3. **Verify** the output contains a `## Persistent Memories` section. If it says none are stored, fall back to `bd memories --json`; plain `bd memories` prints truncated previews and is not a substitute. + +`bd ready` lists available work, `bd show ` reads one issue, `bd update --claim` claims it, `bd close ` finishes it. + +## Read protocol (before non-trivial work) + +Don't guess architectural patterns; look them up. + +1. **Architecture source of truth**: ``. Treat its rules as physical laws. +2. **Current state**: the live queue is `bd ready --json`; persistent knowledge loads through the first-action sequence above. +3. If a doc says "X never does Y", either follow it or change the doc with new reasoning — never silently violate it. + +## Execution constraints (physical laws of this codebase) + +`` + +- **No ghost code** — verify interfaces and files exist (grep or read) before referencing them. +- **Scope containment** — modify only files related to the current objective; never "clean up" unrelated files. + +## Build and test + +```bash +# +``` + +Run the gates you claim to have run. Whoever orchestrates re-runs them independently, and a claimed-but-unrun gate is treated as a failure. + +## Conventions + +- Use `bd` for ALL task tracking and `bd remember` for persistent knowledge. No markdown TODO lists, no parallel tracking systems. +- Default to no code comments; add one only when the WHY is non-obvious. +- **No magic literals, DRY always.** Inline selectors, addresses, thresholds, or duplicated formulas are defects even when correct. One exported implementation; callers import it. +- No features, refactors, or abstractions beyond what the task requires. +- **No AI attribution anywhere**: no `Co-Authored-By:` trailers naming an assistant, no "Generated with …" footers in PR bodies. Authorship is the human committer. + +### Git, commits, PRs + +- ``. +- Multi-paragraph commit messages via heredoc (`git commit -F -`), without attribution trailers. +- **Beads is local-only — never mention bd issue ids in PR titles, PR bodies, commit messages, or anything else team-facing.** Track the bd↔PR link on the bd issue instead (`bd update --notes "PR #NNN"`). + +## Orchestration and delegation protocol + +Applies to whichever assistant is the main session. + +1. **Orchestrate, don't code.** Route by weight, cheapest first: small exactly-speccable tasks to the cheapest coder tier, medium features to the mid tier, cross-system or design-heavy correctness to the strongest. Each toolchain has its own workers — Claude reads `.claude/agents/`, Codex `.codex/agents/`, agy `.agents/agents/`, opencode `.opencode/agent/`. +2. **Specs embed every decision and every gate.** A dispatched worker should never have to infer a decision you already made, and never runs git or a release step unless told to. +3. **Verify, don't trust.** Independently re-run every gate a worker claims to have passed, and check findings against the code before acting on them. +4. **Worktree discipline**: one isolated worktree per workstream. Shell working directories persist between calls, so `cd` with absolute paths, and after any worker run check `git status` in both the worktree and the main checkout. +5. **A worker that reports a blocked or contradictory spec is doing its job.** Fix the spec rather than pressing the same instruction again. + +## Session completion + +Before ending a session: run the gates, close finished issues with reasons, file new issues for follow-ups (never a TODO in code), and push committed work. Work is not complete until it is pushed. diff --git a/templates/agent-instructions/CLAUDE.md b/templates/agent-instructions/CLAUDE.md new file mode 100644 index 0000000..283ee2e --- /dev/null +++ b/templates/agent-instructions/CLAUDE.md @@ -0,0 +1,18 @@ +# Project Instructions for Claude Code + +**Canonical shared instructions live in `AGENTS.md`** (imported below) — read by ALL assistants. Edit shared conventions (execution constraints, build and test, git and PR rules, the bd workflow, the delegation protocol) ONLY in `AGENTS.md`. This file carries ONLY Claude-specific content. + +@AGENTS.md + +# Claude-specific notes + +- Run the `AGENTS.md` first-action sequence manually unless a `SessionStart` hook does it for you. If you add such a hook, it must not export `BEADS_DIR` — the store is repository-local and self-discovering, and a global export routes every other project on the machine into this one's store. +- Keep persistent knowledge in `bd remember`, not in the harness's own memory files, so every assistant shares it. + +# Orchestration and delegation + +The tool-neutral protocol lives in **`AGENTS.md` §"Orchestration and delegation protocol"**. Claude-specific mechanics only: + +- Dispatch workers with the Agent tool; personas live in `.claude/agents/`. Agent Relay generates `orchestrator`, `coder`, and `reviewer` there — run `relay setup` after changing a role source, never hand-edit the generated bundle. +- Route to an external CLI when it is cheaper or when a second vendor's opinion is the point: `codex` and `agy` both read this repository's instruction files. Each provider manifest declares its own default model per role, so ask for the role and let the manifest pick the model. +- Reviews get opinions from a different vendor than the one that wrote the code; a same-vendor review does not satisfy a two-vendor quorum. diff --git a/templates/agent-instructions/GEMINI.md b/templates/agent-instructions/GEMINI.md new file mode 100644 index 0000000..b375de0 --- /dev/null +++ b/templates/agent-instructions/GEMINI.md @@ -0,0 +1,21 @@ +# Project Instructions for Gemini + +**Canonical shared instructions live in `AGENTS.md`** (imported below) — read by ALL assistants. Edit shared conventions ONLY in `AGENTS.md`; this file carries ONLY Gemini-specific content. + +@./AGENTS.md + +# Gemini-specific notes + +- **Run the `AGENTS.md` first-action sequence as your FIRST action.** Nothing injects it for you. +- **If an orchestrator dispatched you**: your brief embeds every decision and every gate. Follow it exactly, do not run git or any release step, and report gate results honestly — they are independently re-run. Pin ACTUAL observed behaviour over the brief's predictions and flag mismatches loudly. +- If the brief contradicts itself or the code, stop and report instead of guessing. A blocked worker is cheaper than a wrong merge. +- Verify you are writing into the intended worktree rather than the main checkout; use absolute paths. + +# Eager context + +Gemini loads a large context well, so import the documents you would otherwise have to be told to read: + +``` +@./ +@./ +``` diff --git a/test/supervisor.test.mjs b/test/supervisor.test.mjs index b25519d..66083a2 100644 --- a/test/supervisor.test.mjs +++ b/test/supervisor.test.mjs @@ -186,6 +186,53 @@ test("setup writes local state, syncs roles, and marks .agents/agent-relay ignor assert.equal(claudeLaw.trim(), "# Fixture CLAUDE"); }); +test("setup scaffolds missing agent instruction files, keeps existing ones, and excludes all of them", { timeout: 10000 }, async () => { + const projectRoot = await createProjectFixture(); + // The fixture models a project that already has its own law: all three files + // exist, so none may be rewritten. + const existing = await setup({ projectRoot, adapterName: "example-app" }); + assert.equal(existing.ok, true); + assert.deepEqual(existing.agentInstructions.written, []); + assert.deepEqual(existing.agentInstructions.preserved, ["AGENTS.md", "CLAUDE.md", "GEMINI.md"]); + assert.equal( + (await readFile(path.join(projectRoot, "CLAUDE.md"), "utf8")).trim(), + "# Fixture CLAUDE" + ); + + // A project with none of them gets all three from the shipped templates. + for (const fileName of ["AGENTS.md", "CLAUDE.md", "GEMINI.md"]) { + await rm(path.join(projectRoot, fileName)); + } + const result = await setup({ projectRoot, adapterName: "example-app" }); + assert.deepEqual(result.agentInstructions.written, ["AGENTS.md", "CLAUDE.md", "GEMINI.md"]); + assert.deepEqual(result.agentInstructions.preserved, []); + + const agentsLaw = await readFile(path.join(projectRoot, "AGENTS.md"), "utf8"); + assert.match(agentsLaw, /single source of truth/); + // The template must warn about the inherited-BEADS_DIR trap: it silently routes + // every bd call into another project's store. + assert.match(agentsLaw, /BEADS_DIR/); + assert.match(await readFile(path.join(projectRoot, "GEMINI.md"), "utf8"), /@\.\/AGENTS\.md/); + + // Root-anchored entries: a bare `AGENTS.md` would also ignore a package-level + // AGENTS.md that the project legitimately tracks. + const exclude = await readFile(path.join(projectRoot, ".git", "info", "exclude"), "utf8"); + for (const fileName of ["AGENTS.md", "CLAUDE.md", "GEMINI.md"]) { + assert.match(exclude, new RegExp(`^/${fileName.replace(".", "\\.")}$`, "m")); + } + + // A rerun neither rewrites a file nor duplicates an exclude entry. + await writeFile(path.join(projectRoot, "AGENTS.md"), "# Edited by the project\n", "utf8"); + const rerun = await setup({ projectRoot, adapterName: "example-app" }); + assert.deepEqual(rerun.agentInstructions.written, []); + assert.equal( + await readFile(path.join(projectRoot, "AGENTS.md"), "utf8"), + "# Edited by the project\n" + ); + const excludeAfter = await readFile(path.join(projectRoot, ".git", "info", "exclude"), "utf8"); + assert.equal(excludeAfter.split("\n").filter((line) => line === "/AGENTS.md").length, 1); +}); + test("setup creates the ignored reference cache and the project-local beads exclude entry", { timeout: 10000 }, async () => { const projectRoot = await createProjectFixture(); const result = await setup({ projectRoot, adapterName: "example-app" }); @@ -277,6 +324,10 @@ test("doctor honors the requested adapter and validates required files", { timeo assert.equal(result.beadsStorePresent, true); assert.equal(result.resourcesIgnored, true); assert.equal(result.inheritedBeadsDirIgnored, false); + assert.deepEqual(result.agentInstructions, { + present: ["AGENTS.md", "CLAUDE.md", "GEMINI.md"], + missing: [] + }); }); test("doctor reports a missing project-local beads store and an ignored global BEADS_DIR", { timeout: 10000 }, async () => {