mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Make project instruction candidates configurable
This commit is contained in:
@@ -90,7 +90,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
|
||||
| [Compaction as a capability seam (abstract contract + basic backend)](implemented/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 |
|
||||
| [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 |
|
||||
| [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 |
|
||||
| [Project instruction files (`AGENTS.md` with `CLAUDE.md` fallback)](implemented/feature/2026-06-24-project-instruction-files.md) | 2026-06-24 |
|
||||
| [Project instruction files (configurable `AGENTS.md`/`CLAUDE.md` candidates)](implemented/feature/2026-06-24-project-instruction-files.md) | 2026-06-24 |
|
||||
| [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 |
|
||||
| [dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges](implemented/feature/2026-06-30-hook-bridges.md) | 2026-06-30 |
|
||||
| [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# RFC: Project instruction files (`AGENTS.md` with `CLAUDE.md` fallback)
|
||||
# RFC: Project instruction files (configurable `AGENTS.md`/`CLAUDE.md` candidates)
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -20,13 +20,13 @@ This RFC ships baseline loading plus structured file-tool nested loading. The ba
|
||||
|
||||
### File names and precedence
|
||||
|
||||
The native file name is `AGENTS.md`. `CLAUDE.md` is a compatibility fallback, not a parallel default. In any one directory, load at most one instruction file: `AGENTS.md` wins; if absent, `CLAUDE.md` may load. This mirrors opencode's conflict-avoidance policy rather than Reasonix's "load everything" policy, because a repo carrying both names is likely in transition and the two files can duplicate or contradict each other.
|
||||
The native file name is `AGENTS.md`. `CLAUDE.md` is a compatibility fallback, not a parallel default. The default per-directory candidate list is `['AGENTS.md', 'CLAUDE.md']`; in any one directory, the plugin loads at most one instruction file by checking that list in order. With defaults, `AGENTS.md` wins; if absent, `CLAUDE.md` may load. This mirrors opencode's conflict-avoidance policy rather than Reasonix's "load everything" policy, because a repo carrying both names is likely in transition and the two files can duplicate or contradict each other.
|
||||
|
||||
The first cut intentionally does not load lowercase variants (`agents.md`, `claude.md`), local/personal variants (`AGENTS.local.md`, `CLAUDE.local.md`), `.claude/CLAUDE.md`, or `.claude/rules/*.md`. Those are valid future extensions, but the first shipped contract should be small and predictable: one cross-tool user-global file, plus one instruction candidate per directory on the applicable path.
|
||||
Apps may override `instructionFileCandidates` to customize project and nested per-directory discovery. `AGENTS.md` is intentionally part of that candidate list rather than a hidden hard-coded priority, so a product may opt into names such as `CLAUDE.local.md` or use a narrower project contract. Candidate entries are same-directory file names only; empty entries, `.`/`..`, and entries containing `/` or `\` are ignored. The first shipped default remains small and predictable: one cross-tool user-global file, plus one instruction candidate per directory on the applicable path. Lowercase variants (`agents.md`, `claude.md`), local/personal variants (`AGENTS.local.md`, `CLAUDE.local.md`), `.claude/CLAUDE.md`, and `.claude/rules/*.md` are not loaded by default; simple same-directory names can be configured, while nested rule directories and import-like semantics remain deferred.
|
||||
|
||||
### User-global instructions
|
||||
|
||||
User-global harness instructions live at `$DSH_HOME/AGENTS.md`, where `$DSH_HOME` defaults to `~/.dsh` when unset. This mirrors Codex's `~/.codex` and Claude Code's `~/.claude` convention without inventing a repo-local home. The user-global file loads before project files so project-specific instructions appear later and can override broad preferences in the model-readable order.
|
||||
User-global harness instructions live at `$DSH_HOME/AGENTS.md`, where `$DSH_HOME` defaults to `~/.dsh` when unset. This mirrors Codex's `~/.codex` and Claude Code's `~/.claude` convention without inventing a repo-local home. The user-global file name is fixed because `$DSH_HOME` is the harness-level data/config location; `instructionFileCandidates` only customizes per-directory project and nested discovery. The user-global file loads before project files so project-specific instructions appear later and can override broad preferences in the model-readable order.
|
||||
|
||||
`$DSH_HOME` is a filesystem location only; this RFC does not introduce a broader config service. The default `.dsh` directory name and tilde expansion live in the small `dsh-paths` utility package so future features can share the same convention without depending on this prompt plugin. If a future config package owns the harness data directory, it should preserve this default and consume or supersede that helper deliberately.
|
||||
|
||||
@@ -34,7 +34,7 @@ User-global harness instructions live at `$DSH_HOME/AGENTS.md`, where `$DSH_HOME
|
||||
|
||||
For each agent request, the plugin derives the applicable working directory from `agent.session.header.cwd`. If the session has no cwd, it may fall back to `process.cwd()`, but that fallback is only meaningful for single-session local/stdio runs; ACP-created or ACP-resumed sessions are expected to carry an absolute persisted cwd, because the server launch directory is not the client's workspace.
|
||||
|
||||
The plugin finds the project root by walking upward from that cwd until it finds a `.git` marker. The marker may be either a directory or a file, so linked worktrees and submodules work. If no `.git` marker is found, the project root is the cwd itself. The plugin then considers the ancestor chain from project root to cwd, inclusive, and in each directory loads `AGENTS.md` or, when absent, `CLAUDE.md`.
|
||||
The plugin finds the project root by walking upward from that cwd until it finds a `.git` marker. The marker may be either a directory or a file, so linked worktrees and submodules work. If no `.git` marker is found, the project root is the cwd itself. The plugin then considers the ancestor chain from project root to cwd, inclusive, and in each directory loads the first existing `instructionFileCandidates` entry.
|
||||
|
||||
Example: if the session cwd is `/repo/packages/app`, and `/repo/.git` exists, the baseline search order is `/repo`, `/repo/packages`, `/repo/packages/app`. If `/repo/AGENTS.md`, `/repo/packages/CLAUDE.md`, and `/repo/packages/app/AGENTS.md` exist, the rendered order is user-global first, then `/repo/AGENTS.md`, then `/repo/packages/CLAUDE.md`, then `/repo/packages/app/AGENTS.md`. Later entries are more specific, so the rendered text states that deeper files override parent files and direct user/developer/system instructions override all instruction files.
|
||||
|
||||
@@ -42,7 +42,7 @@ If the user launches from the repository root, only the root directory is in the
|
||||
|
||||
### Nested discovery after file tools
|
||||
|
||||
The plugin observes successful `read`, `write`, and `edit` calls through `tools/post-execute`. For a touched file under the session cwd, it checks the directory chain from just below the session cwd through the touched file's parent directory, using the same file-name precedence as baseline discovery. Newly discovered nested files are attached as `additionalContext` so the loop records them after the tool result as durable `context/message` events for the next request. Visible session context suppresses duplicate nested injections even if file content is evicted from the content cache; compaction that replaces a nested context message out of the visible surface allows a later structured file touch to re-load the applicable instruction file.
|
||||
The plugin observes successful `read`, `write`, and `edit` calls through `tools/post-execute`. For a touched file under the session cwd, it checks the directory chain from just below the session cwd through the touched file's parent directory, using the same configured candidate precedence as baseline discovery. Newly discovered nested files are attached as `additionalContext` so the loop records them after the tool result as durable `context/message` events for the next request. Visible session context suppresses duplicate nested injections even if file content is evicted from the content cache; compaction that replaces a nested context message out of the visible surface allows a later structured file touch to re-load the applicable instruction file.
|
||||
|
||||
Shell commands are not a trigger. `dsh-bash-local` runs each command in a fresh shell and does not persist shell cwd, and parsing `cd subdir && cat file` reliably would require shell semantics the harness does not own. If bash-driven path discovery becomes necessary, it should be a separate design over an explicit path-reporting contract rather than a heuristic bolted onto this plugin.
|
||||
|
||||
@@ -116,13 +116,13 @@ Summarize instruction files before injection. This saves tokens but makes the in
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add `packages/prompt/project-instructions` with config for `dshHome`, `projectRootMarkers` (default `['.git']`), `baselineMaxBytes` (default `65536`), and `enableClaudeFallback` (default `true`). Include pure discovery/rendering helpers so the filesystem rules can be tested without Cordis.
|
||||
1. Add `packages/prompt/project-instructions` with config for `dshHome`, `projectRootMarkers` (default `['.git']`), `baselineMaxBytes` (default `65536`), and `instructionFileCandidates` (default `['AGENTS.md', 'CLAUDE.md']`). Include pure discovery/rendering helpers so the filesystem rules can be tested without Cordis.
|
||||
|
||||
2. Implement baseline `agent/request` injection in `dsh-project-instructions`. The listener computes the instruction block for `agent.session.header.cwd` or the stdio-only `process.cwd()` fallback, prepends one synthetic workspace-context message to the request messages, and returns the request through `next()`. It must never mutate shared global prompt sections or the provider system field. Implement nested `tools/post-execute` injection for successful structured file-tool touches, folding the new context onto any downstream `additionalContext`.
|
||||
|
||||
3. Load the plugin from `@deepseek-ai/dsh-agent-core` so both app packages receive it by default, and expose `projectInstructions` config through `agent-core`, `stdio-agent`, and `acp-agent`. Update `packages/README.md` and `docs/architecture.md` as part of the implementation. No generated Cordis catalog update is expected because the implementation adds no event or service.
|
||||
|
||||
4. Add tests: pure discovery order, `AGENTS.md` over `CLAUDE.md`, `$DSH_HOME` defaulting to `~/.dsh`, `.git` file and directory markers, no project-root overrun, no recursive startup scan, full-text rendering, budget truncation naming omitted/truncated paths, per-request discovery of new baseline files, content cache invalidation by signature, per-agent no-leak behavior with two agents in different cwd values, dynamic nested loading through the real file tools, duplicate suppression, and HMR/dispose cleanup.
|
||||
4. Add tests: pure discovery order, default `AGENTS.md` over `CLAUDE.md`, configurable candidate order, `$DSH_HOME` defaulting to `~/.dsh`, `.git` file and directory markers, no project-root overrun, no recursive startup scan, full-text rendering, budget truncation naming omitted/truncated paths, per-request discovery of new baseline files, content cache invalidation by signature, per-agent no-leak behavior with two agents in different cwd values, dynamic nested loading through the real file tools, duplicate suppression, and HMR/dispose cleanup.
|
||||
|
||||
5. Add request-shape coverage that proves the synthetic workspace-context message is present and lower in authority than the system field. Add a with-key e2e smoke test because the baseline change affects real model behavior but is not observable in replay snapshots. Snapshot coverage is not required for this phase unless the implementation also changes editor-visible transcript output.
|
||||
|
||||
@@ -130,7 +130,7 @@ Summarize instruction files before injection. This saves tokens but makes the in
|
||||
|
||||
Prompt growth is the main operational risk. Full-text loading is deliberate, but a large root `AGENTS.md` can consume context. The byte budget and explicit omitted/truncated file list make the behavior bounded and visible. The default should be generous enough for real project guidance but small enough to avoid surprising model-call cost.
|
||||
|
||||
Instruction conflicts are unavoidable when users keep both `AGENTS.md` and `CLAUDE.md`. The fallback rule keeps the conflict local and predictable: a native `AGENTS.md` suppresses `CLAUDE.md` in the same directory, while a directory with only `CLAUDE.md` still works.
|
||||
Instruction conflicts are unavoidable when users keep multiple configured instruction filenames in one directory. The first-existing candidate rule keeps the conflict local and predictable: with the default list, a native `AGENTS.md` suppresses `CLAUDE.md` in the same directory, while a directory with only `CLAUDE.md` still works.
|
||||
|
||||
Repository instructions are not necessarily trusted. The fenced workspace-context role, lower-authority wording, and refusal to put repo text in the provider system field reduce the risk, but they do not make prompt injection disappear. Future permission/sandbox work should continue to treat repo content as untrusted input.
|
||||
|
||||
@@ -142,4 +142,4 @@ Multi-session isolation is load-bearing. Any implementation that stores the rend
|
||||
|
||||
Bash-driven nested instruction loading is deferred. `dsh-tool-bash` should not be the first path-reporting consumer: parsing arbitrary shell commands for touched paths is brittle and would create false positives. If the product later needs bash-derived context, it should add an explicit path-reporting contract to the real execution surface and cover the resulting editor-visible context with snapshots.
|
||||
|
||||
Lowercase file names, `.claude/CLAUDE.md`, `.claude/rules/*.md`, local/private variants, import directives such as Reasonix/Claude-style `@path`, ACP `additionalDirectories`, file watching for changed instruction files, first-load trust acknowledgements, and model-generated summaries are also deferred. Each adds real semantics beyond the minimal compatibility contract and should land only after the native/fallback baseline proves itself.
|
||||
Lowercase file names by default, `.claude/CLAUDE.md`, `.claude/rules/*.md`, import directives such as Reasonix/Claude-style `@path`, ACP `additionalDirectories`, file watching for changed instruction files, first-load trust acknowledgements, and model-generated summaries are also deferred. Each adds real semantics beyond the minimal compatibility contract and should land only after the native/fallback baseline proves itself. Same-directory local/private variants can be opted into by setting `instructionFileCandidates`, but they are not part of the product default.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user