From 136bad9f17cbe3833bb8ed4be651ab8c6db9bf22 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 11 Aug 2026 13:57:10 -0400 Subject: [PATCH] feat(core): discover AGENTS.md up to the home directory (#41652) --- .../core/src/config/plugin/instruction.ts | 6 +- .../core/test/instruction-discovery.test.ts | 82 +++++++++++++++++-- .../content/docs/(Configure)/instructions.mdx | 9 +- packages/www/content/docs/migrate-v1.mdx | 4 +- 4 files changed, 86 insertions(+), 15 deletions(-) diff --git a/packages/core/src/config/plugin/instruction.ts b/packages/core/src/config/plugin/instruction.ts index 2b8ee8f19f7..3f22919afbf 100644 --- a/packages/core/src/config/plugin/instruction.ts +++ b/packages/core/src/config/plugin/instruction.ts @@ -27,8 +27,10 @@ export const Plugin = define({ const changes = yield* PubSub.sliding(1) const lock = Semaphore.makeUnsafe(1) const start = yield* fs.resolve(location.directory) - const stop = yield* fs.resolve(location.project.directory) - const project = discovery.project && FSUtil.contains(stop, start) + const root = yield* fs.resolve(location.project.directory) + const home = yield* fs.resolve(global.home) + const project = discovery.project && FSUtil.contains(root, start) + const stop = FSUtil.contains(home, start) ? home : root const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md")) const loaded: { current: Loaded } = { current: { type: "available", files: [] } } diff --git a/packages/core/test/instruction-discovery.test.ts b/packages/core/test/instruction-discovery.test.ts index 935ca0ebbaa..5c2706f2d1f 100644 --- a/packages/core/test/instruction-discovery.test.ts +++ b/packages/core/test/instruction-discovery.test.ts @@ -24,6 +24,7 @@ const it = testEffect(Layer.empty) const instructionLayer = (input: { config?: string + home?: string locationServiceLayer: Layer.Layer filesystemLayer?: Layer.Layer project?: boolean @@ -34,7 +35,15 @@ const instructionLayer = (input: { LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]), [ [InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })], - [Global.node, input.config ? Global.layerWith({ config: input.config }) : tempGlobalLayer], + [ + Global.node, + input.config || input.home + ? Global.layerWith({ + ...(input.config ? { config: input.config } : {}), + ...(input.home ? { home: input.home } : {}), + }) + : tempGlobalLayer, + ], [Location.node, input.locationServiceLayer], [Watcher.node, watcher], ...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []), @@ -112,10 +121,13 @@ describe("ConfigInstructionPlugin.Plugin", () => { ).pipe( Effect.flatMap((tmp) => { const global = path.join(tmp.path, "global") - const project = path.join(tmp.path, "project") + const home = path.join(tmp.path, "home") + const shared = path.join(home, "code") + const project = path.join(shared, "repo") const directory = path.join(project, "packages", "core") const outside = path.join(tmp.path, "AGENTS.md") const globalFile = path.join(global, "AGENTS.md") + const sharedFile = path.join(shared, "AGENTS.md") const projectFile = path.join(project, "AGENTS.md") const packageFile = path.join(directory, "AGENTS.md") return Effect.gen(function* () { @@ -124,6 +136,7 @@ describe("ConfigInstructionPlugin.Plugin", () => { await fs.mkdir(directory, { recursive: true }) await fs.writeFile(outside, "outside") await fs.writeFile(globalFile, "global") + await fs.writeFile(sharedFile, "shared") await fs.writeFile(projectFile, "project") await fs.writeFile(packageFile, "package") }) @@ -135,13 +148,20 @@ describe("ConfigInstructionPlugin.Plugin", () => { { path: packageFile, type: "file" }, { path: path.join(project, "packages", "AGENTS.md"), type: "file" }, { path: projectFile, type: "file" }, + { path: sharedFile, type: "file" }, + { path: path.join(home, "AGENTS.md"), type: "file" }, ]) + expect(yield* watcher.subscriptions()).not.toContainEqual({ + path: path.join(tmp.path, "AGENTS.md"), + type: "file", + }) const initialized = yield* readInitial(yield* discovery.load()) expect(initialized.text).toBe( [ `Instructions from: ${globalFile}\nglobal`, `Instructions from: ${packageFile}\npackage`, `Instructions from: ${projectFile}\nproject`, + `Instructions from: ${sharedFile}\nshared`, ].join("\n\n"), ) expect(initialized.text).not.toContain("outside") @@ -159,6 +179,7 @@ describe("ConfigInstructionPlugin.Plugin", () => { "These instructions replace all previously loaded ambient instructions.", `Instructions from: ${globalFile}\nglobal`, `Instructions from: ${projectFile}\nproject`, + `Instructions from: ${sharedFile}\nshared`, ].join("\n\n"), ) @@ -166,6 +187,8 @@ describe("ConfigInstructionPlugin.Plugin", () => { yield* emitAndWait({ type: "delete", path: globalFile }) yield* Effect.promise(() => fs.rm(projectFile)) yield* emitAndWait({ type: "delete", path: projectFile }) + yield* Effect.promise(() => fs.rm(sharedFile)) + yield* emitAndWait({ type: "delete", path: sharedFile }) expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe( "Previously loaded instructions no longer apply.", ) @@ -173,6 +196,7 @@ describe("ConfigInstructionPlugin.Plugin", () => { Effect.provide( instructionLayer({ config: global, + home, locationServiceLayer: Layer.succeed( Location.Service, Location.Service.of( @@ -215,15 +239,17 @@ describe("ConfigInstructionPlugin.Plugin", () => { ), ) - it.live("discovers a newly created instruction file in an intermediate directory", () => + it.live("discovers a newly created instruction file above the project root", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ).pipe( Effect.flatMap((tmp) => { - const project = path.join(tmp.path, "project") - const intermediate = path.join(project, "packages", "AGENTS.md") - const directory = path.join(project, "packages", "core") + const home = path.join(tmp.path, "home") + const shared = path.join(home, "code") + const project = path.join(shared, "repo") + const intermediate = path.join(shared, "AGENTS.md") + const directory = path.join(project, "core") const projectFile = path.join(project, "AGENTS.md") return Effect.gen(function* () { yield* Effect.promise(() => fs.mkdir(directory, { recursive: true })) @@ -235,7 +261,7 @@ describe("ConfigInstructionPlugin.Plugin", () => { yield* emitAndWait({ type: "create", path: intermediate }) expect((yield* readInitial(yield* discovery.load())).text).toBe( - [`Instructions from: ${intermediate}\nintermediate`, `Instructions from: ${projectFile}\nproject`].join( + [`Instructions from: ${projectFile}\nproject`, `Instructions from: ${intermediate}\nintermediate`].join( "\n\n", ), ) @@ -243,6 +269,48 @@ describe("ConfigInstructionPlugin.Plugin", () => { Effect.provide( instructionLayer({ config: path.join(tmp.path, "global"), + home, + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { projectDirectory: AbsolutePath.make(project) }, + ), + ), + ), + }), + ), + ) + }), + ), + ) + + it.live("stops instruction candidates at the project root outside home", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => { + const global = path.join(tmp.path, "global") + const home = path.join(tmp.path, "home") + const project = path.join(tmp.path, "scratch", "repo") + const directory = path.join(project, "packages", "core") + return Effect.gen(function* () { + yield* Effect.promise(() => fs.mkdir(directory, { recursive: true })) + yield* start() + const watcher = yield* Watcher.Test + expect(yield* watcher.subscriptions()).toEqual([ + { path: path.join(global, "AGENTS.md"), type: "file" }, + { path: path.join(directory, "AGENTS.md"), type: "file" }, + { path: path.join(project, "packages", "AGENTS.md"), type: "file" }, + { path: path.join(project, "AGENTS.md"), type: "file" }, + ]) + }).pipe( + Effect.provide( + instructionLayer({ + config: global, + home, locationServiceLayer: Layer.succeed( Location.Service, Location.Service.of( diff --git a/packages/www/content/docs/(Configure)/instructions.mdx b/packages/www/content/docs/(Configure)/instructions.mdx index 049f5bb0ee4..d47fc13497a 100644 --- a/packages/www/content/docs/(Configure)/instructions.mdx +++ b/packages/www/content/docs/(Configure)/instructions.mdx @@ -19,8 +19,9 @@ V2 loads: 1. The global file at `$XDG_CONFIG_HOME/opencode/AGENTS.md`, normally `~/.config/opencode/AGENTS.md`. -2. Every `AGENTS.md` from the current Location up to and including the project - root. +2. Every `AGENTS.md` from the current Location up to and including the home + directory when the Location is inside it. For Locations outside home, the + scan stops at the project root. For example, when the Location is `packages/web`, OpenCode can load all three project files below: @@ -35,9 +36,9 @@ my-project/ ``` The files are combined rather than selecting a single winner. They are rendered -in this order: global, then project files from the Location toward the project +in this order: global, then files from the Location toward home or the project root. OpenCode does not resolve conflicts between their contents, so keep broad -guidance global and put scoped guidance in the relevant project directory. +guidance global and put scoped guidance in the relevant directory. If the Location is outside the project root, only the global file is loaded. Setting `OPENCODE_DISABLE_PROJECT_CONFIG=1` also skips project `AGENTS.md` diff --git a/packages/www/content/docs/migrate-v1.mdx b/packages/www/content/docs/migrate-v1.mdx index 05e290f9f77..3dca0cd1618 100644 --- a/packages/www/content/docs/migrate-v1.mdx +++ b/packages/www/content/docs/migrate-v1.mdx @@ -504,8 +504,8 @@ require a V2 rewrite. See [Skills](/skills). ### Instruction files -Existing `AGENTS.md` files stay in place. V2 discovers the global `~/.config/opencode/AGENTS.md` and project `AGENTS.md` -files from the current directory up to the project root. +Existing `AGENTS.md` files stay in place. V2 discovers the global `~/.config/opencode/AGENTS.md` and ambient `AGENTS.md` +files from the current directory up to home. For projects outside home, discovery stops at the project root. If a V1 setup relied on a `CLAUDE.md` fallback, move that guidance into the applicable `AGENTS.md`. V2 currently only discovers `AGENTS.md`; because non-API V1 behavior is intended to remain compatible, also run `/report` with the affected