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:
@@ -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