diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index a3eec63b8f..54767cf46d 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -18,11 +18,11 @@ The plugin does not statically inject `fs`. Providerless product trees therefore ### File Names And Precedence -The default per-directory candidate list is `['AGENTS.md', 'CLAUDE.md']`. The list is configurable as `instructionFileCandidates`, and `AGENTS.md` is an ordinary first candidate rather than a hidden priority. In one directory, only the first existing regular-file candidate loads. With defaults, `AGENTS.md` is native and `CLAUDE.md` is a compatibility fallback. +The default per-directory candidate list is `['AGENTS.md', 'CLAUDE.md']`. The list is configurable as `instructionFileCandidates`, and `AGENTS.md` is an ordinary first candidate rather than a hidden priority. In one directory, only the first existing regular-file candidate loads. With defaults, `AGENTS.md` is native and `CLAUDE.md` is a compatibility fallback. A second list, `localInstructionFileCandidates` (default `['AGENTS.local.md', 'CLAUDE.local.md']`), loads an additive local overlay after the base file in the same directory; the [default local overlay](2026-07-21-local-instruction-overlay.md) owns that decision. -Candidate entries are same-directory file names. Empty entries, `.`/`..`, and entries containing `/` or `\` are ignored. Lowercase names, local variants, and other same-directory names can be opted into explicitly; rule directories and import semantics are outside this contract. +Candidate entries are same-directory file names. Empty entries, `.`/`..`, and entries containing `/` or `\` are ignored. Other same-directory names can be opted into explicitly; rule directories and import semantics are outside this contract. -The user-global file is fixed at `$DSH_HOME/AGENTS.md` and is not affected by `instructionFileCandidates`. `$DSH_HOME` defaults to `~/.dsh`, matching the harness-level home role of `~/.codex` or `~/.claude` rather than introducing a plugin-specific home. Tilde expansion and the default live in `dsh-paths` so future harness features share the same convention. +The user-global file is fixed at `$DSH_HOME/AGENTS.md`, is not affected by either candidate list, and has no local overlay. `$DSH_HOME` defaults to `~/.dsh`, matching the harness-level home role of `~/.codex` or `~/.claude` rather than introducing a plugin-specific home. Tilde expansion and the default live in `dsh-paths` so future harness features share the same convention. ### Baseline Prefix @@ -84,4 +84,4 @@ The system is event-driven rather than watch-driven. Edits are not visible at th ## Deferred -Bash-derived path reporting, recursive startup scans, file watchers, lowercase defaults, `.claude/CLAUDE.md`, `.claude/rules/*.md`, import directives, ACP `additionalDirectories`, trust acknowledgements, and model-generated summaries are deferred. Same-directory private variants can be configured today; directory rule systems and imports need their own precedence and trust designs. +Bash-derived path reporting, recursive startup scans, file watchers, lowercase defaults, `.claude/CLAUDE.md`, `.claude/rules/*.md`, import directives, ACP `additionalDirectories`, trust acknowledgements, and model-generated summaries are deferred. Project-directory `.local.` overlays now load by default (the [default local overlay](2026-07-21-local-instruction-overlay.md) owns that decision); a user-global overlay, directory rule systems, and imports still need their own precedence and trust designs. diff --git a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml new file mode 100644 index 0000000000..ebfc3c5203 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-21-local-instruction-overlay.md: bb413455d453e2a1eb445cae5e4d7193e7939dc8 +2026-07-21-local-instruction-overlay.zh.md: c7447a8f1b2c9eae649225f8d54a03fc671c230a diff --git a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md new file mode 100644 index 0000000000..bb413455d4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md @@ -0,0 +1,37 @@ +# Agent Note: Default local instruction overlay + +Status: implemented + +English | [中文](2026-07-21-local-instruction-overlay.zh.md) + +## Problem + +Personal, git-ignored guidance (`AGENTS.local.md` / `CLAUDE.local.md`) is a Claude Code convention for per-developer overrides that are deliberately not committed. The [workspace-context plugin](2026-06-24-workspace-context.md) loaded only one candidate per directory, so a `.local.` name could only be reached by adding it to `instructionFileCandidates`, where — because a directory has one winner — it would *shadow* the committed base file instead of supplementing it. That inverts the additive "base plus personal overlay" model the names evoke, and it was off by default. + +## Decision + +The plugin loads a second, independent candidate list per project directory. `localInstructionFileCandidates` defaults to `['AGENTS.local.md', 'CLAUDE.local.md']` and is resolved with the same same-directory validation as `instructionFileCandidates`. In every project directory from the root to the session cwd, the plugin loads the first existing base candidate and then, additively, the first existing local candidate; the local file is ordered after the base file so its guidance takes precedence within the byte budget. An empty `localInstructionFileCandidates` disables the overlay. + +The default lives in the plugin `Config` schema rather than a product `cordis.yml`, so every embedder (TUI, ACP, headless) reads `.local.` files consistently and a deployment overrides or disables the behavior in one place. This is symmetric with the plugin-owned `instructionFileCandidates` default. + +The fixed user-global `$DSH_HOME/AGENTS.md` has no local overlay and stays base-only. + +## Tiered scope keys + +Each directory now yields up to two logical scopes that share a path but must stay independent across baseline freezing, the pending window, the version cache, and reconciliation. `render.ts` encodes the tier into the scope key: the base tier keeps the bare directory scope (`.`, `pkg`, `user-global`) and the local tier appends a NUL sentinel (`\u0000local`) that cannot occur in a real path. `scopeKey`/`decodeScopeKey` own the encoding. `DiscoveredInstructionFile` carries a required `tier` and `LoadedInstructionFile` an optional one (absent means base); discovery tags each file, `baselineInstructionState` derives the tiered key from directory plus tier, `reconcileInstructionContext` enumerates both tiers per project directory when the local list is non-empty, and `probeScopeInstruction` decodes the key to pick the base or local candidate list. The model-facing prompt derives its human directory label from the file display path, so the sentinel never reaches the model, and existing dedup by absolute path still collapses base and local lists that resolve to the same file. + +## Alternatives considered + +**Higher-priority first-wins (`.local.` loaded instead of the base file).** Rejected: a personal overlay that replaces the committed file drops shared project guidance whenever the overlay exists, which is the opposite of the additive Claude Code model. + +**Keep it opt-in through `instructionFileCandidates`.** Rejected: one directory has a single winner, so a `.local.` name added to that list shadows the base file rather than supplementing it. The packages guidance to keep opt-ins out of shipped defaults is outweighed here by strong prior art and the user-facing expectation that `.local.` files are always read. + +**Default at the product `cordis.yml` level instead of the plugin schema.** Rejected: it would enable `.local.` only for whichever front door remembered to opt in, splitting behavior across TUI/ACP/headless and duplicating a value that belongs beside the existing candidate default. + +**Reuse the bare directory as the scope key for both tiers.** Rejected: base and local files in one directory would collide in every scope-keyed map, so a change to one would suppress or overwrite the other. A sentinel-suffixed key keeps the tiers independent without widening the persisted metadata shape. + +**Extend the overlay to the user-global scope.** Deferred: `$DSH_HOME` is a single fixed `AGENTS.md` with no committed base to supplement, so it stays base-only until a concrete need appears. + +## Consequences + +`.local.` guidance is read by default across all products with no per-deployment configuration, matching neighboring tools. Each project directory can contribute two durable scopes instead of one, so dynamic discovery, edits, and removals reconcile the base and local tiers independently. The scope-key shape changed to carry the tier; `dsh-session` keeps no compatibility promise for older sessions, so this is a free change. The user-global scope remains base-only, recorded as a Known Limitation in the package README. diff --git a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.zh.md b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.zh.md new file mode 100644 index 0000000000..c7447a8f1b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 默认的本地指令覆盖层 + +Status: implemented + +[English](2026-07-21-local-instruction-overlay.md) | 中文 + +## 问题 + +个人的、被 git 忽略的指导文件(`AGENTS.local.md` / `CLAUDE.local.md`)是 Claude Code 的一项约定,用于存放刻意不提交、每位开发者各自的覆盖内容。[workspace-context 插件](2026-06-24-workspace-context.md)每个目录只加载一个候选,因此只有把某个 `.local.` 名字加进 `instructionFileCandidates` 才能读到它;而由于一个目录只有一个胜出者,这样做只会让它*遮蔽*已提交的基础文件,而不是补充它。这与这些名字所暗示的「基础文件加个人覆盖层」的叠加模型正好相反,而且它默认是关闭的。 + +## 决策 + +插件为每个项目目录额外加载第二个独立的候选列表。`localInstructionFileCandidates` 默认为 `['AGENTS.local.md', 'CLAUDE.local.md']`,并与 `instructionFileCandidates` 采用相同的同目录校验来解析。在从项目根到会话 cwd 的每个项目目录中,插件先加载第一个存在的基础候选,然后叠加加载第一个存在的本地候选;本地文件排在基础文件之后,因此在字节预算之内其内容优先级更高。将 `localInstructionFileCandidates` 置空即可关闭该覆盖层。 + +该默认值定义在插件的 `Config` schema 中,而非某个产品的 `cordis.yml` 里,因此每个嵌入方(TUI、ACP、headless)读取 `.local.` 文件的行为一致,部署方也可以在一处覆盖或关闭该行为。这与插件自身持有的 `instructionFileCandidates` 默认值保持对称。 + +固定的用户全局文件 `$DSH_HOME/AGENTS.md` 没有本地覆盖层,始终只有基础文件。 + +## 分层的 scope 键 + +现在每个目录最多产生两个逻辑 scope,它们共享同一路径,但在基线冻结、待定窗口、版本缓存和协调过程中都必须彼此独立。`render.ts` 把层级编码进 scope 键:基础层级保留原始的目录 scope(`.`、`pkg`、`user-global`),本地层级则追加一个真实路径中不可能出现的 NUL 哨兵(`\u0000local`)。`scopeKey`/`decodeScopeKey` 负责这套编码。`DiscoveredInstructionFile` 携带必填的 `tier`,`LoadedInstructionFile` 携带可选的 `tier`(缺省即表示基础层级);发现过程为每个文件打上标记,`baselineInstructionState` 由目录加层级推导出分层的键,`reconcileInstructionContext` 在本地列表非空时为每个项目目录枚举两个层级,`probeScopeInstruction` 则解码该键以选取基础或本地候选列表。面向模型的提示词从文件的展示路径推导出供人阅读的目录标签,因此哨兵永远不会到达模型;而既有的按绝对路径去重仍会把解析到同一文件的基础列表与本地列表合并为一个。 + +## 备选方案 + +**更高优先级的先到先得(加载 `.local.` 而非基础文件)。** 否决:一个会替换已提交文件的个人覆盖层,会在覆盖层存在时丢弃共享的项目指导,这与 Claude Code 的叠加模型正好相反。 + +**通过 `instructionFileCandidates` 保持按需开启。** 否决:一个目录只有一个胜出者,因此加进该列表的 `.local.` 名字会遮蔽基础文件,而非补充它。packages 指引要求把按需开启项排除在出厂默认之外,但此处强有力的现有实践、以及用户对 `.local.` 文件总会被读取的预期,压过了这一考量。 + +**在产品 `cordis.yml` 层面设默认,而非在插件 schema 中。** 否决:这样只会为记得开启的那个前门启用 `.local.`,从而在 TUI/ACP/headless 之间割裂行为,并重复一个本应与既有候选默认值放在一起的取值。 + +**两个层级复用原始目录作为 scope 键。** 否决:同一目录下的基础文件与本地文件会在每个以 scope 为键的映射中冲突,于是对其中一个的改动会抑制或覆盖另一个。带哨兵后缀的键让两个层级保持独立,且无需扩展持久化的元数据结构。 + +**将覆盖层扩展到用户全局 scope。** 暂缓:`$DSH_HOME` 是单个固定的 `AGENTS.md`,没有可供补充的已提交基础文件,因此在出现具体需求前始终只有基础文件。 + +## 影响 + +`.local.` 指导在所有产品中默认被读取,无需按部署单独配置,与邻近工具保持一致。每个项目目录可以贡献两个持久 scope 而非一个,因此动态发现、编辑和移除会分别独立地协调基础层级与本地层级。scope 键的形态发生了变化以携带层级;`dsh-session` 对旧会话不作兼容承诺,因此这是一次无成本的改动。用户全局 scope 仍然只有基础文件,这一点作为 Known Limitation 记录在包 README 中。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 496c254ef3..21ea4ff7ad 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1728,10 +1728,12 @@ 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[] } ``` -Source: [`packages/context/workspace-context/src/config.ts:16`](../packages/context/workspace-context/src/config.ts) +Source: [`packages/context/workspace-context/src/config.ts:17`](../packages/context/workspace-context/src/config.ts) ## Loadable plugins with no config diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index 7dd1e06931..17c0f7fc59 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -4,7 +4,7 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The ## Lifecycle -The baseline is composed once per agent-loop instance on `agent/session-prefix`. It reads `$DSH_HOME/AGENTS.md` followed by one configured instruction candidate in each directory from the project root to `agent.session.header.cwd`. The prefix is placed before all derived history, recorded in `EpochHeader.messagePrefix`, and reused verbatim for that loop instance. Because the plugin prepends its contribution before delegating, a later-registered skills catalog appears after workspace instructions. +The baseline is composed once per agent-loop instance on `agent/session-prefix`. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, the first existing base candidate and then the first existing local-overlay candidate. The prefix is placed before all derived history, recorded in `EpochHeader.messagePrefix`, and reused verbatim for that loop instance. Because the plugin prepends its contribution before delegating, a later-registered skills catalog appears after workspace instructions. The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. A new file is attached through the result's `additionalContexts`; a changed file or candidate switch appends a replacement; a missing final candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable. @@ -61,12 +61,13 @@ export interface Config { maxBytes: number maxSourceBytes?: number instructionFileCandidates?: string[] + localInstructionFileCandidates?: string[] } ``` -`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `maxSourceBytes` limits each source instruction file before rendering and defaults to 1 MiB. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory, the first existing candidate wins; with defaults, `AGENTS.md` is native and `CLAUDE.md` is the compatibility fallback. Candidate entries must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored. +`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `maxSourceBytes` limits each source instruction file before rendering and defaults to 1 MiB. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory, the first existing candidate wins; with defaults, `AGENTS.md` is native and `CLAUDE.md` is the compatibility fallback. `localInstructionFileCandidates` defaults to `['AGENTS.local.md', 'CLAUDE.local.md']` and loads the first existing local overlay *in addition to* the base file of the same directory (rendered after it); an empty list disables the overlay. Candidate entries in both lists must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored. -The user-global file is always `$DSH_HOME/AGENTS.md`; the candidate list only controls project scopes. `$DSH_HOME` defaults to `~/.dsh`, and configured `~`, `~/...`, and Windows-style `~\...` prefixes are expanded against the operating-system home directory. A non-positive or non-finite render budget disables both baseline and dynamic loading; configured `maxSourceBytes` must be a positive integer. +The user-global file is always `$DSH_HOME/AGENTS.md` with no local overlay; both candidate lists only control project scopes. `$DSH_HOME` defaults to `~/.dsh`, and configured `~`, `~/...`, and Windows-style `~\...` prefixes are expanded against the operating-system home directory. A non-positive or non-finite render budget disables both baseline and dynamic loading; configured `maxSourceBytes` must be a positive integer. ## Budgeting And Bounded Reads @@ -160,5 +161,5 @@ Append-only; newly visible content follows the reusable request prefix and does - **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam. - **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, or when a resumed loop recomposes its prefix. -- **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; same-directory names such as `CLAUDE.local.md` require explicit `instructionFileCandidates` configuration. +- **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; project scopes load `AGENTS.local.md`/`CLAUDE.local.md` overlays by default, but the user-global `$DSH_HOME` scope has no local overlay and other custom names require explicit candidate configuration. - **Instruction content is bounded, not summarized** — over-budget broad files are omitted and the most-specific file may be truncated; the plugin never asks a model to compress instruction prose. diff --git a/packages/context/workspace-context/src/config.ts b/packages/context/workspace-context/src/config.ts index c4bdd663c7..4b966e890c 100644 --- a/packages/context/workspace-context/src/config.ts +++ b/packages/context/workspace-context/src/config.ts @@ -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 = z.object({ @@ -32,6 +35,7 @@ export const Config: z = 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: Pick, ): 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) )) } diff --git a/packages/context/workspace-context/src/files.ts b/packages/context/workspace-context/src/files.ts index 7a995c5886..8656582aa4 100644 --- a/packages/context/workspace-context/src/files.ts +++ b/packages/context/workspace-context/src/files.ts @@ -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 { @@ -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 { - 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 }, diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 21ef979459..d06c8c542f 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -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 ?? []) diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index 910853b13b..7f8fd2f647 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -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}`, '', diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 0ab455349a..885942a8c5 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -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() + 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) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 9c5cb3b0d3..c3d1aa05f3 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -311,6 +311,52 @@ describe('workspace context instruction discovery', () => { } }) + it('loads a same-directory local overlay in addition to the base file by default', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + const cwd = join(root, 'pkg') + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'root base') + await write(join(root, 'AGENTS.local.md'), 'root local') + await write(join(cwd, 'CLAUDE.md'), 'pkg base') + await write(join(cwd, 'CLAUDE.local.md'), 'pkg local') + + const files = await discoverBaselineInstructionFiles({ cwd, dshHome: home }) + + expect(files.map(file => file.displayPath)).toEqual([ + 'AGENTS.md', + 'AGENTS.local.md', + 'pkg/CLAUDE.md', + 'pkg/CLAUDE.local.md', + ]) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('loads no local overlay when localInstructionFileCandidates is empty', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'base rule') + await write(join(root, 'AGENTS.local.md'), 'local rule') + + const files = await discoverBaselineInstructionFiles({ + cwd: root, + dshHome: home, + localInstructionFileCandidates: [], + }) + + expect(files.map(file => file.displayPath)).toEqual(['AGENTS.md']) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('treats a .git file as a project root marker and does not search above it', async () => { const outer = await tempRepo() const home = await tempRepo() @@ -1459,6 +1505,27 @@ describe('workspace context request injection', () => { } }) + it('renders a default local overlay alongside the base file in the baseline prefix', async () => { + const root = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'base rule') + await write(join(root, 'AGENTS.local.md'), 'local rule') + const ctx = new Context() + await ctx.plugin(LocalFileSystem, { cwd: '/' }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + + expect(derivedText(agent)).toContain('Instructions from: AGENTS.md\n\nbase rule') + expect(derivedText(agent)).toContain('Instructions from: AGENTS.local.md\n\nlocal rule') + await ctx.fiber.dispose() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it('cleans up its agent/session-prefix listener when the plugin fiber is disposed', async () => { const root = await tempRepo() const home = await tempRepo() @@ -1818,6 +1885,75 @@ describe('dynamic nested workspace context injection', () => { } }) + it('attaches a nested base file and its local overlay together by default', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'baseline root rule') + await write(join(root, 'pkg/AGENTS.md'), 'nested base rule') + await write(join(root, 'pkg/AGENTS.local.md'), 'nested local rule') + await write(join(root, 'pkg/deep/file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + + const result = await ctx.tools.execute({ + callId: CallId('read-nested-overlay'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent: stubAgent(root), + }) + + const meta = workspaceContextOf(result)?.meta + const changes = typeof meta === 'object' && meta !== null && !Array.isArray(meta) && Array.isArray(meta.changes) + ? meta.changes + : [] + expect(changes).toEqual(expect.arrayContaining([ + expect.objectContaining({ action: 'set', path: 'pkg/AGENTS.md' }), + expect.objectContaining({ action: 'set', path: 'pkg/AGENTS.local.md' }), + ])) + const text = blocksText(workspaceContextOf(result)?.content) + expect(text).toContain('Additional instructions from: pkg/AGENTS.md') + expect(text).toContain('nested base rule') + expect(text).toContain('Additional instructions from: pkg/AGENTS.local.md') + expect(text).toContain('nested local rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('does not attach a nested local overlay when the overlay is disabled', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'nested base rule') + await write(join(root, 'pkg/AGENTS.local.md'), 'nested local rule') + await write(join(root, 'pkg/deep/file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { + dshHome: home, + maxBytes: 65536, + localInstructionFileCandidates: [], + }) + + const result = await ctx.tools.execute({ + callId: CallId('read-nested-overlay-disabled'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent: stubAgent(root), + }) + + const text = blocksText(workspaceContextOf(result)?.content) + expect(text).toContain('Additional instructions from: pkg/AGENTS.md') + expect(text).not.toContain('pkg/AGENTS.local.md') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('does not attach nested instructions again for the same session once a path has been loaded', async () => { const root = await tempRepo() const home = await tempRepo()