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