diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 26cd3720d9d9..4ae4a41348ed 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -95,6 +95,9 @@ export class Info extends Schema.Class("Config.Info")({ instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({ description: "Additional paths or URLs supplying ambient instructions", }), + local_instruction_filenames: Schema.String.pipe(Schema.Array, Schema.optional).annotate({ + description: "Additional filenames to search for when loading directory-specific instructions", + }), references: ConfigReference.Info.pipe(Schema.optional).annotate({ description: "Named local directories or Git repositories available as external context", }), diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-context.ts index fdfe59aa0b49..c630efe8122e 100644 --- a/packages/core/src/instruction-context.ts +++ b/packages/core/src/instruction-context.ts @@ -2,6 +2,7 @@ export * as InstructionContext from "./instruction-context" import { Array, Effect, Layer, Schema } from "effect" import { isAbsolute, join, relative, sep } from "path" +import { Config } from "./config" import { FSUtil } from "./fs-util" import { Flag } from "./flag/flag" import { Global } from "./global" @@ -21,6 +22,7 @@ const key = SystemContext.Key.make("core/instructions") export const layer = Layer.effectDiscard( Effect.gen(function* () { const fs = yield* FSUtil.Service + const config = yield* Config.Service const global = yield* Global.Service const location = yield* Location.Service const registry = yield* SystemContextRegistry.Service @@ -42,11 +44,17 @@ export const layer = Layer.effectDiscard( const fromProject = relative(stop, start) const insideProject = fromProject === "" || (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject)) + const targets = Array.dedupe([ + "AGENTS.md", + ...(yield* config.entries()).flatMap((entry) => + entry.type === "document" ? (entry.info.local_instruction_filenames ?? []) : [], + ), + ]) const discovered = new Set( (Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject ? [] : yield* fs.up({ - targets: ["AGENTS.md"], + targets, start, stop, }) diff --git a/packages/core/src/system-context/builtins.ts b/packages/core/src/system-context/builtins.ts index 42cba27b9228..72ef598f89ef 100644 --- a/packages/core/src/system-context/builtins.ts +++ b/packages/core/src/system-context/builtins.ts @@ -1,6 +1,7 @@ export * as SystemContextBuiltIns from "./builtins" import { DateTime, Effect, Layer, Schema } from "effect" +import { Config } from "../config" import { Location } from "../location" import { SystemContext } from "./index" import { InstructionContext } from "../instruction-context" @@ -42,6 +43,7 @@ const builtIns = Layer.effectDiscard( export const layer = Layer.mergeAll(builtIns, InstructionContext.layer).pipe( Layer.provideMerge(SystemContextRegistry.layer), + Layer.provide(Config.locationLayer), ) export const locationLayer = layer diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 2e773f71e256..c961ce6c7377 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -121,6 +121,9 @@ export const Info = Schema.Struct({ instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Additional instruction files or patterns to include", }), + local_instruction_filenames: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ + description: "Additional filenames to search for when loading directory-specific instructions", + }), layout: Schema.optional(ConfigLayoutV1.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), permission: Schema.optional(ConfigPermissionV1.Info), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index c474cac51a75..95a392513cbe 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -63,6 +63,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) { skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])], commands: info.command, instructions: info.instructions, + local_instruction_filenames: info.local_instruction_filenames, references: info.references ?? info.reference, plugins: info.plugin?.map((plugin) => typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] }, diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 6275d8fed350..8bbd8f18dd76 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -144,6 +144,14 @@ describe("Config", () => { }), ) + it.effect("migrates v1 local instruction filenames", () => + Effect.sync(() => { + expect( + ConfigMigrateV1.migrate({ local_instruction_filenames: ["REVIEW.md"] }).local_instruction_filenames, + ).toEqual(["REVIEW.md"]) + }), + ) + it.live("returns an empty configuration when directory files do not exist", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -325,6 +333,7 @@ describe("Config", () => { }, skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"], instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"], + local_instruction_filenames: ["REVIEW.md", "SECURITY.md"], references: { local: { path: "../library" }, sdk: { repository: "github.com/example/sdk", branch: "main" }, @@ -418,6 +427,7 @@ describe("Config", () => { ".cursor/rules/*.md", "https://example.com/shared-rules.md", ]) + expect(documents[0]?.info.local_instruction_filenames).toEqual(["REVIEW.md", "SECURITY.md"]) expect(documents[0]?.info.references).toEqual({ local: { path: "../library" }, sdk: { repository: "github.com/example/sdk", branch: "main" }, diff --git a/packages/core/test/instruction-context.test.ts b/packages/core/test/instruction-context.test.ts index f21567f1f53f..b11a9d73d63a 100644 --- a/packages/core/test/instruction-context.test.ts +++ b/packages/core/test/instruction-context.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import fs from "fs/promises" import path from "path" +import { Config } from "@opencode-ai/core/config" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { InstructionContext } from "@opencode-ai/core/instruction-context" @@ -14,6 +15,11 @@ import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" const it = testEffect(Layer.empty) +const instructionLayer = (entries: Config.Entry[] = []) => + InstructionContext.layer.pipe( + Layer.provideMerge(SystemContextRegistry.layer), + Layer.provide(Layer.mock(Config.Service, { entries: () => Effect.succeed(entries) })), + ) describe("InstructionContext", () => { it.live("loads global and upward project AGENTS.md files as one aggregate context", () => @@ -41,7 +47,7 @@ describe("InstructionContext", () => { const load = SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide(instructionLayer()), Effect.provide(FSUtil.defaultLayer), Effect.provide(Global.layerWith({ config: global })), Effect.provide( @@ -96,6 +102,65 @@ describe("InstructionContext", () => { ), ) + it.live("loads configured instruction filenames from every config scope", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const directory = path.join(tmp.path, "project", "packages", "core") + const agents = path.join(directory, "AGENTS.md") + const review = path.join(directory, "REVIEW.md") + const security = path.join(directory, "SECURITY.md") + yield* Effect.promise(async () => { + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile(agents, "agents") + await fs.writeFile(review, "review") + await fs.writeFile(security, "security") + }) + + const context = yield* SystemContextRegistry.Service.pipe( + Effect.flatMap((service) => service.load()), + Effect.provide( + instructionLayer([ + new Config.Document({ + type: "document", + info: new Config.Info({ local_instruction_filenames: ["REVIEW.md"] }), + }), + new Config.Document({ + type: "document", + info: new Config.Info({ local_instruction_filenames: ["SECURITY.md", "REVIEW.md"] }), + }), + ]), + ), + Effect.provide(FSUtil.defaultLayer), + Effect.provide(Global.layerWith({ config: path.join(tmp.path, "global") })), + Effect.provide( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { projectDirectory: AbsolutePath.make(path.join(tmp.path, "project")) }, + ), + ), + ), + ), + ) + + expect((yield* SystemContext.initialize(context)).baseline).toBe( + [ + `Instructions from: ${agents}\nagents`, + `Instructions from: ${review}\nreview`, + `Instructions from: ${security}\nsecurity`, + ].join("\n\n"), + ) + }), + ), + ), + ) + it.live("keeps an empty AGENTS.md as available context", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -107,7 +172,7 @@ describe("InstructionContext", () => { yield* Effect.promise(() => fs.writeFile(file, "")) const context = yield* SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide(instructionLayer()), Effect.provide(FSUtil.defaultLayer), Effect.provide(Global.layerWith({ config: path.join(tmp.path, "global") })), Effect.provide( @@ -136,7 +201,7 @@ describe("InstructionContext", () => { ).pipe(Layer.provide(FSUtil.defaultLayer)) const context = yield* SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide(instructionLayer()), Effect.provide(failingFS), Effect.provide(Global.layerWith({ config: "/global" })), Effect.provide( @@ -172,7 +237,7 @@ describe("InstructionContext", () => { ).pipe(Layer.provide(FSUtil.defaultLayer)) const context = yield* SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide(instructionLayer()), Effect.provide(racingFS), Effect.provide(Global.layerWith({ config: "/global" })), Effect.provide( @@ -212,7 +277,7 @@ describe("InstructionContext", () => { yield* SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide(instructionLayer()), Effect.provide(observingFS), Effect.provide(Global.layerWith({ config: "/global" })), Effect.provide( @@ -241,7 +306,7 @@ describe("InstructionContext", () => { yield* SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide(instructionLayer()), Effect.provide( Layer.effect( FSUtil.Service, @@ -271,7 +336,7 @@ describe("InstructionContext", () => { let scanned = false yield* SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide(instructionLayer()), Effect.provide( Layer.effect( FSUtil.Service, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7f568f492073..1a27dfb26b8f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -47,6 +47,11 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info { if (target.instructions && source.instructions) { merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) } + if (target.local_instruction_filenames && source.local_instruction_filenames) { + merged.local_instruction_filenames = Array.from( + new Set([...target.local_instruction_filenames, ...source.local_instruction_filenames]), + ) + } return merged } diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 38ac55bbb64d..3eda6474cbaf 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -11,8 +11,22 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { FSUtil } from "@opencode-ai/core/fs-util" import { withTransientReadRetry } from "@/util/effect-http-client" import { Global } from "@opencode-ai/core/global" -import type { MessageV2 } from "./message-v2" import type { MessageID } from "./schema" +import { ConfigV1 } from "@opencode-ai/core/v1/config/config" + +function allInstructions(config: ConfigV1.Info, disableClaudeCodePrompt: boolean) { + const standard = [ + { filename: "AGENTS.md", standard: true, enabled: true }, + { filename: "CLAUDE.md", standard: true, enabled: !disableClaudeCodePrompt }, + { filename: "CONTEXT.md", standard: true, enabled: true }, // deprecated + ] + return [ + ...standard, + ...Array.from(new Set(config.local_instruction_filenames ?? [])) + .filter((filename) => !standard.some((item) => item.filename === filename)) + .map((filename) => ({ filename, standard: false, enabled: true })), + ] +} function extract(messages: SessionV1.WithParts[]) { const paths = new Set() @@ -61,12 +75,6 @@ export const layer: Layer.Layer< path.join(global.config, "AGENTS.md"), ...(!flags.disableClaudeCodePrompt ? [path.join(global.home, ".claude", "CLAUDE.md")] : []), ] - const instructionFiles = [ - "AGENTS.md", - ...(!flags.disableClaudeCodePrompt ? ["CLAUDE.md"] : []), - "CONTEXT.md", // deprecated - ] - const state = yield* InstanceState.make( Effect.fn("Instruction.state")(() => Effect.succeed({ @@ -114,21 +122,28 @@ export const layer: Layer.Layer< for (const file of globalFiles) { if (yield* fs.existsSafe(file)) { - paths.add(path.resolve(file)) + paths.add(FSUtil.resolve(file)) break } } // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { - for (const file of instructionFiles) { + const reservedBuiltins = new Set() + let foundStandard = false + for (const instruction of allInstructions(config, flags.disableClaudeCodePrompt)) { const matches = yield* fs - .findUp(file, ctx.directory, ctx.worktree) + .findUp(instruction.filename, ctx.directory, ctx.worktree) .pipe(Effect.catch(() => Effect.succeed([]))) - if (matches.length > 0) { - matches.forEach((item) => paths.add(path.resolve(item))) - break + const resolved = matches.map(FSUtil.resolve) + if (instruction.standard) { + resolved.forEach((item) => reservedBuiltins.add(item)) + if (!instruction.enabled || foundStandard || resolved.length === 0) continue + resolved.forEach((item) => paths.add(item)) + foundStandard = true + continue } + resolved.filter((item) => !reservedBuiltins.has(item)).forEach((item) => paths.add(item)) } } @@ -145,7 +160,7 @@ export const layer: Layer.Layer< }) : relative(instruction) ).pipe(Effect.catch(() => Effect.succeed([] as string[]))) - matches.forEach((item) => paths.add(path.resolve(item))) + matches.forEach((item) => paths.add(FSUtil.resolve(item))) } } @@ -168,12 +183,28 @@ export const layer: Layer.Layer< ] }) - const find = Effect.fn("Instruction.find")(function* (dir: string) { - for (const file of instructionFiles) { - const filepath = path.resolve(path.join(dir, file)) - if (yield* fs.existsSafe(filepath)) return filepath + const findFiles = Effect.fnUntraced(function* (dir: string, config: ConfigV1.Info) { + const files = new Set() + const reservedBuiltins = new Set() + let foundStandard = false + for (const instruction of allInstructions(config, flags.disableClaudeCodePrompt)) { + const filepath = path.resolve(path.join(dir, instruction.filename)) + if (!(yield* fs.existsSafe(filepath))) continue + const resolved = FSUtil.resolve(filepath) + if (instruction.standard) { + reservedBuiltins.add(resolved) + if (!instruction.enabled || foundStandard) continue + files.add(resolved) + foundStandard = true + continue + } + if (!reservedBuiltins.has(resolved)) files.add(resolved) } - return undefined + return Array.from(files) + }) + + const find = Effect.fn("Instruction.find")(function* (dir: string) { + return (yield* findFiles(dir, yield* cfg.get()))[0] }) const resolve = Effect.fn("Instruction.resolve")(function* ( @@ -182,36 +213,32 @@ export const layer: Layer.Layer< messageID: MessageID, ) { const sys = yield* systemPaths() - const already = extract(messages) + const already = new Set(Array.from(extract(messages), FSUtil.resolve)) const results: { filepath: string; content: string }[] = [] const s = yield* InstanceState.get(state) - const root = path.resolve(yield* InstanceState.directory) + const root = FSUtil.resolve(yield* InstanceState.directory) + const config = yield* cfg.get() - const target = path.resolve(filepath) + const target = FSUtil.resolve(filepath) let current = path.dirname(target) // Walk upward from the file being read and attach nearby instruction files once per message. while (current.startsWith(root) && current !== root) { - const found = yield* find(current) - if (!found || found === target || sys.has(found) || already.has(found)) { - current = path.dirname(current) - continue - } + for (const found of yield* findFiles(current, config)) { + if (found === target || sys.has(found) || already.has(found)) continue - let set = s.claims.get(messageID) - if (!set) { - set = new Set() - s.claims.set(messageID, set) - } - if (set.has(found)) { - current = path.dirname(current) - continue - } + let set = s.claims.get(messageID) + if (!set) { + set = new Set() + s.claims.set(messageID, set) + } + if (set.has(found)) continue - set.add(found) - const content = yield* read(found) - if (content) { - results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` }) + set.add(found) + const content = yield* read(found) + if (content) { + results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` }) + } } current = path.dirname(current) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 02ace5366880..9084aaf6648e 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1072,6 +1072,18 @@ it.effect("deduplicates duplicate instructions from global and local configs", ( ), ) +it.effect("merges local instruction filenames from global and local configs", () => + withConfigTree( + { + global: { local_instruction_filenames: ["GLOBAL.md", "SHARED.md"] }, + local: { local_instruction_filenames: ["SHARED.md", "LOCAL.md"] }, + }, + Effect.gen(function* () { + expect((yield* Config.use.get()).local_instruction_filenames).toEqual(["GLOBAL.md", "SHARED.md", "LOCAL.md"]) + }), + ), +) + it.effect("deduplicates duplicate plugins from global and local configs", () => withConfigTree( { diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 53ccf06e120a..b33143d04b33 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" +import fs from "fs/promises" import path from "path" import { Effect, FileSystem, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" @@ -8,7 +9,6 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { FSUtil } from "@opencode-ai/core/fs-util" import { Instruction } from "../../src/session/instruction" -import type { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { Global } from "@opencode-ai/core/global" import { RuntimeFlags } from "../../src/effect/runtime-flags" @@ -17,14 +17,17 @@ import { testEffect } from "../lib/effect" import { TestConfig } from "../fixture/config" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { ConfigV1 } from "@opencode-ai/core/v1/config/config" const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer)) -const configLayer = TestConfig.layer() - -const instructionLayer = (global: Partial, flags: Partial = {}) => +const instructionLayer = ( + global: Partial, + flags: Partial = {}, + config: ConfigV1.Info = {}, +) => Instruction.layer.pipe( - Layer.provide(configLayer), + Layer.provide(TestConfig.layer({ get: () => Effect.succeed(config) })), Layer.provide(FSUtil.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(Global.layerWith(global)), @@ -32,9 +35,9 @@ const instructionLayer = (global: Partial, flags: Partial, flags?: Partial) => + (global: Partial, flags?: Partial, config?: ConfigV1.Info) => (self: Effect.Effect) => - self.pipe(Effect.provide(instructionLayer(global, flags))) + self.pipe(Effect.provide(instructionLayer(global, flags, config))) const write = (filepath: string, content: string) => Effect.gen(function* () { @@ -43,17 +46,29 @@ const write = (filepath: string, content: string) => yield* fs.writeFileString(filepath, content) }) +const alias = (target: string, filepath: string) => + Effect.promise(() => + fs + .stat(filepath) + .then(() => undefined) + .catch(() => fs.symlink(target, filepath)), + ) + const writeFiles = (dir: string, files: Record) => Effect.all( Object.entries(files).map(([file, content]) => write(path.join(dir, file), content)), { discard: true }, ) -const withFiles = (files: Record, self: (dir: string) => Effect.Effect) => +const withFiles = ( + files: Record, + self: (dir: string) => Effect.Effect, + config?: ConfigV1.Info, +) => provideTmpdirInstance((dir) => Effect.gen(function* () { yield* writeFiles(dir, files) - return yield* self(dir).pipe(provideInstruction({ home: dir, config: dir })) + return yield* self(dir).pipe(provideInstruction({ home: dir, config: dir }, {}, config)) }), ) @@ -135,6 +150,76 @@ describe("Instruction.resolve", () => { ), ) + it.live("returns a configured instruction filename from a subdirectory", () => + withFiles( + { + "subdir/AGENTS.md": "# General Instructions", + "subdir/REVIEW.md": "# Review Instructions", + "subdir/nested/file.ts": "const x = 1", + }, + (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const agents = path.join(dir, "subdir", "AGENTS.md") + const review = path.join(dir, "subdir", "REVIEW.md") + const results = yield* svc.resolve( + [], + path.join(dir, "subdir", "nested", "file.ts"), + MessageID.make("msg_message-custom-1"), + ) + + expect(results).toEqual([ + { filepath: agents, content: `Instructions from: ${agents}\n# General Instructions` }, + { filepath: review, content: `Instructions from: ${review}\n# Review Instructions` }, + ]) + }), + { local_instruction_filenames: ["REVIEW.md"] }, + ), + ) + + it.live("deduplicates standard aliases and preserves the Claude prompt opt-out", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* writeFiles(dir, { + "AGENTS.md": "# Root Instructions", + "CLAUDE.md": "# Root Claude", + "subdir/AGENTS.md": "# Subdir Instructions", + "subdir/CLAUDE.md": "# Subdir Claude", + "subdir/nested/file.ts": "const x = 1", + }) + yield* Effect.all( + [ + [path.join(dir, "AGENTS.md"), path.join(dir, "agents.md")], + [path.join(dir, "CLAUDE.md"), path.join(dir, "claude.md")], + [path.join(dir, "subdir", "AGENTS.md"), path.join(dir, "subdir", "agents.md")], + [path.join(dir, "subdir", "CLAUDE.md"), path.join(dir, "subdir", "claude.md")], + ].map(([target, filepath]) => alias(target, filepath)), + { discard: true }, + ) + + return yield* Effect.gen(function* () { + const svc = yield* Instruction.Service + expect(yield* svc.systemPaths()).toEqual(new Set([FSUtil.resolve(path.join(dir, "AGENTS.md"))])) + + const agents = FSUtil.resolve(path.join(dir, "subdir", "AGENTS.md")) + expect( + yield* svc.resolve( + [], + path.join(dir, "subdir", "nested", "file.ts"), + MessageID.make("msg_message-alias-1"), + ), + ).toEqual([{ filepath: agents, content: `Instructions from: ${agents}\n# Subdir Instructions` }]) + }).pipe( + provideInstruction( + { home: dir, config: path.join(dir, "global") }, + { disableClaudeCodePrompt: true }, + { local_instruction_filenames: ["agents.md", "claude.md"] }, + ), + ) + }), + ), + ) + it.live("doesn't reload AGENTS.md when reading it directly", () => withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) => Effect.gen(function* () { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e27957f88fe1..a48dc01970ca 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2006,6 +2006,7 @@ export type Config = { } } instructions?: Array + local_instruction_filenames?: Array layout?: LayoutConfig permission?: PermissionConfig tools?: {