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:
Turtle
2026-07-22 10:55:18 +08:00
parent 45868b940f
commit c8cc087e05
12 changed files with 309 additions and 26 deletions
@@ -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()