DSH / Atlas
2026-07-21implementedfeature

Load all instruction candidates with per-directory dedup

加载全部指令候选并按目录去重

The [agent-instructions plugin](2026-06-24-workspace-context.md) resolved one winning file per candidate list per directory: the first existing name in `instructionFileCandidates` won the base slot, and the [local overlay](2026-07-21-local-instruction-overlay.md) added one more winner. But `AGENTS.md` and `CLAUDE.md` routinely coexist in the same directory. In most repositories one is a symlink to the other, so they

English

Problem

The agent-instructions plugin resolved one winning file per candidate list per directory: the first existing name in instructionFileCandidates won the base slot, and the local overlay added one more winner. But AGENTS.md and CLAUDE.md routinely coexist in the same directory. In most repositories one is a symlink to the other, so they carry identical content; in repositories mid-migration they are two distinct real files that have drifted apart. First-wins silently dropped the non-winning committed file, so a directory that legitimately carried two distinct instruction files only ever surfaced one — and which one depended on candidate order, not on content. The request was to read both and deduplicate only when they are effectively the same file.

Decision

Every existing candidate in each list loads — the base list first, then the local list — in configured order. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in that order, and the kept file's original bytes are rendered. Dedup is per-directory rather than global, and symmetric across the base and local lists. Trimming before comparison tolerates a trailing newline or indentation difference between a file and its near-copy while still rendering the survivor verbatim — the "extra safe" comparison the request asked for.

Symlinks now flow through this uniformly. Instruction discovery resolves each candidate and stats its target instead of rejecting a final-component symlink, so a CLAUDE.md that symlinks its sibling AGENTS.md resolves to identical content and collapses here like any byte-identical real duplicate. Content dedup therefore renders the common symlink-mirror once through the same path as a real copy. The follow-symlinks note owns that reversal and its residual trust-boundary risk.

Scope keys become per-candidate

Each (directory, candidateName) pair is now its own logical scope, encoded directory\u0000candidateName with a NUL separator that cannot occur in a real path. candidateScopeKey / decodeScopeKey own the encoding, and probeScopeInstruction decodes the candidate name to read exactly that file. This replaces the tier-sentinel scope key the overlay note introduced: a directory no longer has a "base scope" and a "local scope" but one scope per candidate name, so AGENTS.md and CLAUDE.md in one directory are independent scopes that reconcile separately.

Because a scope now names one fixed file, the previous "candidate switch within a scope" — an AGENTS.md scope that fell through to CLAUDE.md and recorded the old name in previousPath — can no longer occur. previousPath was removed from the change record, the serialized context/message metadata, and the render text; a change is now either set, a same-file replace, or a remove. Removing one candidate emits a remove for that candidate's own scope, leaving a distinct sibling as an independent scope.

Dedup is enforced during reconciliation, not only at baseline composition. Each reconciliation pass rebuilds a per-directory set of kept trimmed-content digests in candidate order, so an unchanged file is removed when an earlier candidate converges on its content, and a newly duplicate sibling is dropped or removed. The version cache stores a trimmedDigest beside the full content digest so the fast path can re-evaluate duplication without re-reading content.

Alternatives considered

Keep first-wins per candidate list. Rejected: it silently drops a directory's second committed instruction file and makes the survivor depend on candidate order rather than on whether the files actually differ, which is exactly the surprise the request set out to remove.

Global, cross-directory dedup. Rejected: identical boilerplate under two different directories is legitimately in scope for each, and the deeper file must still surface for work under the deeper directory. Collapsing across directories would hide instructions the model should see.

Compare raw bytes without trimming. Rejected: an editor that adds a trailing newline, or a copy that reflows indentation, would defeat dedup for files that are the same in substance. Trimming before comparison is the tolerant key the request asked for, and the survivor still renders its original bytes.

Follow symlinks so a mirror deduplicates through content. Rejected for this change to preserve the no-follow invariant, then adopted separately: the follow-symlinks note reverses that invariant, after which a symlinked mirror is resolved and deduplicated through content exactly like a real duplicate.

Consequences

A directory with two distinct real instruction files now surfaces both; a directory whose second file merely mirrors the first still renders once, and the ubiquitous symlink case is unchanged. The visible behavior difference is confined to transition repositories that carry two distinct real files. The scope-key shape changed from a tier sentinel to a per-candidate key and previousPath disappeared from the durable change metadata; dsh-session keeps no compatibility promise for older sessions, so both are free changes. The version cache row grew a trimmedDigest field, and reconciliation now compares trimmed content per directory, so an unchanged file can be removed by a sibling's convergence — a transition the state model previously could not produce.

中文

问题

