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

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