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

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

View File

@@ -11,7 +11,7 @@ import type { FileSystem, FsInfo, FsPathInfo, FsTarget, FsVersion } from '@deeps
import { assertNever } from '@deepseek-ai/dsh-llm'
import { dshHomeDisplay } from '@deepseek-ai/dsh-paths'
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts'
import { decodeScopeKey, renderWorkspaceContext, type InstructionTier, type RenderedWorkspaceContext } from './render.ts'
/** An instruction candidate identified by absolute and model-facing paths. */
export interface InstructionFile {
@@ -24,12 +24,15 @@ export interface LoadedInstructionFile extends InstructionFile {
content: string
/** Provider freshness token when the file was loaded through `ctx.fs`. */
version?: FsVersion
/** Base file or additive local overlay; absent is treated as base. */
tier?: InstructionTier
}
interface DiscoveredInstructionFile extends InstructionFile {
target?: FsTarget
size?: number
version?: FsVersion
tier: InstructionTier
}
/** Provider metadata for a winning scope candidate before its content is read. */
@@ -44,6 +47,7 @@ interface DiscoverOptions {
dshHome?: string
projectRootMarkers?: string[]
instructionFileCandidates?: string[]
localInstructionFileCandidates?: string[]
signal?: AbortSignal
}
@@ -236,6 +240,7 @@ async function firstExistingInstructionFile(
dir: string,
root: string,
instructionFileCandidates: readonly string[],
tier: InstructionTier,
fileSystem?: FileSystem,
signal?: AbortSignal,
): Promise<DiscoveredInstructionFile | undefined> {
@@ -247,6 +252,7 @@ async function firstExistingInstructionFile(
return {
absolutePath: path,
displayPath: relativeDisplay(root, path),
tier,
...probe.info,
}
case 'absent':
@@ -281,6 +287,7 @@ async function discoverInstructionFiles(
addFile({
absolutePath: userGlobal,
displayPath: userGlobalDisplayPath(config.dshHome),
tier: 'base',
...userGlobalProbe.info,
})
break
@@ -295,8 +302,12 @@ async function discoverInstructionFiles(
const cwd = resolve(options.cwd)
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal)
for (const dir of ancestorChain(projectRoot, cwd)) {
const file = await firstExistingInstructionFile(dir, projectRoot, config.instructionFileCandidates, fileSystem, options.signal)
if (file !== undefined) addFile(file)
const base = await firstExistingInstructionFile(dir, projectRoot, config.instructionFileCandidates, 'base', fileSystem, options.signal)
if (base !== undefined) addFile(base)
if (config.localInstructionFileCandidates.length > 0) {
const local = await firstExistingInstructionFile(dir, projectRoot, config.localInstructionFileCandidates, 'local', fileSystem, options.signal)
if (local !== undefined) addFile(local)
}
}
return files
}
@@ -316,7 +327,7 @@ async function* nodeTextChunks(path: string, signal?: AbortSignal): AsyncIterabl
}
async function readBounded(
file: DiscoveredInstructionFile,
file: { absolutePath: string; target?: FsTarget; size?: number },
maxSourceBytes: number,
fileSystem?: FileSystem,
signal?: AbortSignal,
@@ -382,6 +393,7 @@ export async function loadBaselineInstructionSet(
absolutePath: file.absolutePath,
displayPath: file.displayPath,
content,
tier: file.tier,
...file.version === undefined ? {} : { version: file.version },
})
}
@@ -394,7 +406,7 @@ export async function loadBaselineInstructionSet(
/**
* Probe the current first-winning instruction candidate for one logical scope.
* @param scope - `user-global`, `.`, or a project-relative directory.
* @param scope - `user-global`, or a {@link scopeKey} for a project directory's base or local tier.
* @param projectRoot - project root used to resolve and display project scopes.
* @param resolved - normalized plugin configuration.
* @param fileSystem - provider used for no-follow probing.
@@ -408,10 +420,13 @@ export async function probeScopeInstruction(
fileSystem: FileSystem,
signal?: AbortSignal,
): Promise<ScopeInstructionProbe> {
const dir = scope === 'user-global'
const { directory, tier } = decodeScopeKey(scope)
const dir = directory === 'user-global'
? resolved.dshHome
: scope === '.' ? projectRoot : join(projectRoot, scope)
const candidates = scope === 'user-global' ? ['AGENTS.md'] : resolved.instructionFileCandidates
: directory === '.' ? projectRoot : join(projectRoot, directory)
const candidates = directory === 'user-global'
? ['AGENTS.md']
: tier === 'local' ? resolved.localInstructionFileCandidates : resolved.instructionFileCandidates
for (const candidate of candidates) {
const absolutePath = join(dir, candidate)
let pathInfo: FsPathInfo | undefined
@@ -434,7 +449,7 @@ export async function probeScopeInstruction(
if (info?.type !== 'file') return { kind: 'unavailable' }
const file: ProbedInstructionFile = {
absolutePath,
displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
displayPath: directory === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
target,
version: info.version,
...info.size === undefined ? {} : { size: info.size },

View File

@@ -74,6 +74,7 @@ export function apply(ctx: Context, config: Config): void {
maxBytes: resolved.maxBytes,
maxSourceBytes: resolved.maxSourceBytes,
instructionFileCandidates: resolved.instructionFileCandidates,
localInstructionFileCandidates: resolved.localInstructionFileCandidates,
signal,
}, fileSystem)
const baseline = baselineInstructionState(instructions?.included ?? [])

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

View File

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