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

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

View File

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

View File

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

View File

@@ -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 中。