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