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

View File

@@ -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}`,
'',