feat(workspace-context): load .local. instruction overlays by default
Load a per-directory local overlay in addition to the base instruction file, matching the Claude Code AGENTS.local.md / CLAUDE.local.md convention for git-ignored personal guidance. - New config `localInstructionFileCandidates`, default `['AGENTS.local.md', 'CLAUDE.local.md']`; empty disables the overlay. The default lives in the plugin Config schema, so every front door (TUI/ACP/headless) reads .local. files consistently. - Per project directory the plugin loads the first-existing base candidate, then additively the first-existing local candidate, rendered after the base so it takes precedence within the byte budget. - Base and local tiers get distinct scope keys via a NUL sentinel (scopeKey/decodeScopeKey) so they never collide in the baseline map, pending window, or version cache. - The fixed user-global $DSH_HOME/AGENTS.md stays base-only. Docs: README (config, lifecycle, Known Limitations), regenerated config-catalog, and a new bilingual Agent Note cross-linked to the owning workspace-context note. 100% per-file coverage retained.
This commit is contained in:
@@ -4,7 +4,7 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The
|
||||
|
||||
## Lifecycle
|
||||
|
||||
The baseline is composed once per agent-loop instance on `agent/session-prefix`. It reads `$DSH_HOME/AGENTS.md` followed by one configured instruction candidate in each directory from the project root to `agent.session.header.cwd`. The prefix is placed before all derived history, recorded in `EpochHeader.messagePrefix`, and reused verbatim for that loop instance. Because the plugin prepends its contribution before delegating, a later-registered skills catalog appears after workspace instructions.
|
||||
The baseline is composed once per agent-loop instance on `agent/session-prefix`. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, the first existing base candidate and then the first existing local-overlay candidate. The prefix is placed before all derived history, recorded in `EpochHeader.messagePrefix`, and reused verbatim for that loop instance. Because the plugin prepends its contribution before delegating, a later-registered skills catalog appears after workspace instructions.
|
||||
|
||||
The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. A new file is attached through the result's `additionalContexts`; a changed file or candidate switch appends a replacement; a missing final candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
|
||||
|
||||
@@ -61,12 +61,13 @@ export interface Config {
|
||||
maxBytes: number
|
||||
maxSourceBytes?: number
|
||||
instructionFileCandidates?: string[]
|
||||
localInstructionFileCandidates?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `maxSourceBytes` limits each source instruction file before rendering and defaults to 1 MiB. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory, the first existing candidate wins; with defaults, `AGENTS.md` is native and `CLAUDE.md` is the compatibility fallback. Candidate entries must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored.
|
||||
`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `maxSourceBytes` limits each source instruction file before rendering and defaults to 1 MiB. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory, the first existing candidate wins; with defaults, `AGENTS.md` is native and `CLAUDE.md` is the compatibility fallback. `localInstructionFileCandidates` defaults to `['AGENTS.local.md', 'CLAUDE.local.md']` and loads the first existing local overlay *in addition to* the base file of the same directory (rendered after it); an empty list disables the overlay. Candidate entries in both lists must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored.
|
||||
|
||||
The user-global file is always `$DSH_HOME/AGENTS.md`; the candidate list only controls project scopes. `$DSH_HOME` defaults to `~/.dsh`, and configured `~`, `~/...`, and Windows-style `~\...` prefixes are expanded against the operating-system home directory. A non-positive or non-finite render budget disables both baseline and dynamic loading; configured `maxSourceBytes` must be a positive integer.
|
||||
The user-global file is always `$DSH_HOME/AGENTS.md` with no local overlay; both candidate lists only control project scopes. `$DSH_HOME` defaults to `~/.dsh`, and configured `~`, `~/...`, and Windows-style `~\...` prefixes are expanded against the operating-system home directory. A non-positive or non-finite render budget disables both baseline and dynamic loading; configured `maxSourceBytes` must be a positive integer.
|
||||
|
||||
## Budgeting And Bounded Reads
|
||||
|
||||
@@ -160,5 +161,5 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
- **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam.
|
||||
- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, or when a resumed loop recomposes its prefix.
|
||||
- **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; same-directory names such as `CLAUDE.local.md` require explicit `instructionFileCandidates` configuration.
|
||||
- **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; project scopes load `AGENTS.local.md`/`CLAUDE.local.md` overlays by default, but the user-global `$DSH_HOME` scope has no local overlay and other custom names require explicit candidate configuration.
|
||||
- **Instruction content is bounded, not summarized** — over-budget broad files are omitted and the most-specific file may be truncated; the plugin never asks a model to compress instruction prose.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
|
||||
const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const
|
||||
const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const
|
||||
const DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.local.md', 'CLAUDE.local.md'] as const
|
||||
const DEFAULT_MAX_SOURCE_BYTES = 1_048_576
|
||||
const RESERVED_PATH_SEGMENTS = new Set(['', '.', '..'])
|
||||
|
||||
@@ -24,6 +25,8 @@ export interface Config {
|
||||
maxSourceBytes?: number
|
||||
/** Ordered same-directory project candidates; the first existing regular file wins in each scope. */
|
||||
instructionFileCandidates?: string[]
|
||||
/** Ordered same-directory local-overlay candidates loaded in addition to the base file per scope; empty disables the overlay. */
|
||||
localInstructionFileCandidates?: string[]
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
@@ -32,6 +35,7 @@ export const Config: z<Config> = z.object({
|
||||
maxBytes: z.number().required(),
|
||||
maxSourceBytes: z.number().step(1).min(1).default(DEFAULT_MAX_SOURCE_BYTES),
|
||||
instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]),
|
||||
localInstructionFileCandidates: z.array(z.string()).default([...DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES]),
|
||||
})
|
||||
|
||||
/** Normalized instruction discovery configuration. */
|
||||
@@ -39,6 +43,7 @@ export interface ResolvedDiscoveryConfig {
|
||||
dshHome: string
|
||||
projectRootMarkers: string[]
|
||||
instructionFileCandidates: string[]
|
||||
localInstructionFileCandidates: string[]
|
||||
}
|
||||
|
||||
/** Normalized configuration used by discovery and reconciliation. */
|
||||
@@ -66,17 +71,24 @@ export function resolveConfig(config: Config): ResolvedConfig {
|
||||
* @returns normalized home, root markers, and instruction candidates.
|
||||
*/
|
||||
export function resolveDiscoveryConfig(
|
||||
config: Pick<Config, 'dshHome' | 'projectRootMarkers' | 'instructionFileCandidates'>,
|
||||
config: Pick<Config, 'dshHome' | 'projectRootMarkers' | 'instructionFileCandidates' | 'localInstructionFileCandidates'>,
|
||||
): ResolvedDiscoveryConfig {
|
||||
return {
|
||||
dshHome: resolveDshHome(config.dshHome),
|
||||
projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
|
||||
instructionFileCandidates: resolveInstructionFileCandidates(config.instructionFileCandidates),
|
||||
instructionFileCandidates: resolveInstructionFileCandidates(
|
||||
config.instructionFileCandidates,
|
||||
DEFAULT_INSTRUCTION_FILE_CANDIDATES,
|
||||
),
|
||||
localInstructionFileCandidates: resolveInstructionFileCandidates(
|
||||
config.localInstructionFileCandidates,
|
||||
DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveInstructionFileCandidates(candidates: string[] | undefined): string[] {
|
||||
return (candidates ?? [...DEFAULT_INSTRUCTION_FILE_CANDIDATES]).filter(candidate => (
|
||||
function resolveInstructionFileCandidates(candidates: string[] | undefined, fallback: readonly string[]): string[] {
|
||||
return (candidates ?? [...fallback]).filter(candidate => (
|
||||
!RESERVED_PATH_SEGMENTS.has(candidate) && !/[\\/]/.test(candidate)
|
||||
))
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { FileSystem, FsInfo, FsPathInfo, FsTarget, FsVersion } from '@deeps
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { dshHomeDisplay } from '@deepseek-ai/dsh-paths'
|
||||
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
|
||||
import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts'
|
||||
import { decodeScopeKey, renderWorkspaceContext, type InstructionTier, type RenderedWorkspaceContext } from './render.ts'
|
||||
|
||||
/** An instruction candidate identified by absolute and model-facing paths. */
|
||||
export interface InstructionFile {
|
||||
@@ -24,12 +24,15 @@ export interface LoadedInstructionFile extends InstructionFile {
|
||||
content: string
|
||||
/** Provider freshness token when the file was loaded through `ctx.fs`. */
|
||||
version?: FsVersion
|
||||
/** Base file or additive local overlay; absent is treated as base. */
|
||||
tier?: InstructionTier
|
||||
}
|
||||
|
||||
interface DiscoveredInstructionFile extends InstructionFile {
|
||||
target?: FsTarget
|
||||
size?: number
|
||||
version?: FsVersion
|
||||
tier: InstructionTier
|
||||
}
|
||||
|
||||
/** Provider metadata for a winning scope candidate before its content is read. */
|
||||
@@ -44,6 +47,7 @@ interface DiscoverOptions {
|
||||
dshHome?: string
|
||||
projectRootMarkers?: string[]
|
||||
instructionFileCandidates?: string[]
|
||||
localInstructionFileCandidates?: string[]
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
@@ -236,6 +240,7 @@ async function firstExistingInstructionFile(
|
||||
dir: string,
|
||||
root: string,
|
||||
instructionFileCandidates: readonly string[],
|
||||
tier: InstructionTier,
|
||||
fileSystem?: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DiscoveredInstructionFile | undefined> {
|
||||
@@ -247,6 +252,7 @@ async function firstExistingInstructionFile(
|
||||
return {
|
||||
absolutePath: path,
|
||||
displayPath: relativeDisplay(root, path),
|
||||
tier,
|
||||
...probe.info,
|
||||
}
|
||||
case 'absent':
|
||||
@@ -281,6 +287,7 @@ async function discoverInstructionFiles(
|
||||
addFile({
|
||||
absolutePath: userGlobal,
|
||||
displayPath: userGlobalDisplayPath(config.dshHome),
|
||||
tier: 'base',
|
||||
...userGlobalProbe.info,
|
||||
})
|
||||
break
|
||||
@@ -295,8 +302,12 @@ async function discoverInstructionFiles(
|
||||
const cwd = resolve(options.cwd)
|
||||
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal)
|
||||
for (const dir of ancestorChain(projectRoot, cwd)) {
|
||||
const file = await firstExistingInstructionFile(dir, projectRoot, config.instructionFileCandidates, fileSystem, options.signal)
|
||||
if (file !== undefined) addFile(file)
|
||||
const base = await firstExistingInstructionFile(dir, projectRoot, config.instructionFileCandidates, 'base', fileSystem, options.signal)
|
||||
if (base !== undefined) addFile(base)
|
||||
if (config.localInstructionFileCandidates.length > 0) {
|
||||
const local = await firstExistingInstructionFile(dir, projectRoot, config.localInstructionFileCandidates, 'local', fileSystem, options.signal)
|
||||
if (local !== undefined) addFile(local)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
@@ -316,7 +327,7 @@ async function* nodeTextChunks(path: string, signal?: AbortSignal): AsyncIterabl
|
||||
}
|
||||
|
||||
async function readBounded(
|
||||
file: DiscoveredInstructionFile,
|
||||
file: { absolutePath: string; target?: FsTarget; size?: number },
|
||||
maxSourceBytes: number,
|
||||
fileSystem?: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
@@ -382,6 +393,7 @@ export async function loadBaselineInstructionSet(
|
||||
absolutePath: file.absolutePath,
|
||||
displayPath: file.displayPath,
|
||||
content,
|
||||
tier: file.tier,
|
||||
...file.version === undefined ? {} : { version: file.version },
|
||||
})
|
||||
}
|
||||
@@ -394,7 +406,7 @@ export async function loadBaselineInstructionSet(
|
||||
|
||||
/**
|
||||
* Probe the current first-winning instruction candidate for one logical scope.
|
||||
* @param scope - `user-global`, `.`, or a project-relative directory.
|
||||
* @param scope - `user-global`, or a {@link scopeKey} for a project directory's base or local tier.
|
||||
* @param projectRoot - project root used to resolve and display project scopes.
|
||||
* @param resolved - normalized plugin configuration.
|
||||
* @param fileSystem - provider used for no-follow probing.
|
||||
@@ -408,10 +420,13 @@ export async function probeScopeInstruction(
|
||||
fileSystem: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ScopeInstructionProbe> {
|
||||
const dir = scope === 'user-global'
|
||||
const { directory, tier } = decodeScopeKey(scope)
|
||||
const dir = directory === 'user-global'
|
||||
? resolved.dshHome
|
||||
: scope === '.' ? projectRoot : join(projectRoot, scope)
|
||||
const candidates = scope === 'user-global' ? ['AGENTS.md'] : resolved.instructionFileCandidates
|
||||
: directory === '.' ? projectRoot : join(projectRoot, directory)
|
||||
const candidates = directory === 'user-global'
|
||||
? ['AGENTS.md']
|
||||
: tier === 'local' ? resolved.localInstructionFileCandidates : resolved.instructionFileCandidates
|
||||
for (const candidate of candidates) {
|
||||
const absolutePath = join(dir, candidate)
|
||||
let pathInfo: FsPathInfo | undefined
|
||||
@@ -434,7 +449,7 @@ export async function probeScopeInstruction(
|
||||
if (info?.type !== 'file') return { kind: 'unavailable' }
|
||||
const file: ProbedInstructionFile = {
|
||||
absolutePath,
|
||||
displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
|
||||
displayPath: directory === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
|
||||
target,
|
||||
version: info.version,
|
||||
...info.size === undefined ? {} : { size: info.size },
|
||||
|
||||
@@ -74,6 +74,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
maxBytes: resolved.maxBytes,
|
||||
maxSourceBytes: resolved.maxSourceBytes,
|
||||
instructionFileCandidates: resolved.instructionFileCandidates,
|
||||
localInstructionFileCandidates: resolved.localInstructionFileCandidates,
|
||||
signal,
|
||||
}, fileSystem)
|
||||
const baseline = baselineInstructionState(instructions?.included ?? [])
|
||||
|
||||
@@ -81,6 +81,35 @@ export function scopeForDisplayPath(displayPath: string): string {
|
||||
return dirname(displayPath)
|
||||
}
|
||||
|
||||
/** Instruction tier: the native base file or the additive local overlay. */
|
||||
export type InstructionTier = 'base' | 'local'
|
||||
|
||||
const LOCAL_SCOPE_SUFFIX = '\u0000local'
|
||||
|
||||
/**
|
||||
* Compose the reconciliation key for a directory scope and instruction tier.
|
||||
* The base tier keeps the human-readable directory; the local overlay appends a
|
||||
* NUL-delimited marker that no directory path can contain, so a directory's base
|
||||
* and local files never collide in the scope-keyed state maps.
|
||||
* @param directory - `user-global`, `.`, or a project-relative directory.
|
||||
* @param tier - base file or additive local overlay.
|
||||
* @returns the collision-free logical scope key.
|
||||
*/
|
||||
export function scopeKey(directory: string, tier: InstructionTier): string {
|
||||
return tier === 'local' ? `${directory}${LOCAL_SCOPE_SUFFIX}` : directory
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the directory and tier that {@link scopeKey} encoded.
|
||||
* @param scope - a base or local scope key.
|
||||
* @returns the directory scope and its instruction tier.
|
||||
*/
|
||||
export function decodeScopeKey(scope: string): { directory: string; tier: InstructionTier } {
|
||||
return scope.endsWith(LOCAL_SCOPE_SUFFIX)
|
||||
? { directory: scope.slice(0, -LOCAL_SCOPE_SUFFIX.length), tier: 'local' }
|
||||
: { directory: scope, tier: 'base' }
|
||||
}
|
||||
|
||||
function additionalSectionText(file: LoadedInstructionFile): string {
|
||||
const scope = scopeForDisplayPath(file.displayPath)
|
||||
return [
|
||||
@@ -102,7 +131,7 @@ function changedSectionText(item: ChangeRenderItem): string {
|
||||
}
|
||||
const description = change.previousPath === undefined
|
||||
? 'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.'
|
||||
: `The instructions previously loaded from \`${change.previousPath}\` no longer apply. Use the following content for \`${change.scope}\` instead.`
|
||||
: `The instructions previously loaded from \`${change.previousPath}\` no longer apply. Use the following content for \`${scopeForDisplayPath(change.path)}\` instead.`
|
||||
return [
|
||||
`Updated instructions from: ${change.path}`,
|
||||
'',
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import {
|
||||
renderInstructionChanges,
|
||||
scopeForDisplayPath,
|
||||
scopeKey,
|
||||
type ChangeRenderItem,
|
||||
type WorkspaceInstructionChange,
|
||||
} from './render.ts'
|
||||
@@ -169,7 +170,7 @@ export function baselineInstructionState(files: LoadedInstructionFile[]): {
|
||||
const digest = instructionContentSha1(file.content)
|
||||
const change: WorkspaceInstructionChange = {
|
||||
action: 'set',
|
||||
scope: scopeForDisplayPath(file.displayPath),
|
||||
scope: scopeKey(scopeForDisplayPath(file.displayPath), file.tier ?? 'base'),
|
||||
path: file.displayPath,
|
||||
digest,
|
||||
}
|
||||
@@ -391,13 +392,19 @@ export async function reconcileInstructionContext(
|
||||
// recomputing it after marker edits reinterprets the existing relative scope keys.
|
||||
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
|
||||
const scopes = new Set<string>()
|
||||
const localEnabled = resolved.localInstructionFileCandidates.length > 0
|
||||
const addProjectScopes = (dir: string): void => {
|
||||
const scope = relativeScope(projectRoot, dir)
|
||||
scopes.add(scope)
|
||||
if (localEnabled) scopes.add(scopeKey(scope, 'local'))
|
||||
}
|
||||
if (options.includeBaselineScopes) {
|
||||
scopes.add('user-global')
|
||||
for (const dir of ancestorChain(projectRoot, cwd)) scopes.add(relativeScope(projectRoot, dir))
|
||||
for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(dir)
|
||||
}
|
||||
for (const scope of effective.keys()) scopes.add(scope)
|
||||
if (options.touchedPath !== undefined) {
|
||||
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) scopes.add(relativeScope(projectRoot, dir))
|
||||
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(dir)
|
||||
}
|
||||
|
||||
const versions = versionStatesFor(session, versionCache)
|
||||
|
||||
@@ -311,6 +311,52 @@ describe('workspace context instruction discovery', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('loads a same-directory local overlay in addition to the base file by default', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
const cwd = join(root, 'pkg')
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'root base')
|
||||
await write(join(root, 'AGENTS.local.md'), 'root local')
|
||||
await write(join(cwd, 'CLAUDE.md'), 'pkg base')
|
||||
await write(join(cwd, 'CLAUDE.local.md'), 'pkg local')
|
||||
|
||||
const files = await discoverBaselineInstructionFiles({ cwd, dshHome: home })
|
||||
|
||||
expect(files.map(file => file.displayPath)).toEqual([
|
||||
'AGENTS.md',
|
||||
'AGENTS.local.md',
|
||||
'pkg/CLAUDE.md',
|
||||
'pkg/CLAUDE.local.md',
|
||||
])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('loads no local overlay when localInstructionFileCandidates is empty', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'base rule')
|
||||
await write(join(root, 'AGENTS.local.md'), 'local rule')
|
||||
|
||||
const files = await discoverBaselineInstructionFiles({
|
||||
cwd: root,
|
||||
dshHome: home,
|
||||
localInstructionFileCandidates: [],
|
||||
})
|
||||
|
||||
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('treats a .git file as a project root marker and does not search above it', async () => {
|
||||
const outer = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
@@ -1459,6 +1505,27 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('renders a default local overlay alongside the base file in the baseline prefix', async () => {
|
||||
const root = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'base rule')
|
||||
await write(join(root, 'AGENTS.local.md'), 'local rule')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalFileSystem, { cwd: '/' })
|
||||
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md\n\nbase rule')
|
||||
expect(derivedText(agent)).toContain('Instructions from: AGENTS.local.md\n\nlocal rule')
|
||||
await ctx.fiber.dispose()
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('cleans up its agent/session-prefix listener when the plugin fiber is disposed', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
@@ -1818,6 +1885,75 @@ describe('dynamic nested workspace context injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('attaches a nested base file and its local overlay together by default', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'baseline root rule')
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'nested base rule')
|
||||
await write(join(root, 'pkg/AGENTS.local.md'), 'nested local rule')
|
||||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('read-nested-overlay'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
agent: stubAgent(root),
|
||||
})
|
||||
|
||||
const meta = workspaceContextOf(result)?.meta
|
||||
const changes = typeof meta === 'object' && meta !== null && !Array.isArray(meta) && Array.isArray(meta.changes)
|
||||
? meta.changes
|
||||
: []
|
||||
expect(changes).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ action: 'set', path: 'pkg/AGENTS.md' }),
|
||||
expect.objectContaining({ action: 'set', path: 'pkg/AGENTS.local.md' }),
|
||||
]))
|
||||
const text = blocksText(workspaceContextOf(result)?.content)
|
||||
expect(text).toContain('Additional instructions from: pkg/AGENTS.md')
|
||||
expect(text).toContain('nested base rule')
|
||||
expect(text).toContain('Additional instructions from: pkg/AGENTS.local.md')
|
||||
expect(text).toContain('nested local rule')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not attach a nested local overlay when the overlay is disabled', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'nested base rule')
|
||||
await write(join(root, 'pkg/AGENTS.local.md'), 'nested local rule')
|
||||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, {
|
||||
dshHome: home,
|
||||
maxBytes: 65536,
|
||||
localInstructionFileCandidates: [],
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('read-nested-overlay-disabled'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
agent: stubAgent(root),
|
||||
})
|
||||
|
||||
const text = blocksText(workspaceContextOf(result)?.content)
|
||||
expect(text).toContain('Additional instructions from: pkg/AGENTS.md')
|
||||
expect(text).not.toContain('pkg/AGENTS.local.md')
|
||||
} 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