Make project instruction candidates configurable

This commit is contained in:
Yichen Jiang
2026-07-05 22:19:12 +08:00
parent 5ad483d120
commit f8f270c13e
6 changed files with 131 additions and 60 deletions

View File

@@ -1,14 +1,14 @@
# @deepseek-ai/dsh-project-instructions
Project instruction file loader for the harness. It discovers `AGENTS.md` with `CLAUDE.md` fallback for each agent session, injects the baseline content as fenced workspace context before model requests, and lazily adds nested instruction files when structured file tools touch deeper paths.
Project instruction file loader for the harness. It discovers the configured per-directory instruction file candidates for each agent session, injects the baseline content as fenced workspace context before model requests, and lazily adds nested instruction files when structured file tools touch deeper paths. The default candidate order is `AGENTS.md`, then `CLAUDE.md`.
## Behavior
The plugin listens on the `agent/request` waterfall and depends on the `ctx.fs` provider seam to read instruction file content. For each request it derives the workspace from `agent.session.header.cwd`; if the session has no cwd, it falls back to `process.cwd()` for single-session local/stdio runs. It then finds the project root by walking upward until it sees `.git` as either a directory or a file, considers the ancestor chain from project root to cwd, and loads at most one instruction file per directory: `AGENTS.md` wins, `CLAUDE.md` is a compatibility fallback.
The plugin listens on the `agent/request` waterfall and depends on the `ctx.fs` provider seam to read instruction file content. For each request it derives the workspace from `agent.session.header.cwd`; if the session has no cwd, it falls back to `process.cwd()` for single-session local/stdio runs. It then finds the project root by walking upward until it sees `.git` as either a directory or a file, considers the ancestor chain from project root to cwd, and loads at most one instruction file per directory by checking `instructionFileCandidates` in order. With the default candidates, `AGENTS.md` wins and `CLAUDE.md` is a compatibility fallback.
The plugin also listens on `tools/post-execute` for successful structured filesystem touches from the first-party `read`, `write`, and `edit` tools. When one of those tools touches a descendant of the session cwd, the plugin checks the directories between the session cwd and the touched file for instruction files that are not already visible in session context, then attaches them as `additionalContext` so the loop records a durable `context/message` for the next model request. This intentionally follows file-tool touches, not shell `cd`: `dsh-bash-local` uses fresh shells per call, and parsing arbitrary shell commands for reached paths would be brittle.
User-global instructions live at `$DSH_HOME/AGENTS.md`; `$DSH_HOME` defaults to `~/.dsh`. A configured `~`, `~/...`, or Windows-style `~\...` prefix is expanded against the operating-system home directory before resolution. The user-global file renders before project files, so deeper project files appear later in the context and can override broader guidance.
User-global instructions live at `$DSH_HOME/AGENTS.md`; `$DSH_HOME` defaults to `~/.dsh`. A configured `~`, `~/...`, or Windows-style `~\...` prefix is expanded against the operating-system home directory before resolution. The user-global file name is harness-level and is not affected by `instructionFileCandidates`, which only controls per-directory project and nested discovery. The user-global file renders before project files, so deeper project files appear later in the context and can override broader guidance.
Baseline files are inserted as a synthetic user-role workspace-context message, not as provider system text and not as persisted session events. Nested files discovered after structured file tools run are inserted through the existing `context/message` path, so they persist with the session and resume like other plugin-provided context. Nested duplicate suppression is derived from the visible session surface plus a short pending window before the loop records `additionalContext`; if compaction removes a nested context message from the surface, a later structured file touch may re-load it so the next model request still sees the applicable guidance. The rendered envelope states that these files are workspace-provided guidance, lower authority than system/developer/direct user instructions, and must not override safety, permission, or secret-handling rules.
@@ -21,11 +21,11 @@ export interface Config {
dshHome?: string
projectRootMarkers?: string[]
baselineMaxBytes?: number
enableClaudeFallback?: boolean
instructionFileCandidates?: string[]
}
```
`projectRootMarkers` defaults to `['.git']`, `baselineMaxBytes` defaults to `65536`, and `enableClaudeFallback` defaults to `true`. Setting `baselineMaxBytes` to `0` or another non-positive value disables both baseline and nested instruction injection.
`projectRootMarkers` defaults to `['.git']`, `baselineMaxBytes` defaults to `65536`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project or nested directory, the first existing candidate is loaded and the rest are ignored. Candidate entries must be same-directory file names; empty entries, `.`/`..`, and entries containing `/` or `\` are ignored. Setting `baselineMaxBytes` to `0` or another non-positive value disables both baseline and nested instruction injection.
## Budgeting and cache
@@ -35,4 +35,4 @@ Discovery re-walks the applicable ancestor chain on every request so newly creat
## Non-goals
This phase does not implement `contextPaths()`, shell parsing, bash-`cd`-based instruction loading, lowercase filenames, `.claude/` rule directories, local/private variants, `@path` imports, file watching, or model-generated summaries. Those need separate semantics beyond structured file-tool touches.
This phase does not implement `contextPaths()`, shell parsing, bash-`cd`-based instruction loading, lowercase filenames by default, `.claude/` rule directories, `@path` imports, file watching, or model-generated summaries. Simple same-directory local/private filenames such as `CLAUDE.local.md` can be opted into through `instructionFileCandidates`; broader rule directories and import semantics need separate design beyond structured file-tool touches.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-project-instructions",
"description": "Project instruction file loader for AGENTS.md with CLAUDE.md fallback",
"description": "Project instruction file loader with configurable instruction candidates",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -1,7 +1,7 @@
/**
* Project instruction file loader: discovers `AGENTS.md` with `CLAUDE.md`
* fallback on the per-session workspace path, reads them through `ctx.fs`, and
* injects them as fenced workspace context for each model request.
* Project instruction file loader: discovers the configured per-directory
* instruction candidate list, reads matches through `ctx.fs`, and injects them
* as fenced workspace context for each model request.
*
* @module @deepseek-ai/dsh-project-instructions
*/
@@ -21,6 +21,8 @@ export const inject = ['fs']
const DEFAULT_BASELINE_MAX_BYTES = 64 * 1024
const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const
const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const
const RESERVED_PATH_SEGMENTS = new Set(['', '.', '..'])
const WORKSPACE_CONTEXT_OPEN = '<workspace-context source="project-instruction-files">'
const WORKSPACE_CONTEXT_CLOSE = '</workspace-context>'
const INSTRUCTION_FILE_MARKER_OPEN = '<!-- project-instruction-files:path='
@@ -38,14 +40,14 @@ export interface Config {
dshHome?: string
projectRootMarkers?: string[]
baselineMaxBytes?: number
enableClaudeFallback?: boolean
instructionFileCandidates?: string[]
}
export const Config: z<Config> = z.object({
dshHome: z.string(),
projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]),
baselineMaxBytes: z.number().default(DEFAULT_BASELINE_MAX_BYTES),
enableClaudeFallback: z.boolean().default(true),
instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]),
})
export interface InstructionFile {
@@ -78,7 +80,7 @@ interface ResolvedConfig {
dshHome: string
projectRootMarkers: string[]
baselineMaxBytes: number
enableClaudeFallback: boolean
instructionFileCandidates: string[]
}
interface FileSignature {
@@ -96,7 +98,7 @@ interface DiscoverOptions {
cwd: string
dshHome?: string
projectRootMarkers?: string[]
enableClaudeFallback?: boolean
instructionFileCandidates?: string[]
}
interface LoadOptions extends DiscoverOptions {
@@ -117,10 +119,16 @@ function resolveConfig(config: Config): ResolvedConfig {
dshHome: resolveDshHome(config.dshHome),
projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
baselineMaxBytes: config.baselineMaxBytes ?? DEFAULT_BASELINE_MAX_BYTES,
enableClaudeFallback: config.enableClaudeFallback ?? true,
instructionFileCandidates: resolveInstructionFileCandidates(config.instructionFileCandidates),
}
}
function resolveInstructionFileCandidates(candidates: string[] | undefined): string[] {
return (candidates ?? [...DEFAULT_INSTRUCTION_FILE_CANDIDATES]).filter(candidate => (
!RESERVED_PATH_SEGMENTS.has(candidate) && !/[\\/]/.test(candidate)
))
}
function byteLength(value: string): number {
return Buffer.byteLength(value, 'utf8')
}
@@ -222,30 +230,20 @@ function descendantDirsBetween(root: string, touchedPath: string): string[] {
async function firstExistingInstructionFile(
dir: string,
root: string,
enableClaudeFallback: boolean,
instructionFileCandidates: readonly string[],
fileSystem?: FileSystem,
): Promise<DiscoveredInstructionFile | undefined> {
const agentsPath = join(dir, 'AGENTS.md')
const agentsSignature = await statFile(agentsPath, fileSystem)
if (agentsSignature !== undefined) {
const { target, ...signature } = agentsSignature
return {
absolutePath: agentsPath,
displayPath: relativeDisplay(root, agentsPath),
signature,
...target === undefined ? {} : { target },
}
}
if (!enableClaudeFallback) return undefined
const claudePath = join(dir, 'CLAUDE.md')
const claudeSignature = await statFile(claudePath, fileSystem)
if (claudeSignature !== undefined) {
const { target, ...signature } = claudeSignature
return {
absolutePath: claudePath,
displayPath: relativeDisplay(root, claudePath),
signature,
...target === undefined ? {} : { target },
for (const candidate of instructionFileCandidates) {
const path = join(dir, candidate)
const fileSignature = await statFile(path, fileSystem)
if (fileSignature !== undefined) {
const { target, ...signature } = fileSignature
return {
absolutePath: path,
displayPath: relativeDisplay(root, path),
signature,
...target === undefined ? {} : { target },
}
}
}
return undefined
@@ -282,7 +280,7 @@ async function discoverInstructionFiles(options: DiscoverOptions, fileSystem?: F
const cwd = resolve(options.cwd)
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem)
for (const dir of ancestorChain(projectRoot, cwd)) {
const file = await firstExistingInstructionFile(dir, projectRoot, config.enableClaudeFallback, fileSystem)
const file = await firstExistingInstructionFile(dir, projectRoot, config.instructionFileCandidates, fileSystem)
if (file !== undefined) addFile(file)
}
return files
@@ -294,7 +292,7 @@ async function discoverNestedInstructionFiles(options: NestedLoadOptions, fileSy
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem)
const files: DiscoveredInstructionFile[] = []
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) {
const file = await firstExistingInstructionFile(dir, projectRoot, config.enableClaudeFallback, fileSystem)
const file = await firstExistingInstructionFile(dir, projectRoot, config.instructionFileCandidates, fileSystem)
if (file !== undefined && !options.loadedDisplayPaths.has(file.displayPath)) files.push(file)
}
return files
@@ -580,7 +578,7 @@ async function dynamicInstructionContext(
dshHome: resolved.dshHome,
projectRootMarkers: resolved.projectRootMarkers,
baselineMaxBytes: resolved.baselineMaxBytes,
enableClaudeFallback: resolved.enableClaudeFallback,
instructionFileCandidates: resolved.instructionFileCandidates,
touchedPath,
loadedDisplayPaths,
pendingDisplayPaths,
@@ -603,7 +601,7 @@ export function apply(ctx: Context, config: Config): void {
dshHome: resolved.dshHome,
projectRootMarkers: resolved.projectRootMarkers,
baselineMaxBytes: resolved.baselineMaxBytes,
enableClaudeFallback: resolved.enableClaudeFallback,
instructionFileCandidates: resolved.instructionFileCandidates,
cache,
}, ctx.fs)
if (instructions !== undefined) {

View File

@@ -132,7 +132,7 @@ function appendAdditionalContext(agent: Agent, result: { additionalContext?: Hoo
}
describe('project instruction discovery', () => {
it('loads user-global first, then root-to-cwd project instructions with AGENTS.md winning over CLAUDE.md', async () => {
it('loads user-global first, then root-to-cwd project instructions using the default candidate order', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -144,11 +144,7 @@ describe('project instruction discovery', () => {
await write(join(root, 'packages/CLAUDE.md'), 'package claude')
await write(join(cwd, 'AGENTS.md'), 'app agents')
const files = await discoverBaselineInstructionFiles({
cwd,
dshHome: home,
enableClaudeFallback: true,
})
const files = await discoverBaselineInstructionFiles({ cwd, dshHome: home })
expect(files.map(file => file.displayPath)).toEqual([
'$DSH_HOME/AGENTS.md',
@@ -268,14 +264,18 @@ describe('project instruction discovery', () => {
}
})
it('does not load CLAUDE.md when Claude fallback is disabled', async () => {
it('honors configured instruction candidates that exclude CLAUDE.md', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'CLAUDE.md'), 'claude only')
const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: home, enableClaudeFallback: false })
const files = await discoverBaselineInstructionFiles({
cwd: root,
dshHome: home,
instructionFileCandidates: ['AGENTS.md'],
})
expect(files).toEqual([])
} finally {
@@ -284,6 +284,49 @@ describe('project instruction discovery', () => {
}
})
it('uses the configured instruction candidate order without hard-coding AGENTS.md priority', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'native rule')
await write(join(root, 'CLAUDE.local.md'), 'local claude rule')
await write(join(root, 'CLAUDE.md'), 'claude rule')
const files = await discoverBaselineInstructionFiles({
cwd: root,
dshHome: home,
instructionFileCandidates: ['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.md'],
})
expect(files.map(file => file.displayPath)).toEqual(['CLAUDE.local.md'])
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('ignores configured instruction candidates that are not same-directory file names', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'native rule')
await write(join(root, '.claude/CLAUDE.md'), 'nested claude rule')
const files = await discoverBaselineInstructionFiles({
cwd: root,
dshHome: home,
instructionFileCandidates: ['', '.', '..', '.claude/CLAUDE.md', 'nested\\CLAUDE.md', 'AGENTS.md'],
})
expect(files.map(file => file.displayPath)).toEqual(['AGENTS.md'])
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('defaults dshHome and uses cwd itself as root when no project marker exists', async () => {
const root = await tempRepo()
try {
@@ -936,6 +979,36 @@ describe('dynamic nested project instruction injection', () => {
}
})
it('uses configured instruction candidates for nested discovery', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'pkg/AGENTS.md'), 'native package rule')
await write(join(root, 'pkg/CLAUDE.local.md'), 'local package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndProjectInstructions(ctx, {
dshHome: home,
instructionFileCandidates: ['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.md'],
})
const result = await ctx.tools.execute({
callId: CallId('read-configured-nested-candidate'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
agent: stubAgent(root),
})
const text = blocksText(result.additionalContext?.content)
expect(text).toContain('## pkg/CLAUDE.local.md\n\nlocal package rule')
expect(text).not.toContain('native package rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('does not attach nested instructions again for the same session once a path has been loaded', async () => {
const root = await tempRepo()
const home = await tempRepo()