agent-instructions 插件在每个目录中为每个候选列表只解析出一个胜出文件:instructionFileCandidates 中第一个存在的名字赢得基础槽位,本地覆盖层再追加一个胜出者。但 AGENTS.mdCLAUDE.md 经常共处同一目录。在多数仓库里其中一个是另一个的符号链接,因此内容完全相同;在迁移中的仓库里它们则是两个已经产生分歧的、彼此独立的真实文件。先到先得会悄悄丢弃未胜出的已提交文件,于是一个合理地携带两个不同指令文件的目录最终只暴露其中一个——而暴露哪一个取决于候选顺序,而非内容。需求是把两者都读取,仅在它们实质上是同一文件时才去重。

决策

每个列表中每个存在的候选都会被加载——先基础列表,再本地列表——按配置顺序进行。在同一目录内,内容在去除首尾空白后逐字节相同的候选会合并到该顺序中最靠前的候选,并渲染被保留文件的原始字节。去重是按目录进行的,而非全局,并且在基础列表与本地列表之间对称。比较前先做去空白处理,可以容忍某文件与其近似副本之间的末尾换行或缩进差异,同时仍逐字节渲染保留下来的文件——这正是需求所要求的「格外稳妥」的比较。

符号链接现在会统一经此流转。指令发现会解析每个候选并对其目标做 stat,而非拒绝末段的符号链接,因此一个符号链接指向其同级 AGENTS.mdCLAUDE.md 会解析到相同内容,并在此像任何逐字节相同的真实副本一样被合并。因此内容去重会通过与真实副本相同的路径把常见的符号链接镜像只渲染一次。跟随符号链接说明 拥有该反转决策及其残余的信任边界风险。

scope 键改为按候选划分

现在每个 (directory, candidateName) 对都是各自独立的逻辑 scope,编码为 directory\u0000candidateName,其中 NUL 分隔符在真实路径中不可能出现。candidateScopeKey / decodeScopeKey 负责这套编码,probeScopeInstruction 则解码候选名以精确读取该文件。这取代了覆盖层 note 引入的层级哨兵 scope 键:一个目录不再有「基础 scope」和「本地 scope」,而是每个候选名一个 scope,因此同一目录中的 AGENTS.mdCLAUDE.md 是各自独立协调的 scope。

由于一个 scope 现在只对应一个固定文件,此前的「同一 scope 内的候选切换」——即一个 AGENTS.md scope 回退到 CLAUDE.md 并把旧名字记录在 previousPath 中——不再可能发生。previousPath 已从变更记录、序列化的 context/message 元数据以及渲染文本中移除;一次变更现在要么是 set、要么是同一文件的 replace、要么是 remove。移除某个候选会为该候选自己的 scope 发出一个 remove,而把不同的同级文件留作独立的 scope。

去重在协调过程中强制执行,而不仅仅在基线组合时。每一轮协调都会按候选顺序重建一个按目录的「已保留去空白摘要」集合,因此当更靠前的候选收敛到某文件的内容时,一个未变更的文件也会被移除,而新出现的重复同级文件会被丢弃或移除。版本缓存在完整内容摘要之外还存储一个 trimmedDigest,使快速路径无需重新读取内容即可重新判定是否重复。

备选方案

每个候选列表保持先到先得。 否决:这会悄悄丢弃一个目录的第二个已提交指令文件,并使胜出者取决于候选顺序、而非文件是否真的不同,而这恰恰是需求要消除的意外。

全局的、跨目录的去重。 否决:两个不同目录下相同的样板内容对各自而言都合理地在作用域内,而更深层的文件对于该更深目录下的工作仍必须暴露。跨目录合并会隐藏模型本应看到的指令。

不做去空白、直接比较原始字节。 否决:一个添加末尾换行的编辑器,或一个重排缩进的副本,都会让实质相同的文件无法去重。比较前去空白正是需求所要求的宽容键,而保留下来的文件仍渲染其原始字节。

跟随符号链接,从而让镜像通过内容去重。 为本次改动否决以保留「不跟随」不变式,随后另行采纳:跟随符号链接说明 反转了该不变式,此后符号链接镜像会被解析,并像真实副本一样通过内容去重。

后果

一个携带两个不同真实指令文件的目录现在会把两者都暴露;一个第二个文件仅仅是镜像的目录仍只渲染一次,而无处不在的符号链接场景保持不变。可见的行为差异被限定在携带两个不同真实文件的迁移期仓库中。scope 键的形态从层级哨兵改为按候选划分,previousPath 也从持久化的变更元数据中消失;dsh-session 对旧会话不作兼容承诺,因此两者都是无成本的改动。版本缓存行新增了一个 trimmedDigest 字段,协调过程现在按目录比较去空白后的内容,因此一个未变更的文件可以被同级文件的收敛所移除——这是状态模型此前无法产生的转换。