Răsfoiți Sursa

Merge branch 'feat/plugin-mgmt-1-boot' into feat/plugin-mgmt-2-manager

Adopt owned row ids in the plugin manager and inventory: a row view carries
`rowId` (the declared id a patch or row action targets) instead of the
withdrawn `originalId`, probe rows are listed under their declared ids, a
conflict record is attributed to the bundle it names, and the manager's
test boot records conflicts the way the launcher does.
Yichen Jiang 3 săptămâni în urmă
părinte
comite
3b0a07e029
32 a modificat fișierele cu 750 adăugiri și 261 ștergeri
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml
  2. 11 7
      .agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md
  3. 11 7
      .agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md
  4. 12 7
      apps/cli/src/dump-config.ts
  5. 53 36
      apps/cli/src/profile-boot.ts
  6. 2 2
      docs/architecture.i18n.yaml
  7. 1 1
      docs/architecture.md
  8. 1 1
      docs/architecture.zh.md
  9. 2 2
      docs/subsystems/core.i18n.yaml
  10. 4 3
      docs/subsystems/core.md
  11. 4 3
      docs/subsystems/core.zh.md
  12. 2 2
      packages/boot/app-boot/README.i18n.yaml
  13. 6 4
      packages/boot/app-boot/README.md
  14. 6 4
      packages/boot/app-boot/README.zh.md
  15. 192 0
      packages/boot/app-boot/src/compose-stack.ts
  16. 45 3
      packages/boot/app-boot/src/contained-group.ts
  17. 41 60
      packages/boot/app-boot/src/external-bundles.ts
  18. 8 5
      packages/boot/app-boot/src/index.ts
  19. 19 30
      packages/boot/app-boot/src/profile-runtime.ts
  20. 142 0
      packages/boot/app-boot/tests/compose-stack.spec.ts
  21. 29 16
      packages/boot/app-boot/tests/external-bundles.spec.ts
  22. 30 9
      packages/boot/app-boot/tests/profile-runtime.spec.ts
  23. 5 5
      packages/extensions/tool-cordis/src/api-catalog.ts
  24. 6 3
      packages/host/plugin-inventory/src/index.ts
  25. 3 5
      packages/host/plugin-inventory/src/types.ts
  26. 43 0
      packages/host/plugin-inventory/tests/inventory.spec.ts
  27. 2 2
      packages/host/plugin-manager/README.i18n.yaml
  28. 1 1
      packages/host/plugin-manager/README.md
  29. 1 1
      packages/host/plugin-manager/README.zh.md
  30. 9 10
      packages/host/plugin-manager/src/index.ts
  31. 3 3
      packages/host/plugin-manager/src/types.ts
  32. 54 27
      packages/host/plugin-manager/tests/plugin-manager.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 .agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md
-2026-09-04-external-bundles-as-contained-groups.md: e9bca676ed6d45ed88b1f4124c62c22c676b45c4
-2026-09-04-external-bundles-as-contained-groups.zh.md: bb360e61e6ec9aa71b84895514937e5465119c9c
+2026-09-04-external-bundles-as-contained-groups.md: b8639bd48dfb99df7f90ad28f8eeaf4be9b803d2
+2026-09-04-external-bundles-as-contained-groups.zh.md: 04980f87429c231d539c367d6c5bf2cf51ba554f

+ 11 - 7
.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md

@@ -1,4 +1,4 @@
-# Agent Note: External bundles mount as contained, prefixed groups
+# Agent Note: External bundles mount as contained groups with owned row ids
 
 Status: implemented
 
@@ -10,15 +10,17 @@ A bundle installed with `dsh plugin add` mounted its rows exactly like the insta
 
 ## Decision
 
-**Every external bundle is one group.** The profile launcher classifies each layer by provenance: a bundle that is a pnpm dependency of the profile is `external`, a template bundle or one the profile lists under `dsh.profile.firstParty` is `builtin`. `composeExternalLayer` renders a `runtime`-stage external layer as one `cordis:contained-group` entry, `bundle/<package>`, holding the bundle's inserted rows with their ids prefixed `<package>/<id>`; inserts into a built-in group nest their own contained group inside the target, and the bundle's id-targeted patches are rewritten to the prefixed ids when they address its own rows and passed through when they address built-in rows. `/` rather than `:` because `:` is the Loader's nested-id separator.
+**Every external bundle is one group.** The profile launcher classifies each layer by provenance: a bundle that is a pnpm dependency of the profile is `external`, a template bundle or one the profile lists under `dsh.profile.firstParty` is `builtin`. `composeExternalLayer` renders a `runtime`-stage external layer as one `cordis:contained-group` entry, `bundle/<package>`, holding the bundle's inserted rows under the ids its patch declares; inserts into a built-in group nest their own contained group inside the target, and the bundle's id-targeted patches pass through, reported as overrides when they address rows the bundle did not insert. `/` rather than `:` in the group id because `:` is the Loader's nested-id separator.
+
+**Row ids are owned, never rewritten.** `composeProfileStack` decides ownership before anything mounts: built-in and boot-staged layers claim their ids first and a duplicate among them fails the boot; a contained bundle that declares an id another layer owns is left out whole; a user-layer insert of a taken id is dropped. Each row left out is a `conflict` record in `pluginFailures` — printed on stderr at boot, replaced on every recomposition, shown per package in the plugin list — and boot, live recomposition, and `--dump-config` compose through the one function.
 
 **The contained group isolates row failures.** `ContainedGroup extends Group` overrides `create()`, the one per-row step the transactional `update()` awaits: a rejected row is recorded on the root's `pluginFailures` registry — tree-wide id, declared row id, module, group, stage parsed from the Loader's wrapper, message — and the group activates without it. `assertEntriesActivated` exempts contained rows (a failed or pending one becomes a record) and keeps the fatal path for built-in rows. One fail-safe closes the corner case where isolation would hide a broken core: a built-in row left pending while any bundle is isolated still fails the boot, and the diagnostic names the isolated bundles and the `stage: boot` escape.
 
-**`stage: boot` is the explicit opt-out.** A bundle whose rows provide a service built-in rows inject declares `dsh.bundle.stage: boot` in its manifest, or the deployer sets `dsh.profile.stages` in the profile manifest, which wins; such a layer mounts unwrapped and unprefixed with fatal semantics. An unknown stage value fails profile loading.
+**`stage: boot` is the explicit opt-out.** A bundle whose rows provide a service built-in rows inject declares `dsh.bundle.stage: boot` in its manifest, or the deployer sets `dsh.profile.stages` in the profile manifest, which wins; such a layer mounts unwrapped with fatal semantics. An unknown stage value fails profile loading.
 
 **Installed and enabled are two facts.** `reconcileInstalledBundles` no longer appends every bundle-declaring dependency to `dsh.profile.bundles` unconditionally; `autoEnable` keeps the CLI's install-and-enable semantics, and `enableBundle`/`disableBundle` are the manifest operations a plugin manager calls. `dependencies` records the install, `bundles` the enabled layers.
 
-**Provenance is a launcher service.** `ProfileRuntime` (`ctx.profileRuntime`) holds the booted profile, attributes each row to the layer that inserted it (`originOf`, prefixed ids included), reads which rows the user patch files disable with a literal `disabled: true`, and recomposes the tree through the root include — the same path the patch watchers take. The plugin inventory reads it and the failure registry to serve `trust`, `package`, `disabledBy`, and `failure` per row, listing rows the registry alone knows.
+**Provenance is a launcher service.** `ProfileRuntime` (`ctx.profileRuntime`) holds the booted profile, attributes each row to the layer that owns its id (`originOf`), reads which rows the user patch files disable with a literal `disabled: true`, and recomposes the tree through the root include — the same path the patch watchers take. The plugin inventory reads it and the failure registry to serve `trust`, `package`, `disabledBy`, and `failure` per row, listing rows the registry alone knows.
 
 ## Alternatives considered
 
@@ -26,12 +28,14 @@ A bundle installed with `dsh plugin add` mounted its rows exactly like the insta
 
 **A second boot phase for external bundles, mounted by a runtime plugin after the built-in tree is up.** Cleanest isolation and no composition rewriting, but every bundle that provides a built-in seam service would need an explicit escape anyway, sessions could start before the second phase landed, and the browser roster would need recomputation. Rejected as the larger change for the same outcome; the contained group keeps one boot transaction.
 
-**Keep ids unprefixed and rely on authors choosing unique ids.** Rejected because the failure mode is silent takeover, not an error.
+**Prefix every external row id with its package name.** Implemented first, then withdrawn: it made collisions impossible, but a user patch, a third-party manager writing `disabled` rows, and a bundle's own `!!js` expression comparing `e.options.id` all addressed the declared id and silently missed — the deep-whale skin manager's mutual-exclusion rows and the pi-ai OAuth bundle's self-excluding gate (which recursed into a stack overflow once its own id no longer matched) both broke this way — and `--dump-config` composed the unprefixed layers, so the dump disagreed with the boot. Ownership with a loud conflict keeps the declared id everywhere and turns a collision into a visible record.
+
+**Keep ids unprefixed and rely on authors choosing unique ids.** Rejected because the failure mode is silent takeover, not an error; the ownership check is what makes unprefixed ids safe.
 
 ## Consequences
 
-A community bundle that breaks on a harness upgrade no longer stops `dsh`; the plugin list shows the failed row with its stage and message, and the process keeps serving. Two bundles may declare the same row id. A user patch that targets an external bundle's row must name the prefixed id, and `--dump-config` shows the group; a `!!js` disabled expression that compared `e.options.id` to the bundle's own unprefixed id no longer matches — comparing `e.options.name` is the documented form. A bundle's override of a built-in row stays outside isolation, because it edits that row in place. The nested-fiber audit that would catch a failed `ctx.inject()` continuation under a built-in entry ships as advisory lines; making it fatal waits on a pass over shipped compositions.
+A community bundle that breaks on a harness upgrade no longer stops `dsh`; the plugin list shows the failed row with its stage and message, and the process keeps serving. Two bundles that declare the same row id cannot both mount: the earlier one in `dsh.profile.bundles` keeps it and the later one is left out with a conflict record, so uninstalling the earlier bundle lets the later one mount on the next boot. A user patch targets an external bundle's row by the id the bundle declares, `--dump-config` shows the contained group and reports the same conflicts, and a bundle's `!!js` expression sees its own declared id. A bundle's override of a built-in row stays outside isolation, because it edits that row in place. The nested-fiber audit that would catch a failed `ctx.inject()` continuation under a built-in entry ships as advisory lines; making it fatal waits on a pass over shipped compositions.
 
 ## Testing
 
-`packages/boot/app-boot/tests/external-bundles.spec.ts` pins the composition (grouping, prefixing, patch rewriting, nested inserts, no mutation of the layer's patches) and the manifest operations. `tests/contained-group.spec.ts` boots real trees: a failing contained row is recorded while its siblings and the built-in rows run, a pending contained row is recorded, a built-in failure still rejects, a built-in row left pending beside an isolated bundle rejects and names it, and a contained row that fails on a later reload is recorded by the audit. `tests/profile.spec.ts` pins trust and stage resolution including the deployer override and the unknown-stage rejection; `tests/profile-runtime.spec.ts` pins provenance and recomposition; `packages/host/plugin-inventory/tests/inventory.spec.ts` pins the new row fields.
+`packages/boot/app-boot/tests/external-bundles.spec.ts` pins the composition (grouping, declared ids, overrides, nested inserts, no mutation of the layer's patches) and the manifest operations; `tests/compose-stack.spec.ts` pins ownership: built-in duplicates throw, an external bundle that collides is left out whole, a user insert of a taken id is dropped, conflict records replace the previous composition's. `tests/contained-group.spec.ts` boots real trees: a failing contained row is recorded while its siblings and the built-in rows run, a pending contained row is recorded, a built-in failure still rejects, a built-in row left pending beside an isolated bundle rejects and names it, and a contained row that fails on a later reload is recorded by the audit. `tests/profile.spec.ts` pins trust and stage resolution including the deployer override and the unknown-stage rejection; `tests/profile-runtime.spec.ts` pins provenance and recomposition; `packages/host/plugin-inventory/tests/inventory.spec.ts` pins the new row fields.

+ 11 - 7
.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md

@@ -1,4 +1,4 @@
-# Agent Note:外部组合包挂载为受控的、带前缀的组
+# Agent Note:外部组合包挂载为受控组,行 id 有归属
 
 Status: implemented
 
@@ -10,15 +10,17 @@ Status: implemented
 
 ## 决定
 
-**每个外部组合包就是一个组。** profile launcher 按来源给每一层分类:作为 profile 的 pnpm 依赖存在的组合包是 `external`,模板组合包或 profile 在 `dsh.profile.firstParty` 下列出的是 `builtin`。`composeExternalLayer` 把 `runtime` 阶段的外部层渲染成一个 `cordis:contained-group` 条目 `bundle/<package>`,其中放着组合包插入的行,id 带 `<package>/<id>` 前缀;插入内置组的行在目标组内嵌套自己的受控组,组合包按 id 定位的 patch 在指向自己的行时改写为前缀后的 id,在指向内置行时原样通过。用 `/` 而不是 `:`,因为 `:` 是 Loader 的嵌套 id 分隔符。
+**每个外部组合包就是一个组。** profile launcher 按来源给每一层分类:作为 profile 的 pnpm 依赖存在的组合包是 `external`,模板组合包或 profile 在 `dsh.profile.firstParty` 下列出的是 `builtin`。`composeExternalLayer` 把 `runtime` 阶段的外部层渲染成一个 `cordis:contained-group` 条目 `bundle/<package>`,其中放着组合包插入的行,id 保持 patch 声明的样子;插入内置组的行在目标组内嵌套自己的受控组,组合包按 id 定位的 patch 原样通过,指向它没有插入的行时报告为覆盖。组 id 用 `/` 而不是 `:`,因为 `:` 是 Loader 的嵌套 id 分隔符。
+
+**行 id 有归属,不改写。** `composeProfileStack` 在任何行挂载之前判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;受控组合包声明了别的层已占有的 id 时整层排除;用户层插入已被占用的 id 时该行丢弃。每一条被排除的行都是 `pluginFailures` 里的一条 `conflict` 记录——启动时打到 stderr,每次重组时替换,在插件列表里按包显示——启动、运行时重组与 `--dump-config` 走同一个函数。
 
 **受控组隔离行的失败。** `ContainedGroup extends Group` 覆盖 `create()`——这是事务性 `update()` 逐行等待的那一步:被拒的行记录到根上的 `pluginFailures` 注册表——树内 id、声明的行 id、模块、组、从 Loader 包装信息解析出的阶段、消息——组在没有它的情况下激活。`assertEntriesActivated` 豁免受控行(失败或 pending 的行变成一条记录),内置行保留致命路径。一条兜底规则封住"隔离反而藏起核心已坏"的 corner case:只要有组合包被隔离,而某个内置行停在 pending,启动仍然失败,诊断点名被隔离的组合包以及 `stage: boot` 这条出路。
 
-**`stage: boot` 是显式的退出隔离。** 若组合包的行提供内置行所注入的服务,作者在 manifest 里声明 `dsh.bundle.stage: boot`,或部署者在 profile manifest 里设置 `dsh.profile.stages`,后者优先;这样的层不包组、不加前缀、按致命语义挂载。未知的 stage 值让 profile 加载失败。
+**`stage: boot` 是显式的退出隔离。** 若组合包的行提供内置行所注入的服务,作者在 manifest 里声明 `dsh.bundle.stage: boot`,或部署者在 profile manifest 里设置 `dsh.profile.stages`,后者优先;这样的层不包组、按致命语义挂载。未知的 stage 值让 profile 加载失败。
 
 **安装与启用是两件事。** `reconcileInstalledBundles` 不再无条件把每个声明了组合包的依赖追加进 `dsh.profile.bundles`;`autoEnable` 保留 CLI 装即启用的语义,`enableBundle`/`disableBundle` 是插件管理器调用的 manifest 操作。`dependencies` 记录安装,`bundles` 记录已启用的层。
 
-**来源是 launcher 的服务。** `ProfileRuntime`(`ctx.profileRuntime`)持有已启动的 profile,把每一行归属到插入它的层(`originOf`,含前缀 id),读取用户 patch 文件用字面量 `disabled: true` 停用了哪些行,并经根 include 重新组合整棵树——patch 监视器走的正是同一条路。插件清单读取它与失败注册表,为每一行提供 `trust`、`package`、`disabledBy` 与 `failure`,并列出只有注册表知道的行。
+**来源是 launcher 的服务。** `ProfileRuntime`(`ctx.profileRuntime`)持有已启动的 profile,把每一行归属到占有其 id 的层(`originOf`),读取用户 patch 文件用字面量 `disabled: true` 停用了哪些行,并经根 include 重新组合整棵树——patch 监视器走的正是同一条路。插件清单读取它与失败注册表,为每一行提供 `trust`、`package`、`disabledBy` 与 `failure`,并列出只有注册表知道的行。
 
 ## 考虑过的替代方案
 
@@ -26,12 +28,14 @@ Status: implemented
 
 **为外部组合包增加第二个启动阶段,由运行时插件在内置树起来之后挂载。** 隔离最干净,也不用改写组合,但每个提供内置 seam 服务的组合包反正都需要显式逃生口,会话可能在第二阶段落地前就开始,浏览器 roster 也要重算。否决:同样的结果却是更大的改动;受控组保住了单一启动事务。
 
-**保持 id 不加前缀,依赖作者自己选唯一 id。** 否决,因为失败形态是静默接管,不是报错。
+**给每个外部行 id 加包名前缀。** 先实现后撤回:它让撞名不可能发生,但用户 patch、往用户层写 `disabled` 行的第三方管理器、以及组合包自己拿 `e.options.id` 比较的 `!!js` 表达式,全都按声明 id 寻址并静默落空——deep-whale 皮肤管理器的互斥行和 pi-ai OAuth 组合包的自排除门控(自己的 id 不再匹配后递归到栈溢出)都是这样坏掉的——而 `--dump-config` 组合的是未加前缀的层,dump 与启动不一致。带响亮冲突的归属让声明 id 处处一致,并把撞名变成看得见的记录。
+
+**保持 id 不加前缀,依赖作者自己选唯一 id。** 否决,因为失败形态是静默接管,不是报错;归属检查正是让不加前缀变得安全的那一步。
 
 ## 后果
 
-harness 升级后损坏的社区组合包不再让 `dsh` 停下;插件列表显示失败的行及其阶段与消息,进程继续服务。两个组合包可以声明同名行 id。针对外部组合包某行的用户 patch 必须写前缀后的 id,`--dump-config` 会显示该组;把 `e.options.id` 与组合包自己未加前缀 id 比较的 `!!js` disabled 表达式不再匹配——比较 `e.options.name` 是文档化的写法。组合包对内置行的覆盖留在隔离之外,因为它原地修改那一行。能抓住内置条目下失败的 `ctx.inject()` 延续的嵌套 fiber 审计以提示行交付;改为致命要等对随附组合做一轮检查。
+harness 升级后损坏的社区组合包不再让 `dsh` 停下;插件列表显示失败的行及其阶段与消息,进程继续服务。两个声明同名行 id 的组合包不能同时挂载:`dsh.profile.bundles` 里靠前的保住它,靠后的被排除并留下冲突记录,卸掉靠前的组合包后靠后的在下次启动时挂上。用户 patch 按组合包声明的 id 定位它的行,`--dump-config` 显示受控组并报告同样的冲突,组合包的 `!!js` 表达式看到的是自己声明的 id。组合包对内置行的覆盖留在隔离之外,因为它原地修改那一行。能抓住内置条目下失败的 `ctx.inject()` 延续的嵌套 fiber 审计以提示行交付;改为致命要等对随附组合做一轮检查。
 
 ## 测试
 
-`packages/boot/app-boot/tests/external-bundles.spec.ts` 钉住组合(分组、前缀、patch 改写、嵌套插入、不改动层自己的 patch)与 manifest 操作。`tests/contained-group.spec.ts` 启动真实的树:受控行失败被记录而其兄弟行与内置行运行,pending 的受控行被记录,内置失败仍然 reject,被隔离组合包旁边停在 pending 的内置行 reject 并点名它,稍后重载时失败的受控行由审计记录。`tests/profile.spec.ts` 钉住 trust 与 stage 的解析,包括部署者覆盖与未知 stage 的拒绝;`tests/profile-runtime.spec.ts` 钉住来源与重新组合;`packages/host/plugin-inventory/tests/inventory.spec.ts` 钉住新的行字段。
+`packages/boot/app-boot/tests/external-bundles.spec.ts` 钉住组合(分组、声明 id、覆盖、嵌套插入、不改动层自己的 patch)与 manifest 操作;`tests/compose-stack.spec.ts` 钉住归属:内置重复抛错,撞名的外部组合包整层排除,用户插入已占用 id 时丢弃,冲突记录替换上一次组合的记录。`tests/contained-group.spec.ts` 启动真实的树:受控行失败被记录而其兄弟行与内置行运行,pending 的受控行被记录,内置失败仍然 reject,被隔离组合包旁边停在 pending 的内置行 reject 并点名它,稍后重载时失败的受控行由审计记录。`tests/profile.spec.ts` 钉住 trust 与 stage 的解析,包括部署者覆盖与未知 stage 的拒绝;`tests/profile-runtime.spec.ts` 钉住来源与重新组合;`packages/host/plugin-inventory/tests/inventory.spec.ts` 钉住新的行字段。

+ 12 - 7
apps/cli/src/dump-config.ts

@@ -9,10 +9,13 @@
 import { existsSync } from 'node:fs'
 import { join, resolve } from 'node:path'
 import {
+  composeProfileStack,
+  formatRowConflict,
   loadOptionalPatches,
   loadOverlayPatches,
   renderConfigDump,
   type ConfigDumpLayer,
+  type StackUserLayer,
 } from '@deepseek-ai/dsh-app-boot'
 import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts'
 
@@ -29,24 +32,26 @@ const NAME = 'dsh'
  */
 export function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): void {
   const loaded = prepareProfile(profile, !defaultOnly)
-  const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({
-    label: layer.packageName,
-    patches: layer.patches,
-  }))
+  const userLayers: StackUserLayer[] = []
   if (!defaultOnly) {
     if (existsSync(loaded.patchPath)) {
-      layers.push({ label: loaded.patchPath, patches: loaded.patches })
+      userLayers.push({ label: loaded.patchPath, patches: loaded.patches })
     }
     const homePatchFile = homePatchPath()
     const homePatches = loadOptionalPatches(NAME, homePatchFile)
     if (homePatches !== undefined) {
-      layers.push({ label: homePatchFile, patches: homePatches })
+      userLayers.push({ label: homePatchFile, patches: homePatches })
     }
     for (const file of patches) {
       const absolute = resolve(file)
-      layers.push({ label: absolute, patches: loadOverlayPatches(NAME, absolute) })
+      userLayers.push({ label: absolute, patches: loadOverlayPatches(NAME, absolute) })
     }
   }
+  // The same composition boot mounts: external bundles as contained groups,
+  // a bundle or row left out by an id conflict reported, not shown.
+  const stack = composeProfileStack(NAME, loaded.layers, userLayers)
+  for (const conflict of stack.conflicts) process.stderr.write(`${NAME}: ${formatRowConflict(conflict)}\n`)
+  const layers: ConfigDumpLayer[] = stack.layers.map(layer => ({ label: layer.label, patches: layer.patches }))
   // The dump anchors on the same empty root file the boot includes.
   process.stdout.write(renderConfigDump(NAME, join(loaded.dir, PROFILE_ROOT_FILENAME), layers))
 }

+ 53 - 36
apps/cli/src/profile-boot.ts

@@ -16,11 +16,11 @@ import { join, resolve } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { FiberState, type Context } from '@deepseek-ai/cordis'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import {
   boot,
-  bundleLayerPatches,
   composeEntries,
+  composeProfileStack,
+  formatRowConflict,
   healProfilesModuleFallback,
   installFailLoud,
   installRuntimeGuards,
@@ -29,10 +29,13 @@ import {
   loadProfile,
   PROFILE_PATCH_FILENAME,
   ProfileRuntime,
+  recordRowConflicts,
   rootIncludeEntry,
   warnNestedFiberFailures,
   watchUserPatches,
+  type ComposedStack,
   type Profile,
+  type StackUserLayer,
 } from '@deepseek-ai/dsh-app-boot'
 import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
@@ -127,24 +130,29 @@ export function prepareProfile(name: string, userLayer = true): Profile {
   return profile
 }
 
-/** One profile's patch layers, in application order. */
+/** One profile's patch stack as booted, and the overlays every later recomposition keeps on top. */
 interface ComposedProfile {
   profile: Profile
-  /** Bundle layers concatenated — the part below the user layers on a live reload. */
-  bundlePatches: PatchOptions[]
-  /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */
-  homePatches: PatchOptions[]
+  /** The stack as composed at boot: bundle layers, both user layers, overlays, the telemetry switch. */
+  stack: ComposedStack
   /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */
-  overlays: PatchOptions[]
+  overlays: StackUserLayer[]
 }
 
-/** The full patch stack of one composed profile, in application order. */
-function allPatches(composed: ComposedProfile): PatchOptions[] {
+/**
+ * The user-owned layers of one profile as they stand on disk: the profile's
+ * own patch file, then the home-level file (`$DSH_HOME/cordis.patch.yml` —
+ * machine-local preferences that apply to every profile, so it outranks the
+ * per-profile layer). BOTH files are re-read per call: the HMR watcher hands
+ * a caller only the changed file's patches, and a fresh read of both keeps
+ * the two watchers from stitching in each other's stale copy.
+ * @param profile - the profile whose patch file to read.
+ * @returns the two layers, labelled by path.
+ */
+function userLayersOf(profile: Profile): StackUserLayer[] {
   return [
-    ...composed.bundlePatches,
-    ...composed.profile.patches,
-    ...composed.homePatches,
-    ...composed.overlays,
+    { label: profile.patchPath, patches: loadOptionalPatches(NAME, profile.patchPath) ?? [] },
+    { label: homePatchPath(), patches: loadOptionalPatches(NAME, homePatchPath()) ?? [] },
   ]
 }
 
@@ -152,12 +160,12 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
  * Load `name` and compose its effective patch stack: bundle layers in
  * `dsh.profile.bundles` order (a base-backed profile gets the base bundle's
  * platform-gated shell rows), the profile's user layer, the home-level user
- * layer (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply
- * to every profile, so it outranks the per-profile layer), `--patch` overlays,
- * then the telemetry switch.
+ * layer, `--patch` overlays, then the telemetry switch. Row-id ownership is
+ * decided here: a bundle whose id another layer already declares is left out
+ * and reported, a user insert of a taken id is dropped and reported.
  * @param name - the profile name.
  * @param patchFiles - `--patch` overlay paths, in argv order.
- * @returns the profile and its patch layers.
+ * @returns the profile and its patch stack.
  */
 async function composeProfile(
   name: string,
@@ -165,17 +173,20 @@ async function composeProfile(
 ): Promise<ComposedProfile> {
   const profile = prepareProfile(name)
   await healProfilesModuleFallback({ installAnchor: INSTALL_ANCHOR, profile })
-  const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
-  const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
-  const bundlePatches = profile.layers.flatMap(bundleLayerPatches)
-  const rows = new Map<string, EntryOptions>()
-  for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) {
-    if (typeof row.id === 'string') rows.set(row.id, row)
+  const overlays: StackUserLayer[] = patchFiles.map(file => ({
+    label: `--patch ${resolve(file)}`, patches: loadOverlayPatches(NAME, resolve(file)),
+  }))
+  const composed = composeProfileStack(NAME, profile.layers, [...userLayersOf(profile), ...overlays])
+  const rows = new Set<string>()
+  for (const row of composeEntries([composed.patches])) {
+    if (typeof row.id === 'string') rows.add(row.id)
   }
-  const composedOverlays = [...overlays]
   const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
-  if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch)
-  return { profile, bundlePatches, homePatches, overlays: composedOverlays }
+  if (telemetryPatch !== undefined) overlays.push({ label: 'DSH_TELEMETRY_DISABLED', patches: [telemetryPatch] })
+  const stack = telemetryPatch === undefined
+    ? composed
+    : composeProfileStack(NAME, profile.layers, [...userLayersOf(profile), ...overlays])
+  return { profile, stack, overlays }
 }
 
 /** Options for {@link runProfile}. */
@@ -260,18 +271,23 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   // objects in place. Reusing one parsed patch object across applications
   // would bake a user override into the bundle's in-memory insert row, so
   // removing the override could never revert the row to the bundle default.
-  const composeFor = (profile: Profile): PatchOptions[] => structuredClone([
-    ...profile.layers.flatMap(bundleLayerPatches),
-    ...loadOptionalPatches(NAME, profile.patchPath) ?? [],
-    ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
-    ...composed.overlays,
-  ])
+  const composeFor = (profile: Profile): ComposedStack => {
+    const stack = composeProfileStack(NAME, profile.layers, [...userLayersOf(profile), ...composed.overlays])
+    return { ...stack, patches: structuredClone(stack.patches) }
+  }
   // Once the profile runtime is mounted its profile is the current one: a
-  // bundle enabled since boot lives only there.
-  const composeLive = (): PatchOptions[] => composeFor(app.runtime?.current ?? composed.profile)
+  // bundle enabled since boot lives only there. The watcher path has no
+  // runtime to record conflicts on beyond the registry the runtime shares, so
+  // it records them itself once the tree accepted the update.
+  const composeLive = (): PatchOptions[] => {
+    const stack = composeFor(app.runtime?.current ?? composed.profile)
+    if (app.current !== undefined) recordRowConflicts(app.current, stack.conflicts)
+    return stack.patches
+  }
+  for (const conflict of composed.stack.conflicts) process.stderr.write(`${NAME}: ${formatRowConflict(conflict)}\n`)
   // Cloned for the same insert-aliasing reason as composeLive: the boot
   // application must not mutate the objects later reloads recompose from.
-  const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => {
+  const ctx = await boot(NAME, rootConfig, structuredClone(composed.stack.patches), (hostCtx) => {
     app.current = hostCtx
     // Before any config-tree entry mounts, so plugins resolve all launch-time
     // environment values from the same immutable provenance snapshot.
@@ -291,6 +307,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   uninstallRuntimeGuards = installRuntimeGuards(NAME, (line) => { process.stderr.write(`${line}\n`) })
   if (!signalShutdown.signal.aborted && ctx.fiber.state === FiberState.ACTIVE && ctx.get('loader') !== undefined) {
     warnNestedFiberFailures(ctx, NAME, (line) => { process.stderr.write(`${line}\n`) })
+    recordRowConflicts(ctx, composed.stack.conflicts)
     await ctx.plugin(ProfileRuntime, {
       profile: composed.profile,
       installAnchor: INSTALL_ANCHOR,

+ 2 - 2
docs/architecture.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 docs/architecture.md
-architecture.md: fb71638bb8583978c4bca5c9751f8435d04f9e7a
-architecture.zh.md: 7a0c902e90771e8d399868ceda9798d92651f92b
+architecture.md: 91e2a1e6a8539ff9b698637afc67f9d9e1aeae61
+architecture.zh.md: 2b18eda7765855e30d1f33f6edff8566ff55d5a1

+ 1 - 1
docs/architecture.md

@@ -26,7 +26,7 @@ Each declares itself in its own `package.json` under a `dsh` field: `dsh.profile
 
 Layers apply to an empty entry list in this order: each bundle in the profile's listed order, then the profile's `cordis.patch.yml`, then the home-level one, then any `--patch` overlay. A patch targets a row by id and replaces its whole config, or inserts new rows.
 
-A bundle installed into the profile with `dsh plugin` is external. Its rows mount under one contained group, `bundle/<package>`, with ids prefixed `<package>/<id>`, and a row that fails to start is isolated and reported in the plugin list while built-in rows keep failing the boot. A bundle that provides a service built-in rows inject opts out of isolation with `dsh.bundle.stage: boot`, and an isolated failure that leaves a built-in row waiting still stops the boot and names the bundle. Installing a package and enabling its layer are separate facts: `dependencies` records the install, `dsh.profile.bundles` records the enabled layers. The `plugins` Remote (`dsh-host-plugin-manager`) performs both at runtime through `profileRuntime`, and adds rows to the profile's user layer or to an agent preset's user layer (`.agent-presets/<id>/cordis.patch.yml`), which every preset accepts over its composition.
+A bundle installed into the profile with `dsh plugin` is external. Its rows mount under one contained group, `bundle/<package>`, under the ids its patch declares, and a row that fails to start is isolated and reported in the plugin list while built-in rows keep failing the boot. Row ids share one namespace: built-in layers own theirs first, and a bundle or user insert that declares a taken id is left out and reported as a conflict. A bundle that provides a service built-in rows inject opts out of isolation with `dsh.bundle.stage: boot`, and an isolated failure that leaves a built-in row waiting still stops the boot and names the bundle. Installing a package and enabling its layer are separate facts: `dependencies` records the install, `dsh.profile.bundles` records the enabled layers. The `plugins` Remote (`dsh-host-plugin-manager`) performs both at runtime through `profileRuntime`, and adds rows to the profile's user layer or to an agent preset's user layer (`.agent-presets/<id>/cordis.patch.yml`), which every preset accepts over its composition.
 
 Custom profiles default to live patch reload. The shipped `web` profile is live; `headless`, `sdk`, `sdk-minimal`, and `acp` apply all layers once at startup because replacing a one-shot or stdio application's dependencies after it owns work would invalidate that lifecycle.
 

+ 1 - 1
docs/architecture.zh.md

@@ -26,7 +26,7 @@
 
 各层按此顺序应用在空条目列表之上:先按 profile 列出的顺序应用每个组合包,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的那份,最后是任意 `--patch` overlay。一条 patch 按 id 定位某个条目并替换其整个 config,或插入新条目。
 
-用 `dsh plugin` 装进 profile 的组合包是外部的。它的行挂在一个受控组 `bundle/<package>` 下,id 带 `<package>/<id>` 前缀;启动失败的行被隔离并在插件列表里报告,而内置行仍然让启动失败。提供内置行所注入服务的组合包用 `dsh.bundle.stage: boot` 退出隔离;隔离的失败若让某个内置行停在等待状态,启动仍会失败并点名该组合包。安装一个包和启用它的层是两件事:`dependencies` 记录安装,`dsh.profile.bundles` 记录已启用的层。`plugins` Remote(`dsh-host-plugin-manager`)在运行时经 `profileRuntime` 完成这两件事,并向 profile 的用户层或某个 agent preset 的用户层(`.agent-presets/<id>/cordis.patch.yml`)添加行;每个 preset 都接受这样一层施加在其组合之上。
+用 `dsh plugin` 装进 profile 的组合包是外部的。它的行挂在一个受控组 `bundle/<package>` 下,id 保持 patch 声明的样子;启动失败的行被隔离并在插件列表里报告,而内置行仍然让启动失败。行 id 共用一个命名空间:内置层先占有自己的 id,组合包或用户层插入已被占用的 id 时被排除并报告为冲突。提供内置行所注入服务的组合包用 `dsh.bundle.stage: boot` 退出隔离;隔离的失败若让某个内置行停在等待状态,启动仍会失败并点名该组合包。安装一个包和启用它的层是两件事:`dependencies` 记录安装,`dsh.profile.bundles` 记录已启用的层。`plugins` Remote(`dsh-host-plugin-manager`)在运行时经 `profileRuntime` 完成这两件事,并向 profile 的用户层或某个 agent preset 的用户层(`.agent-presets/<id>/cordis.patch.yml`)添加行;每个 preset 都接受这样一层施加在其组合之上。
 
 自定义 profile 默认实时重载 patch。随附的 `web` profile 使用实时重载;`headless`、`sdk`、`sdk-minimal` 和 `acp` 则只在启动时应用一次所有配置层,因为一次性应用或 stdio 应用拥有工作之后,替换其依赖会破坏该生命周期。
 

+ 2 - 2
docs/subsystems/core.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 docs/subsystems/core.md
-core.md: df34d06554cf11fabd107e5b288329298653e710
-core.zh.md: 85234c6fca3a3584055fa36cfa17fe7aa30c680d
+core.md: 2bf9d2e21d162523471cb2ac92ac25ccd650fda3
+core.zh.md: 37ef25348ddcb1e4d9fe32c17c10fdafc9e9ff39

+ 4 - 3
docs/subsystems/core.md

@@ -980,8 +980,8 @@ Facts and recomposition of the booted profile.
 ```ts cordis-catalog
 /**
  * Where one mounted row came from.
- * @param rowId - the row's tree-wide id.
- * @returns the origin, or undefined for a row no bundle layer inserted (a user or overlay row).
+ * @param rowId - the row's id as the composition declares it.
+ * @returns the origin, or undefined for a row no bundle layer owns (a user or overlay row, or a bundle left out by a conflict).
  */
 originOf(rowId: string): RowOrigin | undefined
 
@@ -998,7 +998,8 @@ userDisabledRowIds(): Set<string>
  * as they stand now. The root Include re-applies the stack transactionally:
  * a row whose options changed is updated in place, a row that appeared is
  * created, a row that vanished is disposed, and a failure rolls the whole
- * update back with the previous tree still running.
+ * update back with the previous tree still running. The rows the stack left
+ * out replace the failure registry's conflict records once the update holds.
  * @param options - `reloadBundles` re-reads the profile manifest first, so a
  * bundle enabled or installed since boot joins the stack.
  * @throws when the root include is not mounted, or the Loader rejected the update.

+ 4 - 3
docs/subsystems/core.zh.md

@@ -990,8 +990,8 @@ Facts and recomposition of the booted profile.
 ```ts cordis-catalog
 /**
  * Where one mounted row came from.
- * @param rowId - the row's tree-wide id.
- * @returns the origin, or undefined for a row no bundle layer inserted (a user or overlay row).
+ * @param rowId - the row's id as the composition declares it.
+ * @returns the origin, or undefined for a row no bundle layer owns (a user or overlay row, or a bundle left out by a conflict).
  */
 originOf(rowId: string): RowOrigin | undefined
 
@@ -1008,7 +1008,8 @@ userDisabledRowIds(): Set<string>
  * as they stand now. The root Include re-applies the stack transactionally:
  * a row whose options changed is updated in place, a row that appeared is
  * created, a row that vanished is disposed, and a failure rolls the whole
- * update back with the previous tree still running.
+ * update back with the previous tree still running. The rows the stack left
+ * out replace the failure registry's conflict records once the update holds.
  * @param options - `reloadBundles` re-reads the profile manifest first, so a
  * bundle enabled or installed since boot joins the stack.
  * @throws when the root include is not mounted, or the Loader rejected the update.

+ 2 - 2
packages/boot/app-boot/README.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 packages/boot/app-boot/README.md
-README.md: 8ab2f482263252e793478d4e45afed3e4ee3666d
-README.zh.md: 51687c5af81715354b33427d824d62083e83b160
+README.md: e72c6f302f5e5a0f3f4c209393b715c992d5a528
+README.zh.md: f063d0c7bdc35a42c0ef372971cc2ca6086b0372

+ 6 - 4
packages/boot/app-boot/README.md

@@ -54,7 +54,7 @@ Your machine-local preferences also live in the Harness home:
 
 Profiles with `patchReload: live` watch both user patch files: a valid edit recomposes without restart, while a rejected edit leaves the last good app running. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback.
 
-A bundle you installed with `dsh plugin` is an **external** bundle: its rows mount under one contained group named `bundle/<package>`, every row id is prefixed `<package>/<id>`, and a row that fails to start is isolated and recorded instead of stopping the process — the group and its other rows stay up, and the plugin list shows the failure. Template bundles are built in and keep failing loud. A bundle that provides a service built-in rows inject must mount like a built-in one: its author declares `dsh.bundle.stage: boot` in `package.json`, or you set `dsh.profile.stages` in the profile manifest, which wins. Even without that, an isolated failure that leaves a built-in row waiting for a service still stops the boot and names the isolated bundle. Two more profile-manifest fields shape this: `dsh.profile.firstParty` lists installed packages treated as built in (a first-party package linked in during development), and `dependencies` versus `dsh.profile.bundles` distinguishes a package that is merely installed from one whose layer is enabled.
+A bundle you installed with `dsh plugin` is an **external** bundle: its rows mount under one contained group named `bundle/<package>` with the ids its patch declares, and a row that fails to start is isolated and recorded instead of stopping the process — the group and its other rows stay up, and the plugin list shows the failure. Template bundles are built in and keep failing loud. A bundle that provides a service built-in rows inject must mount like a built-in one: its author declares `dsh.bundle.stage: boot` in `package.json`, or you set `dsh.profile.stages` in the profile manifest, which wins. Even without that, an isolated failure that leaves a built-in row waiting for a service still stops the boot and names the isolated bundle. Two more profile-manifest fields shape this: `dsh.profile.firstParty` lists installed packages treated as built in (a first-party package linked in during development), and `dependencies` versus `dsh.profile.bundles` distinguishes a package that is merely installed from one whose layer is enabled. Row ids share one namespace across the stack: built-in layers own theirs first, an external bundle that declares an id another layer already owns is left out whole and reported on stderr and in the plugin list, and a user-layer insert of a taken id is dropped and reported the same way.
 
 After the tree is up the launcher provides `ctx.profileRuntime`, which holds the booted profile's facts and the installation anchor, attributes each row to the layer that inserted it, reads which rows the user patch files disable, and recomposes the tree — the same path the patch watchers take, and the one the [plugin manager](../../host/plugin-manager/README.md) uses to enable or retry a bundle; `recordContainedStates` is what such a caller runs afterwards, because the boot audit does not run again. Startup's fail-loud rejection guard is uninstalled once the tree is up: an unhandled rejection after boot is reported and contained, an uncaught exception is reported and exits.
 
@@ -86,7 +86,8 @@ This section explains how the outcomes above are realized and points at the code
 
 - **Channel-neutral library.** The package carries no loader hooks and no dev-mode surface; the [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence, and built consumers use plain Node package resolution.
 - **Three Loader builtins.** `mountRootInclude` registers `cordis:include`, `cordis:group`, and `cordis:contained-group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name, and the contained group is where external bundles mount. All load through the ambient module pipeline rather than the included tree's own specifier resolution.
-- **External bundles are groups.** The vendored `EntryGroup.update` is all-or-nothing and entry ids are unique per tree, so two external bundles that insert the same id would otherwise make the second silently take over the first's entry. `composeExternalLayer` wraps each `runtime`-stage external layer's inserts in one `cordis:contained-group`, prefixes its ids with the package name, and rewrites the bundle's own patches to the prefixed ids; the group's `create()` records a failed row on the root's `pluginFailures` registry instead of rejecting, and `assertEntriesActivated` exempts recorded rows while still failing a built-in row left pending.
+- **External bundles are groups.** The vendored `EntryGroup.update` is all-or-nothing, so `composeExternalLayer` wraps each `runtime`-stage external layer's inserts in one `cordis:contained-group` under the ids the bundle declares; the group's `create()` records a failed row on the root's `pluginFailures` registry instead of rejecting, and `assertEntriesActivated` exempts recorded rows while still failing a built-in row left pending.
+- **Row ids are owned, not rewritten.** Entry ids are unique per tree and a `create()` that finds an existing id re-parents that entry instead of rejecting, so `composeProfileStack` decides ownership before anything mounts: built-in and boot-staged layers claim first and a duplicate among them fails the boot, a contained bundle that collides is left out whole, a user insert of a taken id is dropped, and every such row is a `conflict` record in `pluginFailures`. Boot, live recomposition, and `--dump-config` compose through the same function.
 - **Fail-loud is boot-scoped.** `installFailLoud` exits on any unhandled rejection because during startup one is a load failure; the launcher uninstalls it once the tree is up and installs `installRuntimeGuards`, which reports a rejection and keeps running and exits on an uncaught exception. Nested fibers (a `ctx.inject()` continuation) that fail under a built-in entry are reported by `warnNestedFiberFailures` as advisory lines.
 - **The probe never runs a package in the host.** `probePackage` reads an installed package's manifest here and imports it in a child process, so a package that throws, exits, hangs, or brings its own copy of cordis costs one child and yields a record with the reason.
 - **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links.
@@ -103,7 +104,8 @@ The exports each own one stage of the boot: config resolution and snapshot repla
 |---|---|
 | [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard and runtime guards, activation audit and `recordContainedStates`, patch-file loading through `dsh-patch-file`, config dump, harness-source section |
 | [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution with `layerTrust` and stage, module fallback |
-| [`src/external-bundles.ts`](src/external-bundles.ts) | External layer composition (contained group, id prefixing, patch rewriting), `bundleLayerPatches`, and the manifest operations behind install, enable, and disable |
+| [`src/external-bundles.ts`](src/external-bundles.ts) | External layer composition (contained group, overrides), `bundleLayerPatches`, and the manifest operations behind install, enable, and disable |
+| [`src/compose-stack.ts`](src/compose-stack.ts) | Row-id ownership across the stack: `claimLayerIds`, `composeProfileStack`, conflict records |
 | [`src/contained-group.ts`](src/contained-group.ts) | The `cordis:contained-group` builtin and the `pluginFailures` registry |
 | [`src/profile-runtime.ts`](src/profile-runtime.ts) | The `profileRuntime` service: profile facts, row provenance, user-disabled rows, recomposition |
 | [`src/probe.ts`](src/probe.ts) | The child-process package probe and its per-profile cache |
@@ -149,7 +151,7 @@ These limits describe when this boot library is a poor fit or needs special care
 - **Environment discovery is launch-scoped** — `loadLayeredEnv` reads only the invocation directory and Harness home once; it does not search parents or follow a workspace selected later. `loadEnv` remains the one-directory helper for non-product bins.
 - **A user patch replaces the whole matched config** — an id-targeted patch does not deep-merge, so a profile override restates the bundle fields it keeps.
 - **An external bundle's overrides are not isolated** — a patch it applies to a built-in row edits that row in place, so its effect stays when the bundle's own rows fail and it is the one thing a bundle can break outside its group.
-- **Id prefixing does not rewrite `!!js` literals** — a bundle whose disabled expression compares `e.options.id` to its own unprefixed id keeps the literal; compare `options.name` instead.
+- **A conflict is decided by order, not merit** — among external bundles the earlier layer in `dsh.profile.bundles` keeps a contested id, so uninstalling that bundle lets the later one mount on the next boot; the plugin list shows which bundle lost and to whom.
 - **The nested-fiber audit is advisory** — a failed `ctx.inject()` continuation under a built-in entry is reported, not fatal, until shipped compositions are known clean.
 
 <a id="dev-note"></a>

+ 6 - 4
packages/boot/app-boot/README.zh.md

@@ -54,7 +54,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 带 `patchReload: live` 的 profile 会监视两份用户 patch 文件:有效编辑无需重启即可重新组合,被拒绝的编辑则让最后一个可用应用继续运行。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR 回退。
 
-用 `dsh plugin` 安装的组合包是**外部**组合包:它的行挂在一个名为 `bundle/<package>` 的受控组下,每个行 id 都加上 `<package>/<id>` 前缀,启动失败的行被隔离并记录而不是让进程停下——组和它的其他行继续运行,插件列表显示失败。模板组合包是内置的,仍然明确失败。若某个组合包提供内置行注入的服务,它必须像内置行一样挂载:作者在 `package.json` 里声明 `dsh.bundle.stage: boot`,或者你在 profile manifest 里设置 `dsh.profile.stages`,后者优先。即使没有这些声明,隔离的失败若让某个内置行停在等待服务的状态,启动仍会失败并点名那个被隔离的组合包。profile manifest 还有两个相关字段:`dsh.profile.firstParty` 列出按内置处理的已安装包(开发期 link 进来的一方包),`dependencies` 与 `dsh.profile.bundles` 的区别则把"只是装了"的包和"层已启用"的包分开。
+用 `dsh plugin` 安装的组合包是**外部**组合包:它的行挂在一个名为 `bundle/<package>` 的受控组下,id 保持它的 patch 所声明的样子,启动失败的行被隔离并记录而不是让进程停下——组和它的其他行继续运行,插件列表显示失败。模板组合包是内置的,仍然明确失败。若某个组合包提供内置行注入的服务,它必须像内置行一样挂载:作者在 `package.json` 里声明 `dsh.bundle.stage: boot`,或者你在 profile manifest 里设置 `dsh.profile.stages`,后者优先。即使没有这些声明,隔离的失败若让某个内置行停在等待服务的状态,启动仍会失败并点名那个被隔离的组合包。profile manifest 还有两个相关字段:`dsh.profile.firstParty` 列出按内置处理的已安装包(开发期 link 进来的一方包),`dependencies` 与 `dsh.profile.bundles` 的区别则把"只是装了"的包和"层已启用"的包分开。行 id 在整叠层里共用一个命名空间:内置层先占有自己的 id,外部组合包若声明了别的层已占有的 id 就整层被排除,并在 stderr 与插件列表里报告;用户层插入已被占用的 id 时该行被丢弃,同样报告。
 
 树起来之后 launcher 提供 `ctx.profileRuntime`:它持有已启动 profile 的事实与安装锚点,把每一行归属到插入它的层,读取用户 patch 文件停用了哪些行,并重新组合整棵树——patch 监视器走的正是这条路,[插件管理器](../../host/plugin-manager/README.zh.md) 启用或重试组合包也走它;这样的调用方之后会运行 `recordContainedStates`,因为启动审计不会再跑一次。启动期的 fail-loud rejection 守卫在树起来后卸载:启动后未处理的 rejection 会被报告并兜住,未捕获的异常会被报告并退出。
 
@@ -86,7 +86,8 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 - **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。
 - **三个 Loader builtin。** `mountRootInclude` 把 `cordis:include`、`cordis:group` 与 `cordis:contained-group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`,受控组则是外部组合包挂载的位置。三者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
-- **外部组合包即组。** vendored 的 `EntryGroup.update` 是整组事务,entry id 在整棵树内唯一,因此两个插入同名 id 的外部组合包会让第二个静默接管第一个的 entry。`composeExternalLayer` 把每个 `runtime` 阶段外部层的插入行包进一个 `cordis:contained-group`,给 id 加上包名前缀,并把组合包自己的 patch 改写到前缀后的 id;该组的 `create()` 把失败的行记录到根上的 `pluginFailures` 注册表而不是 reject,`assertEntriesActivated` 豁免已记录的行,但内置行停在 pending 时仍然失败。
+- **外部组合包即组。** vendored 的 `EntryGroup.update` 是整组事务,因此 `composeExternalLayer` 把每个 `runtime` 阶段外部层的插入行按组合包声明的 id 包进一个 `cordis:contained-group`;该组的 `create()` 把失败的行记录到根上的 `pluginFailures` 注册表而不是 reject,`assertEntriesActivated` 豁免已记录的行,但内置行停在 pending 时仍然失败。
+- **行 id 归属而非改写。** entry id 在整棵树内唯一,而 `create()` 遇到已有 id 时会把那个 entry 挪到自己名下而不是 reject,所以 `composeProfileStack` 在任何行挂载之前先判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;撞名的受控组合包整层排除;用户层插入已被占用的 id 时该行丢弃;每一条被排除的行都是 `pluginFailures` 里的一条 `conflict` 记录。启动、运行时重组与 `--dump-config` 走同一个函数。
 - **fail-loud 只在启动期。** `installFailLoud` 对任何未处理 rejection 退出,因为启动期间它就是加载失败;树起来后 launcher 卸载它并安装 `installRuntimeGuards`:rejection 被报告并继续运行,未捕获异常被报告并退出。内置条目下失败的嵌套 fiber(`ctx.inject()` 的延续)由 `warnNestedFiberFailures` 以提示行报告。
 - **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它,因此抛错、退出、挂起或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录。
 - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部 bundle 若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。
@@ -103,7 +104,8 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 |---|---|
 | [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、fail-loud 守卫与运行时守卫、激活审计与 `recordContainedStates`、经 `dsh-patch-file` 加载 patch 文件、配置 dump、harness 源码段落 |
 | [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、带 `layerTrust` 与 stage 的组合包解析、模块后备机制 |
-| [`src/external-bundles.ts`](src/external-bundles.ts) | 外部层组合(受控组、id 前缀、patch 改写)、`bundleLayerPatches`,与安装、启用、停用背后的 manifest 操作 |
+| [`src/external-bundles.ts`](src/external-bundles.ts) | 外部层组合(受控组、覆盖报告)、`bundleLayerPatches`,与安装、启用、停用背后的 manifest 操作 |
+| [`src/compose-stack.ts`](src/compose-stack.ts) | 整叠层的行 id 归属:`claimLayerIds`、`composeProfileStack`、冲突记录 |
 | [`src/contained-group.ts`](src/contained-group.ts) | `cordis:contained-group` builtin 与 `pluginFailures` 注册表 |
 | [`src/profile-runtime.ts`](src/profile-runtime.ts) | `profileRuntime` 服务:profile 事实、行来源、用户停用的行、重新组合 |
 | [`src/probe.ts`](src/probe.ts) | 子进程包探针及其按 profile 的缓存 |
@@ -149,7 +151,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 - **环境发现以启动为界**——`loadLayeredEnv` 只读取一次调用目录与 harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper。
 - **用户 patch 会替换匹配到的整个配置**——按 id 定位的 patch 不做深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。
 - **外部组合包的覆盖不被隔离**——它对内置行施加的 patch 原地修改那一行,所以即使该组合包自己的行失败,效果仍然保留;这是组合包唯一能在自己的组之外造成影响的地方。
-- **id 前缀不改写 `!!js` 字面量**——组合包的 disabled 表达式若拿 `e.options.id` 与自己未加前缀的 id 比较,字面量保持原样;请改为比较 `options.name`。
+- **冲突按顺序判定,不看是非**——外部组合包之间,`dsh.profile.bundles` 里靠前的那层保住争议 id,卸掉它之后靠后的那层在下次启动时挂上;插件列表显示谁输给了谁。
 - **嵌套 fiber 审计只是提示**——内置条目下失败的 `ctx.inject()` 延续会被报告而非致命,直到确认随附组合都没有这类失败。
 
 <a id="dev-note"></a>

+ 192 - 0
packages/boot/app-boot/src/compose-stack.ts

@@ -0,0 +1,192 @@
+/**
+ * The profile patch stack composed with tree-wide row-id ownership. Entry ids
+ * are unique per Loader tree, and the vendored group `create()` re-parents an
+ * existing id instead of rejecting it, so a shared id namespace needs its
+ * check before anything mounts: built-in and boot-staged layers claim their
+ * ids first and a duplicate among them fails loud; an external bundle whose
+ * id is already claimed is skipped whole and recorded as a conflict; a user
+ * layer's insert of a claimed id drops that row and records it. Boot, live
+ * recomposition, and the config dump all compose through here, so they agree.
+ * @module @deepseek-ai/dsh-app-boot/compose-stack
+ */
+
+import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
+import { bundleLayerPatches, composeExternalLayer, isContainedLayer } from './external-bundles.ts'
+import type { ProfileLayer } from './profile.ts'
+
+/** One user-owned patch list in the stack: the profile file, the home file, or a `--patch` overlay. */
+export interface StackUserLayer {
+  /** How diagnostics name the layer (a file path or the flag that supplied it). */
+  readonly label: string
+  /** The layer's patches, as parsed from disk. */
+  readonly patches: PatchOptions[]
+}
+
+/** One row a layer could not mount because another layer already declares its id. */
+export interface RowConflict {
+  /** The id both layers declare. */
+  readonly rowId: string
+  /** The module the losing row named. */
+  readonly moduleName: string
+  /** The layer that lost: a bundle's package name or a user layer's label. */
+  readonly layer: string
+  /** The external bundle that lost, when the loser is one; its whole layer is left out. */
+  readonly packageName?: string
+  /** The layer that owns the id: a bundle's package name or a user layer's label. */
+  readonly declaredBy: string
+}
+
+/** The stack as the root include should mount it, with what it left out. */
+export interface ComposedStack {
+  /** Patches in application order: bundle layers in manifest order, then the user layers as given. */
+  readonly patches: PatchOptions[]
+  /** The same patches per layer, labelled by package name or user-layer label; a skipped bundle has no entry. */
+  readonly layers: StackUserLayer[]
+  /** Every row left out, in stack order. */
+  readonly conflicts: RowConflict[]
+  /** External bundles left out because a row id was already claimed. */
+  readonly skippedBundles: string[]
+}
+
+/** Row-id ownership across the bundle layers: who owns each id, and which external bundles lost. */
+export interface LayerOwnership {
+  /** The layer that owns each id a bundle layer introduces. */
+  readonly owners: Map<string, ProfileLayer>
+  /** The conflicts of each external bundle left out, by package name. */
+  readonly skipped: Map<string, RowConflict[]>
+}
+
+/** Every `(id, module)` a patch list inserts, recursing into inserted groups. */
+function insertedRows(patches: readonly PatchOptions[]): Map<string, string> {
+  const rows = new Map<string, string>()
+  const visit = (row: EntryOptions): void => {
+    if (typeof row.id === 'string') rows.set(row.id, row.name)
+    if (row.group && Array.isArray(row.config)) (row.config as EntryOptions[]).forEach(visit)
+  }
+  for (const patch of patches) patch.insert?.forEach(visit)
+  return rows
+}
+
+/** The ids one inserted row carries: its own and, for a group, its children's. */
+function rowIds(row: EntryOptions): string[] {
+  const ids: string[] = []
+  const visit = (entry: EntryOptions): void => {
+    if (typeof entry.id === 'string') ids.push(entry.id)
+    if (entry.group && Array.isArray(entry.config)) (entry.config as EntryOptions[]).forEach(visit)
+  }
+  visit(row)
+  return ids
+}
+
+/**
+ * Decide row-id ownership across the bundle layers. Built-in and boot-staged
+ * layers claim first, in manifest order; a duplicate among them is a defect
+ * of the shipped composition and throws. Contained external layers then claim
+ * in manifest order, and one whose id is already owned is left out whole.
+ * @param layers - the profile's bundle layers, in manifest order.
+ * @returns the owner of every claimed id and the conflicts of each skipped bundle.
+ * @throws when two built-in or boot-staged layers declare the same id.
+ */
+export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
+  const owners = new Map<string, ProfileLayer>()
+  for (const layer of layers) {
+    if (isContainedLayer(layer)) continue
+    for (const id of insertedRows(layer.patches).keys()) {
+      const owner = owners.get(id)
+      if (owner !== undefined) {
+        throw new Error(`row ${JSON.stringify(id)} is declared by both ${owner.packageName} and ${layer.packageName}`)
+      }
+      owners.set(id, layer)
+    }
+  }
+  const skipped = new Map<string, RowConflict[]>()
+  for (const layer of layers) {
+    if (!isContainedLayer(layer)) continue
+    const { rows } = composeExternalLayer(layer)
+    const conflicts: RowConflict[] = []
+    for (const [rowId, moduleName] of rows) {
+      const owner = owners.get(rowId)
+      if (owner === undefined) continue
+      conflicts.push({ rowId, moduleName, layer: layer.packageName, packageName: layer.packageName, declaredBy: owner.packageName })
+    }
+    if (conflicts.length > 0) {
+      skipped.set(layer.packageName, conflicts)
+      continue
+    }
+    for (const id of rows.keys()) owners.set(id, layer)
+  }
+  return { owners, skipped }
+}
+
+/**
+ * Compose the stack the root include mounts: every bundle layer that owns its
+ * ids, in manifest order, then the user layers with any insert of an already
+ * owned id dropped. Patches are passed by reference; callers that mount them
+ * clone, because the include pushes inserted rows into the tree as they are.
+ * @param binName - the diagnostic prefix on a thrown built-in duplicate.
+ * @param layers - the profile's bundle layers, in manifest order.
+ * @param userLayers - the user-owned layers, in application order.
+ * @returns the patches to mount, the conflicts, and the bundles left out.
+ * @throws when two built-in or boot-staged layers declare the same id.
+ */
+export function composeProfileStack(
+  binName: string, layers: readonly ProfileLayer[], userLayers: readonly StackUserLayer[],
+): ComposedStack {
+  let ownership: LayerOwnership
+  try {
+    ownership = claimLayerIds(layers)
+  } catch (error) {
+    throw new Error(`${binName}: ${(error as Error).message}`)
+  }
+  const composedLayers: StackUserLayer[] = []
+  const conflicts: RowConflict[] = []
+  const skippedBundles: string[] = []
+  for (const layer of layers) {
+    const lost = ownership.skipped.get(layer.packageName)
+    if (lost !== undefined) {
+      conflicts.push(...lost)
+      skippedBundles.push(layer.packageName)
+      continue
+    }
+    composedLayers.push({ label: layer.packageName, patches: bundleLayerPatches(layer) })
+  }
+  const claimed = new Map<string, string>()
+  for (const [id, layer] of ownership.owners) claimed.set(id, layer.packageName)
+  for (const userLayer of userLayers) {
+    const patches: PatchOptions[] = []
+    for (const patch of userLayer.patches) {
+      if (patch.insert === undefined) {
+        patches.push(patch)
+        continue
+      }
+      const kept: EntryOptions[] = []
+      for (const row of patch.insert) {
+        const ids = rowIds(row)
+        const taken = ids.map(id => [id, claimed.get(id)] as const).find(([, owner]) => owner !== undefined)
+        if (taken?.[1] !== undefined) {
+          conflicts.push({ rowId: taken[0], moduleName: row.name, layer: userLayer.label, declaredBy: taken[1] })
+          continue
+        }
+        for (const id of ids) claimed.set(id, userLayer.label)
+        kept.push(row)
+      }
+      if (kept.length === patch.insert.length) patches.push(patch)
+      else if (kept.length > 0) patches.push({ ...patch, insert: kept })
+    }
+    composedLayers.push({ label: userLayer.label, patches })
+  }
+  return { patches: composedLayers.flatMap(layer => layer.patches), layers: composedLayers, conflicts, skippedBundles }
+}
+
+/**
+ * One diagnostic line for a conflict, as boot and the config dump print it.
+ * @param conflict - the conflict to describe.
+ * @returns the line, without a binary-name prefix.
+ */
+export function formatRowConflict(conflict: RowConflict): string {
+  const owner = `row ${JSON.stringify(conflict.rowId)} is already declared by ${conflict.declaredBy}`
+  return conflict.packageName === undefined
+    ? `${conflict.layer}: insert of ${conflict.moduleName} skipped — ${owner}`
+    : `bundle ${conflict.packageName} left out — ${owner}`
+}

+ 45 - 3
packages/boot/app-boot/src/contained-group.ts

@@ -10,6 +10,8 @@
 
 import type { Context, Fiber, FiberState } from '@deepseek-ai/cordis'
 import { Group, type Entry, type EntryGroup, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import type { RowConflict } from './compose-stack.ts'
+import { bundleGroupId } from './external-bundles.ts'
 
 /** Runtime mirror: FiberState is a cross-package const enum. */
 const FIBER_PENDING = 0 as FiberState.PENDING
@@ -26,8 +28,11 @@ export function pendingMessage(fiber: Fiber): string {
   return `pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`
 }
 
-/** The lifecycle step at which a contained row failed. */
-export type ContainedFailureStage = 'import' | 'apply' | 'inject-pending' | 'unknown'
+/**
+ * The lifecycle step at which a contained row failed; `conflict` is a row the
+ * composition left out because another layer already declares its id.
+ */
+export type ContainedFailureStage = 'import' | 'apply' | 'inject-pending' | 'conflict' | 'unknown'
 
 /** One recorded failure of a row inside a contained group. */
 export interface ContainedFailure {
@@ -37,8 +42,10 @@ export interface ContainedFailure {
   readonly rowId: string
   /** The module specifier the row named. */
   readonly moduleName: string
-  /** The contained group the row belongs to. */
+  /** The contained group the row belongs to; for a conflict, the group the bundle would have mounted, or the user layer's label. */
   readonly groupId: string
+  /** The bundle the row belongs to, for a record made before any group mounted (a composition conflict). */
+  readonly packageName?: string
   /** Which lifecycle step failed. */
   readonly stage: ContainedFailureStage
   /** The failure text, with the Loader's per-row wrapper folded in. */
@@ -69,6 +76,16 @@ export class ContainedFailureRegistry {
     this.failures.delete(entryId)
   }
 
+  /**
+   * Forget every record of one stage, before the stage's records are remade.
+   * @param stage - the stage whose records to drop.
+   */
+  clearStage(stage: ContainedFailureStage): void {
+    for (const [entryId, failure] of this.failures) {
+      if (failure.stage === stage) this.failures.delete(entryId)
+    }
+  }
+
   /**
    * Every recorded failure in record order.
    * @returns the failures.
@@ -198,3 +215,28 @@ export function ensurePluginFailures(ctx: Context): ContainedFailureRegistry {
   ctx.root.provide('pluginFailures', registry)
   return registry
 }
+
+/**
+ * Replace the registry's conflict records with the conflicts of one
+ * composition: every earlier `conflict` record is dropped, so a conflict
+ * resolved since (a bundle uninstalled, a user row renamed) disappears, and
+ * each current one is recorded under an id no mounted row can carry.
+ * @param ctx - any context of the runtime.
+ * @param conflicts - the conflicts of the composition just applied.
+ */
+export function recordRowConflicts(ctx: Context, conflicts: readonly RowConflict[]): void {
+  const registry = ensurePluginFailures(ctx)
+  registry.clearStage('conflict')
+  for (const conflict of conflicts) {
+    const groupId = conflict.packageName === undefined ? conflict.layer : bundleGroupId(conflict.packageName)
+    registry.record({
+      entryId: `conflict:${groupId}:${conflict.rowId}`,
+      rowId: conflict.rowId,
+      moduleName: conflict.moduleName,
+      groupId,
+      ...conflict.packageName === undefined ? {} : { packageName: conflict.packageName },
+      stage: 'conflict',
+      message: `row ${JSON.stringify(conflict.rowId)} is already declared by ${conflict.declaredBy}`,
+    })
+  }
+}

+ 41 - 60
packages/boot/app-boot/src/external-bundles.ts

@@ -3,14 +3,13 @@
  * installing, enabling, and disabling bundles.
  *
  * An external (`runtime` stage) bundle never mounts its rows directly into the
- * built-in tree. Its inserted rows are wrapped in one contained group per
- * bundle, each row id is prefixed with the package name, and the bundle's own
- * patches that address those rows are rewritten to the prefixed ids. Two
- * facts of the vendored Loader make this necessary: entry ids are unique per
- * tree (`tree.store`), so two bundles inserting the same id would otherwise
- * make the second silently take over the first's entry; and a group is the
- * unit the Loader rolls back, so one group per bundle is what makes a bundle
- * fail as a whole rather than half-mount.
+ * built-in tree: its inserted rows are wrapped in one contained group per
+ * bundle, under the ids its patch declares. A group is the unit the Loader
+ * rolls back, so one group per bundle is what makes a bundle fail as a whole
+ * rather than half-mount, and the contained variant records a failed row
+ * instead of rejecting. Row ids stay as declared; entry ids are unique per
+ * tree (`tree.store`), and `compose-stack.ts` owns the tree-wide ownership
+ * check that shared id namespace requires.
  * @module @deepseek-ai/dsh-app-boot/external-bundles
  */
 
@@ -47,63 +46,49 @@ export function bundleGroupId(packageName: string): string {
   return `${BUNDLE_GROUP_PREFIX}${packageName}`
 }
 
-/**
- * The tree-wide id of one row an external bundle inserted.
- * @param packageName - the bundle's package name.
- * @param rowId - the id the bundle's own patch declares.
- * @returns the prefixed id the mounted tree uses.
- */
-export function externalRowId(packageName: string, rowId: string): string {
-  return `${packageName}/${rowId}`
-}
-
-/** Where one mounted row came from, keyed by its tree-wide id. */
-export interface ExternalRowOrigin {
-  /** The bundle that inserted the row. */
-  packageName: string
-  /** The id the bundle's own patch declared, before prefixing. */
-  originalId: string
-}
-
 /** One external layer rendered as the patches the tree mounts. */
 export interface ComposedExternalLayer {
-  /** Patches in application order: the group insert first, then the bundle's rewritten patches. */
+  /** Patches in application order: the group insert first, then the bundle's own patches. */
   patches: PatchOptions[]
-  /** Every row the bundle inserted, by prefixed id. */
-  rows: Map<string, ExternalRowOrigin>
-  /** Built-in row ids the bundle overrides; not containable, reported for visibility. */
+  /** Every id the layer introduces — its rows, its group, and any nested group — with the module each names. */
+  rows: Map<string, string>
+  /** Ids outside the bundle that its patch overrides; not containable, reported for visibility. */
   overrides: string[]
 }
 
-/** Deep-clone one entry row and prefix its id and, for a group, its children's ids. */
-function prefixRow(packageName: string, row: EntryOptions, rows: Map<string, ExternalRowOrigin>, ownIds: Set<string>): EntryOptions {
+/** Deep-clone one inserted row and index its id and, for a group, its children's ids. */
+function indexRow(row: EntryOptions, rows: Map<string, string>): EntryOptions {
   const cloned = structuredClone(row)
-  if (typeof cloned.id === 'string') {
-    ownIds.add(cloned.id)
-    const prefixed = externalRowId(packageName, cloned.id)
-    rows.set(prefixed, { packageName, originalId: cloned.id })
-    cloned.id = prefixed
-  }
+  if (typeof cloned.id === 'string') rows.set(cloned.id, cloned.name)
   if (cloned.group && Array.isArray(cloned.config)) {
-    cloned.config = (cloned.config as EntryOptions[]).map(child => prefixRow(packageName, child, rows, ownIds))
+    cloned.config = (cloned.config as EntryOptions[]).map(child => indexRow(child, rows))
   }
   return cloned
 }
 
 /**
- * Render one external bundle layer as contained, prefixed patches. Root
- * inserts become the children of the bundle's group; inserts into a named
- * group the bundle itself introduced follow the prefixed id; inserts into a
- * built-in group are nested in their own contained group inside that target;
- * an id-targeted patch is rewritten when it addresses a row this bundle
- * inserted and passed through unchanged when it addresses a built-in row.
+ * Whether a layer mounts isolated: an external bundle the profile does not
+ * stage at boot.
+ * @param layer - the resolved layer.
+ * @returns true when the layer mounts as a contained group.
+ */
+export function isContainedLayer(layer: ProfileLayer): boolean {
+  return layer.trust === 'external' && layer.stage === 'runtime'
+}
+
+/**
+ * Render one external bundle layer as contained patches. Root inserts become
+ * the children of the bundle's group; an insert into a group the bundle
+ * itself introduced passes through; an insert into a built-in group is nested
+ * in its own contained group inside that target; an id-targeted patch passes
+ * through unchanged and is reported as an override when it addresses a row
+ * the bundle did not insert.
  * @param layer - the resolved external layer.
- * @returns the patches to mount and the provenance of every inserted row.
+ * @returns the patches to mount and the ids the layer introduces.
  */
 export function composeExternalLayer(layer: ProfileLayer): ComposedExternalLayer {
   const { packageName } = layer
-  const rows = new Map<string, ExternalRowOrigin>()
-  const ownIds = new Set<string>()
+  const rows = new Map<string, string>()
   const groupRows: EntryOptions[] = []
   const trailing: PatchOptions[] = []
   const overrides: string[] = []
@@ -111,17 +96,17 @@ export function composeExternalLayer(layer: ProfileLayer): ComposedExternalLayer
   // its id-targeted patches are classified.
   for (const patch of layer.patches) {
     if (patch.insert === undefined) continue
-    const inserted = patch.insert.map(row => prefixRow(packageName, row, rows, ownIds))
+    const inserted = patch.insert.map(row => indexRow(row, rows))
     if (patch.id === undefined) {
       groupRows.push(...inserted)
       continue
     }
-    if (ownIds.has(patch.id)) {
-      trailing.push({ id: externalRowId(packageName, patch.id), insert: inserted })
+    if (rows.has(patch.id)) {
+      trailing.push({ id: patch.id, insert: inserted })
       continue
     }
     const nestedId = `${bundleGroupId(packageName)}/in/${patch.id}`
-    rows.set(nestedId, { packageName, originalId: nestedId })
+    rows.set(nestedId, CONTAINED_GROUP_MODULE)
     trailing.push({
       id: patch.id,
       insert: [{ id: nestedId, name: CONTAINED_GROUP_MODULE, group: true, config: inserted }],
@@ -129,15 +114,11 @@ export function composeExternalLayer(layer: ProfileLayer): ComposedExternalLayer
   }
   for (const patch of layer.patches) {
     if (patch.insert !== undefined || patch.id === undefined) continue
-    if (ownIds.has(patch.id)) {
-      trailing.push({ ...structuredClone(patch), id: externalRowId(packageName, patch.id) })
-      continue
-    }
-    overrides.push(patch.id)
+    if (!rows.has(patch.id)) overrides.push(patch.id)
     trailing.push(structuredClone(patch))
   }
   const groupId = bundleGroupId(packageName)
-  rows.set(groupId, { packageName, originalId: groupId })
+  rows.set(groupId, CONTAINED_GROUP_MODULE)
   const group: EntryOptions = { id: groupId, name: CONTAINED_GROUP_MODULE, group: true, config: groupRows }
   return { patches: [{ insert: [group] }, ...trailing], rows, overrides }
 }
@@ -145,12 +126,12 @@ export function composeExternalLayer(layer: ProfileLayer): ComposedExternalLayer
 /**
  * The patches one bundle layer contributes. A built-in layer, or an external
  * layer the profile stages at boot, mounts its patches as written; every other
- * external layer mounts as one contained, id-prefixed group.
+ * external layer mounts as one contained group under its declared ids.
  * @param layer - the resolved layer.
  * @returns the layer's patches in application order.
  */
 export function bundleLayerPatches(layer: ProfileLayer): PatchOptions[] {
-  if (layer.trust === 'external' && layer.stage === 'runtime') return composeExternalLayer(layer).patches
+  if (isContainedLayer(layer)) return composeExternalLayer(layer).patches
   return layer.patches
 }
 

+ 8 - 5
packages/boot/app-boot/src/index.ts

@@ -58,15 +58,18 @@ export {
   type ProfileTemplate,
 } from './profile.ts'
 export {
-  ContainedFailureRegistry, ContainedGroup, ensurePluginFailures, isContainedEntry,
+  ContainedFailureRegistry, ContainedGroup, ensurePluginFailures, isContainedEntry, recordRowConflicts,
   type ContainedFailure, type ContainedFailureStage,
 } from './contained-group.ts'
 export {
-  bundleLayerPatches,
-  BUNDLE_GROUP_PREFIX, bundleGroupId, composeExternalLayer, CONTAINED_GROUP_MODULE, disableBundle, enableBundle,
-  exportsBundlePatch, externalRowId, isJsDisabled, reconcileInstalledBundles,
-  type BundleReconciliation, type ComposedExternalLayer, type ExternalRowOrigin,
+  BUNDLE_GROUP_PREFIX, bundleGroupId, bundleLayerPatches, composeExternalLayer, CONTAINED_GROUP_MODULE, disableBundle,
+  enableBundle, exportsBundlePatch, isContainedLayer, isJsDisabled, reconcileInstalledBundles,
+  type BundleReconciliation, type ComposedExternalLayer,
 } from './external-bundles.ts'
+export {
+  claimLayerIds, composeProfileStack, formatRowConflict,
+  type ComposedStack, type LayerOwnership, type RowConflict, type StackUserLayer,
+} from './compose-stack.ts'
 export {
   ProfileRuntime, type ProfileRuntimeOptions, type RowOrigin,
 } from './profile-runtime.ts'

+ 19 - 30
packages/boot/app-boot/src/profile-runtime.ts

@@ -9,10 +9,12 @@
  */
 
 import { Context, Service } from '@deepseek-ai/cordis'
-import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
 import type Include from '@deepseek-ai/cordis-plugin-include'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import { composeExternalLayer, isJsDisabled } from './external-bundles.ts'
+import { claimLayerIds, type ComposedStack } from './compose-stack.ts'
+import { recordRowConflicts } from './contained-group.ts'
+import { isJsDisabled } from './external-bundles.ts'
 import type { BundleTrust, Profile, ProfileLayer, ProfilePatchReload } from './profile.ts'
 
 declare module '@deepseek-ai/cordis' {
@@ -30,8 +32,6 @@ export interface RowOrigin {
   readonly packageName: string
   /** The package's version, when its manifest declares one. */
   readonly version?: string
-  /** For an external row: the id the bundle's own patch declared, before prefixing. */
-  readonly originalId?: string
 }
 
 /** What the launcher hands the service. */
@@ -42,23 +42,14 @@ export interface ProfileRuntimeOptions {
   installAnchor: string
   /** Re-read the profile from disk, re-resolving its bundle layers. */
   loadProfile: () => Profile
-  /** The complete patch stack for a profile: bundle layers, user layers, overlays. */
-  compose: (profile: Profile) => PatchOptions[]
+  /** The complete patch stack for a profile — bundle layers, user layers, overlays — with the rows it left out. */
+  compose: (profile: Profile) => ComposedStack
   /** The root Include entry, once mounted. */
   rootEntry: () => Entry | undefined
   /** The user patch layers as they stand on disk (profile file, then home file). */
   readUserPatches: () => PatchOptions[]
 }
 
-/** Collect every id a patch list inserts, recursing into inserted groups. */
-function insertedIds(patches: readonly PatchOptions[], into: Set<string>): void {
-  const visit = (row: EntryOptions): void => {
-    if (typeof row.id === 'string') into.add(row.id)
-    if (row.group && Array.isArray(row.config)) (row.config as EntryOptions[]).forEach(visit)
-  }
-  for (const patch of patches) patch.insert?.forEach(visit)
-}
-
 /** Facts and recomposition of the booted profile. */
 export class ProfileRuntime extends Service {
   private profile: Profile
@@ -106,8 +97,8 @@ export class ProfileRuntime extends Service {
 
   /**
    * Where one mounted row came from.
-   * @param rowId - the row's tree-wide id.
-   * @returns the origin, or undefined for a row no bundle layer inserted (a user or overlay row).
+   * @param rowId - the row's id as the composition declares it.
+   * @returns the origin, or undefined for a row no bundle layer owns (a user or overlay row, or a bundle left out by a conflict).
    */
   originOf(rowId: string): RowOrigin | undefined {
     this.origins ??= this.computeOrigins()
@@ -134,7 +125,8 @@ export class ProfileRuntime extends Service {
    * as they stand now. The root Include re-applies the stack transactionally:
    * a row whose options changed is updated in place, a row that appeared is
    * created, a row that vanished is disposed, and a failure rolls the whole
-   * update back with the previous tree still running.
+   * update back with the previous tree still running. The rows the stack left
+   * out replace the failure registry's conflict records once the update holds.
    * @param options - `reloadBundles` re-reads the profile manifest first, so a
    * bundle enabled or installed since boot joins the stack.
    * @throws when the root include is not mounted, or the Loader rejected the update.
@@ -147,27 +139,24 @@ export class ProfileRuntime extends Service {
       this.origins = undefined
     }
     const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
+    const stack = this.options.compose(this.profile)
     await entry.update({
       config: {
         ...includeConfig,
-        patches: this.options.compose(this.profile),
+        patches: stack.patches,
       },
     })
+    recordRowConflicts(this.ctx, stack.conflicts)
   }
 
   private computeOrigins(): Map<string, RowOrigin> {
     const origins = new Map<string, RowOrigin>()
-    for (const layer of this.profile.layers) {
-      const base = { trust: layer.trust, packageName: layer.packageName, ...layer.version === undefined ? {} : { version: layer.version } }
-      if (layer.trust === 'external' && layer.stage === 'runtime') {
-        for (const [id, origin] of composeExternalLayer(layer).rows) {
-          origins.set(id, { ...base, originalId: origin.originalId })
-        }
-        continue
-      }
-      const ids = new Set<string>()
-      insertedIds(layer.patches, ids)
-      for (const id of ids) origins.set(id, base)
+    for (const [id, layer] of claimLayerIds(this.profile.layers).owners) {
+      origins.set(id, {
+        trust: layer.trust,
+        packageName: layer.packageName,
+        ...layer.version === undefined ? {} : { version: layer.version },
+      })
     }
     return origins
   }

+ 142 - 0
packages/boot/app-boot/tests/compose-stack.spec.ts

@@ -0,0 +1,142 @@
+/**
+ * Tree-wide row-id ownership across the profile stack: built-in layers claim
+ * first and fail loud on a duplicate, an external bundle that collides is left
+ * out and recorded, a user insert of a taken id is dropped, and the conflict
+ * records replace the registry's earlier ones on every composition.
+ */
+
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
+import {
+  claimLayerIds, composeProfileStack, CONTAINED_GROUP_MODULE, ensurePluginFailures, formatRowConflict,
+  recordRowConflicts, type ProfileLayer,
+} from '../src/index.ts'
+
+const NAME = 'dsh-test-bin'
+
+function layer(
+  packageName: string, trust: ProfileLayer['trust'], patches: PatchOptions[], stage: ProfileLayer['stage'] = 'runtime',
+): ProfileLayer {
+  return { packageName, version: '1.0.0', packageDir: '/nowhere', patchPath: '/nowhere/cordis.patch.yml', trust, stage, patches }
+}
+
+const base = layer('@deepseek-ai/dsh-base', 'builtin', [{ insert: [
+  { id: 'settings', name: 'settings' },
+  { id: 'tools', name: 'cordis:group', group: true, config: [{ id: 'tool-bash', name: 'bash' }] },
+] }])
+
+const contexts: Context[] = []
+afterEach(async () => {
+  await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+})
+
+describe('claimLayerIds', () => {
+  it('lets built-in layers own their ids, including group children, before any external layer', () => {
+    const ext = layer('ext', 'external', [{ insert: [{ id: 'tool-bash', name: 'ext' }] }])
+    const { owners, skipped } = claimLayerIds([ext, base])
+    expect(owners.get('tool-bash')?.packageName).toBe('@deepseek-ai/dsh-base')
+    expect(owners.get('tools')?.packageName).toBe('@deepseek-ai/dsh-base')
+    expect(skipped.get('ext')).toEqual([
+      { rowId: 'tool-bash', moduleName: 'ext', layer: 'ext', packageName: 'ext', declaredBy: '@deepseek-ai/dsh-base' },
+    ])
+  })
+
+  it('throws when two built-in or boot-staged layers declare one id', () => {
+    const twin = layer('twin', 'external', [{ insert: [{ id: 'settings', name: 'twin' }] }], 'boot')
+    expect(() => claimLayerIds([base, twin])).toThrow(/row "settings" is declared by both @deepseek-ai\/dsh-base and twin/)
+  })
+
+  it('gives the earlier external bundle the id and leaves the later one out whole', () => {
+    const first = layer('first', 'external', [{ insert: [{ id: 'hello', name: 'first' }, { id: 'only-first', name: 'first/x' }] }])
+    const second = layer('second', 'external', [{ insert: [{ id: 'hello', name: 'second' }, { id: 'only-second', name: 'second/x' }] }])
+    const { owners, skipped } = claimLayerIds([base, first, second])
+    expect(owners.get('hello')?.packageName).toBe('first')
+    expect(owners.get('bundle/first')?.packageName).toBe('first')
+    expect(owners.has('only-second')).toBe(false)
+    expect(skipped.get('second')?.map(conflict => conflict.rowId)).toEqual(['hello'])
+  })
+})
+
+describe('composeProfileStack', () => {
+  it('mounts owning layers in manifest order and drops a user insert of a taken id', () => {
+    const ext = layer('ext', 'external', [{ insert: [{ id: 'ext-tool', name: 'ext' }] }])
+    const stack = composeProfileStack(NAME, [base, ext], [
+      { label: '/p/cordis.patch.yml', patches: [
+        { id: 'settings', config: { path: '/x' } },
+        { insert: [{ id: 'mine', name: 'mine' }, { id: 'ext-tool', name: 'clash' }] },
+        { insert: [{ id: 'g', name: 'cordis:group', group: true, config: [{ id: 'tool-bash', name: 'nested-clash' }] }] },
+      ] },
+      { label: '/home/cordis.patch.yml', patches: [
+        { insert: [{ id: 'mine', name: 'twice' }] },
+        { insert: [{ id: 'clean', name: 'clean' }, { name: 'anonymous' } as EntryOptions] },
+      ] },
+    ])
+    expect(stack.layers.map(current => current.label)).toEqual(['@deepseek-ai/dsh-base', 'ext', '/p/cordis.patch.yml', '/home/cordis.patch.yml'])
+    expect(stack.layers[1]?.patches[0]?.insert?.[0]).toMatchObject({ id: 'bundle/ext', name: CONTAINED_GROUP_MODULE })
+    expect(stack.layers[2]?.patches).toEqual([
+      { id: 'settings', config: { path: '/x' } },
+      { insert: [{ id: 'mine', name: 'mine' }] },
+    ])
+    // An insert with no conflict passes through as the same object; an anonymous row claims nothing.
+    expect(stack.layers[3]?.patches).toEqual([{ insert: [{ id: 'clean', name: 'clean' }, { name: 'anonymous' }] }])
+    expect(stack.patches).toEqual(stack.layers.flatMap(current => current.patches))
+    expect(stack.skippedBundles).toEqual([])
+    expect(stack.conflicts).toEqual([
+      { rowId: 'ext-tool', moduleName: 'clash', layer: '/p/cordis.patch.yml', declaredBy: 'ext' },
+      { rowId: 'tool-bash', moduleName: 'cordis:group', layer: '/p/cordis.patch.yml', declaredBy: '@deepseek-ai/dsh-base' },
+      { rowId: 'mine', moduleName: 'twice', layer: '/home/cordis.patch.yml', declaredBy: '/p/cordis.patch.yml' },
+    ])
+  })
+
+  it('leaves a colliding external bundle out of the stack and reports it', () => {
+    const clash = layer('clash', 'external', [{ insert: [{ id: 'settings', name: 'clash' }] }])
+    const stack = composeProfileStack(NAME, [base, clash], [])
+    expect(stack.layers.map(current => current.label)).toEqual(['@deepseek-ai/dsh-base'])
+    expect(stack.skippedBundles).toEqual(['clash'])
+    expect(stack.conflicts).toEqual([
+      { rowId: 'settings', moduleName: 'clash', layer: 'clash', packageName: 'clash', declaredBy: '@deepseek-ai/dsh-base' },
+    ])
+  })
+
+  it('prefixes a built-in duplicate with the binary name', () => {
+    const twin = layer('twin', 'builtin', [{ insert: [{ id: 'settings', name: 'twin' }] }])
+    expect(() => composeProfileStack(NAME, [base, twin], [])).toThrow(/^dsh-test-bin: row "settings" is declared by both/)
+  })
+})
+
+describe('formatRowConflict', () => {
+  it('names the bundle left out, or the user layer whose insert was skipped', () => {
+    expect(formatRowConflict({ rowId: 'x', moduleName: 'm', layer: 'pkg', packageName: 'pkg', declaredBy: 'base' }))
+      .toBe('bundle pkg left out — row "x" is already declared by base')
+    expect(formatRowConflict({ rowId: 'x', moduleName: 'm', layer: '/p/cordis.patch.yml', declaredBy: 'base' }))
+      .toBe('/p/cordis.patch.yml: insert of m skipped — row "x" is already declared by base')
+  })
+})
+
+describe('recordRowConflicts', () => {
+  it('replaces the conflict records of the previous composition and keeps other stages', () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    const registry = ensurePluginFailures(ctx)
+    registry.record({ entryId: 'include:ext/bad', rowId: 'bad', moduleName: 'ext', groupId: 'include:bundle/ext', stage: 'apply', message: 'boom' })
+    recordRowConflicts(ctx, [
+      { rowId: 'hello', moduleName: 'second', layer: 'second', packageName: 'second', declaredBy: 'first' },
+      { rowId: 'mine', moduleName: 'twice', layer: '/home/cordis.patch.yml', declaredBy: '/p/cordis.patch.yml' },
+    ])
+    expect(registry.list()).toEqual([
+      expect.objectContaining({ entryId: 'include:ext/bad', stage: 'apply' }),
+      {
+        entryId: 'conflict:bundle/second:hello', rowId: 'hello', moduleName: 'second', groupId: 'bundle/second',
+        packageName: 'second', stage: 'conflict', message: 'row "hello" is already declared by first',
+      },
+      {
+        entryId: 'conflict:/home/cordis.patch.yml:mine', rowId: 'mine', moduleName: 'twice', groupId: '/home/cordis.patch.yml',
+        stage: 'conflict', message: 'row "mine" is already declared by /p/cordis.patch.yml',
+      },
+    ])
+    recordRowConflicts(ctx, [])
+    expect(registry.list().map(failure => failure.stage)).toEqual(['apply'])
+  })
+})

+ 29 - 16
packages/boot/app-boot/tests/external-bundles.spec.ts

@@ -1,6 +1,7 @@
 /**
- * External bundle composition (one contained, id-prefixed group per bundle)
- * and the manifest operations behind installing, enabling, and disabling.
+ * External bundle composition (one contained group per bundle, ids as
+ * declared) and the manifest operations behind installing, enabling, and
+ * disabling.
  */
 
 import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
@@ -10,8 +11,8 @@ import { describe, expect, it } from 'vitest'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import {
-  bundleGroupId, composeExternalLayer, CONTAINED_GROUP_MODULE, disableBundle, enableBundle, exportsBundlePatch,
-  externalRowId, isJsDisabled, reconcileInstalledBundles, readProfileManifest, type ProfileLayer,
+  bundleGroupId, bundleLayerPatches, composeExternalLayer, CONTAINED_GROUP_MODULE, disableBundle, enableBundle,
+  exportsBundlePatch, isContainedLayer, isJsDisabled, reconcileInstalledBundles, readProfileManifest, type ProfileLayer,
 } from '../src/index.ts'
 
 const NAME = 'dsh-test-bin'
@@ -26,7 +27,7 @@ function layer(packageName: string, patches: PatchOptions[]): ProfileLayer {
 }
 
 describe('composeExternalLayer', () => {
-  it('wraps root inserts in one contained group and prefixes every row id', () => {
+  it('wraps root inserts in one contained group and keeps every row id as declared', () => {
     const composed = composeExternalLayer(layer('pkg-a', [
       { insert: [{ id: 'tool', name: 'pkg-a' }, { name: 'pkg-a/anonymous' } as EntryOptions] },
       { insert: [{ id: 'grp', name: 'cordis:group', group: true, config: [{ id: 'inner', name: 'pkg-a/inner' }] }] },
@@ -37,18 +38,19 @@ describe('composeExternalLayer', () => {
         name: CONTAINED_GROUP_MODULE,
         group: true,
         config: [
-          { id: 'pkg-a/tool', name: 'pkg-a' },
+          { id: 'tool', name: 'pkg-a' },
           { name: 'pkg-a/anonymous' },
-          { id: 'pkg-a/grp', name: 'cordis:group', group: true, config: [{ id: 'pkg-a/inner', name: 'pkg-a/inner' }] },
+          { id: 'grp', name: 'cordis:group', group: true, config: [{ id: 'inner', name: 'pkg-a/inner' }] },
         ],
       }],
     }])
-    expect([...composed.rows.keys()]).toEqual(['pkg-a/tool', 'pkg-a/grp', 'pkg-a/inner', 'bundle/pkg-a'])
-    expect(composed.rows.get('pkg-a/inner')).toEqual({ packageName: 'pkg-a', originalId: 'inner' })
+    expect([...composed.rows]).toEqual([
+      ['tool', 'pkg-a'], ['grp', 'cordis:group'], ['inner', 'pkg-a/inner'], ['bundle/pkg-a', CONTAINED_GROUP_MODULE],
+    ])
     expect(composed.overrides).toEqual([])
   })
 
-  it('rewrites patches that address the bundle\'s own rows and passes overrides of built-in rows through', () => {
+  it('passes patches on the bundle\'s own rows through and reports patches on other rows as overrides', () => {
     const composed = composeExternalLayer(layer('pkg-b', [
       { insert: [{ id: 'own', name: 'pkg-b' }] },
       { id: 'own', config: { flag: true } },
@@ -56,8 +58,8 @@ describe('composeExternalLayer', () => {
       { id: 'own', insert: [{ id: 'child', name: 'pkg-b/child' }] },
     ]))
     expect(composed.patches.slice(1)).toEqual([
-      { id: 'pkg-b/own', insert: [{ id: 'pkg-b/child', name: 'pkg-b/child' }] },
-      { id: 'pkg-b/own', config: { flag: true } },
+      { id: 'own', insert: [{ id: 'child', name: 'pkg-b/child' }] },
+      { id: 'own', config: { flag: true } },
       { id: 'settings', config: { path: '/x' } },
     ])
     expect(composed.overrides).toEqual(['settings'])
@@ -71,10 +73,10 @@ describe('composeExternalLayer', () => {
       { insert: [{ id: 'bundle/pkg-c', name: CONTAINED_GROUP_MODULE, group: true, config: [] }] },
       {
         id: 'persistent-shell',
-        insert: [{ id: 'bundle/pkg-c/in/persistent-shell', name: CONTAINED_GROUP_MODULE, group: true, config: [{ id: 'pkg-c/extra', name: 'pkg-c/extra' }] }],
+        insert: [{ id: 'bundle/pkg-c/in/persistent-shell', name: CONTAINED_GROUP_MODULE, group: true, config: [{ id: 'extra', name: 'pkg-c/extra' }] }],
       },
     ])
-    expect(composed.rows.has('bundle/pkg-c/in/persistent-shell')).toBe(true)
+    expect(composed.rows.get('bundle/pkg-c/in/persistent-shell')).toBe(CONTAINED_GROUP_MODULE)
   })
 
   it('never mutates the layer\'s own patch objects', () => {
@@ -84,12 +86,23 @@ describe('composeExternalLayer', () => {
     expect(patches).toEqual(snapshot)
   })
 
-  it('spells ids without the Loader\'s nested-id separator', () => {
+  it('spells the group id without the Loader\'s nested-id separator', () => {
     expect(bundleGroupId('@scope/pkg')).toBe('bundle/@scope/pkg')
-    expect(externalRowId('@scope/pkg', 'row')).toBe('@scope/pkg/row')
     expect(bundleGroupId('x')).not.toContain(':')
   })
 
+  it('mounts only runtime-staged external layers as contained groups', () => {
+    const contained = layer('pkg-e', [{ insert: [{ id: 'row', name: 'pkg-e' }] }])
+    const booted: ProfileLayer = { ...contained, stage: 'boot' }
+    const builtin: ProfileLayer = { ...contained, trust: 'builtin' }
+    expect(isContainedLayer(contained)).toBe(true)
+    expect(bundleLayerPatches(contained)[0]?.insert?.[0]?.id).toBe('bundle/pkg-e')
+    for (const plain of [booted, builtin]) {
+      expect(isContainedLayer(plain)).toBe(false)
+      expect(bundleLayerPatches(plain)).toBe(plain.patches)
+    }
+  })
+
   it('tells a !!js disabled node from a literal', () => {
     expect(isJsDisabled({ __jsExpr: 'true' })).toBe(true)
     expect(isJsDisabled(true)).toBe(false)

+ 30 - 9
packages/boot/app-boot/tests/profile-runtime.spec.ts

@@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import { ProfileRuntime, type Profile, type ProfileLayer } from '../src/index.ts'
+import { ensurePluginFailures, ProfileRuntime, type ComposedStack, type Profile, type ProfileLayer } from '../src/index.ts'
 
 const contexts: Context[] = []
 afterEach(async () => {
@@ -24,11 +24,19 @@ function profile(layers: ProfileLayer[]): Profile {
 
 async function harness(
   layers: ProfileLayer[],
-  options: { rootEntry?: () => Entry | undefined; userPatches?: PatchOptions[]; reloaded?: Profile } = {},
+  options: { rootEntry?: () => Entry | undefined; userPatches?: PatchOptions[]; reloaded?: Profile; conflicts?: ComposedStack['conflicts'] } = {},
 ): Promise<{ ctx: Context; runtime: ProfileRuntime; compose: ReturnType<typeof vi.fn> }> {
   const ctx = new Context()
   contexts.push(ctx)
-  const compose = vi.fn((current: Profile) => [{ id: `composed-for-${current.layers.length}` }] as PatchOptions[])
+  const compose = vi.fn((current: Profile): ComposedStack => {
+    const patches = [{ id: `composed-for-${current.layers.length}` }] as PatchOptions[]
+    return {
+      patches,
+      layers: [{ label: 'stack', patches }],
+      conflicts: options.conflicts ?? [],
+      skippedBundles: [],
+    }
+  })
   await ctx.plugin(ProfileRuntime, {
     profile: profile(layers),
     installAnchor: '/install/package.json',
@@ -52,18 +60,20 @@ describe('ProfileRuntime', () => {
     expect(runtime.current.name).toBe('web')
   })
 
-  it('attributes rows to the layer that inserted them, prefixed for external layers', async () => {
+  it('attributes rows to the layer that owns their id', async () => {
     const { runtime } = await harness([
       layer('@deepseek-ai/dsh-base', 'builtin', [{ insert: [{ id: 'settings', name: 'x' }, { name: 'anonymous' } as EntryOptions, { id: 'grp', name: 'cordis:group', group: true, config: [{ id: 'child', name: 'y' }] }] }]),
       layer('ext', 'external', [{ insert: [{ id: 'tool', name: 'ext' }] }]),
       layer('boot-ext', 'external', [{ insert: [{ id: 'svc', name: 'boot-ext' }] }], 'boot'),
+      // Declares an id the first external layer owns: left out whole, so none of its rows has an origin.
+      layer('late', 'external', [{ insert: [{ id: 'tool', name: 'late' }, { id: 'late-only', name: 'late/x' }] }]),
     ])
     expect(runtime.originOf('settings')).toEqual({ trust: 'builtin', packageName: '@deepseek-ai/dsh-base', version: '2.0.0' })
     expect(runtime.originOf('child')).toEqual({ trust: 'builtin', packageName: '@deepseek-ai/dsh-base', version: '2.0.0' })
-    expect(runtime.originOf('ext/tool')).toEqual({ trust: 'external', packageName: 'ext', version: '2.0.0', originalId: 'tool' })
-    expect(runtime.originOf('bundle/ext')).toEqual({ trust: 'external', packageName: 'ext', version: '2.0.0', originalId: 'bundle/ext' })
-    // A boot-stage external layer mounts unprefixed, like a built-in one.
+    expect(runtime.originOf('tool')).toEqual({ trust: 'external', packageName: 'ext', version: '2.0.0' })
+    expect(runtime.originOf('bundle/ext')).toEqual({ trust: 'external', packageName: 'ext', version: '2.0.0' })
     expect(runtime.originOf('svc')).toEqual({ trust: 'external', packageName: 'boot-ext', version: '2.0.0' })
+    expect(runtime.originOf('late-only')).toBeUndefined()
     expect(runtime.originOf('user-row')).toBeUndefined()
   })
 
@@ -95,17 +105,28 @@ describe('ProfileRuntime', () => {
     const update = vi.fn(async () => {})
     const entry = { options: { config: { path: 'file:///root/cordis.yml', patches: [{ id: 'old' }] } }, update } as unknown as Entry
     const reloaded = profile([layer('a', 'builtin', []), layer('b', 'external', [])])
-    const { runtime, compose } = await harness([layer('a', 'builtin', [])], { rootEntry: () => entry, reloaded })
+    const conflicts = [{ rowId: 'x', moduleName: 'm', layer: 'late', packageName: 'late', declaredBy: 'a' }]
+    const { ctx, runtime, compose } = await harness([layer('a', 'builtin', [])], { rootEntry: () => entry, reloaded, conflicts })
 
     await runtime.recompose()
     expect(compose).toHaveBeenLastCalledWith(expect.objectContaining({ layers: expect.any(Array) as ProfileLayer[] }))
     expect(update).toHaveBeenLastCalledWith({ config: { path: 'file:///root/cordis.yml', patches: [{ id: 'composed-for-1' }] } })
+    // The stack's conflicts become the registry's conflict records once the update holds.
+    expect(ensurePluginFailures(ctx).list()).toEqual([expect.objectContaining({ stage: 'conflict', rowId: 'x', packageName: 'late' })])
 
     await runtime.recompose({ reloadBundles: true })
     expect(runtime.layers).toHaveLength(2)
     expect(update).toHaveBeenLastCalledWith({ config: { path: 'file:///root/cordis.yml', patches: [{ id: 'composed-for-2' }] } })
     // Provenance follows the reloaded profile.
-    expect(runtime.originOf('bundle/b')).toEqual({ trust: 'external', packageName: 'b', version: '2.0.0', originalId: 'bundle/b' })
+    expect(runtime.originOf('bundle/b')).toEqual({ trust: 'external', packageName: 'b', version: '2.0.0' })
+  })
+
+  it('leaves the registry untouched when the root include rejects the update', async () => {
+    const entry = { options: { config: { path: 'file:///root/cordis.yml' } }, update: vi.fn(async () => { throw new Error('rejected') }) } as unknown as Entry
+    const conflicts = [{ rowId: 'x', moduleName: 'm', layer: 'late', packageName: 'late', declaredBy: 'a' }]
+    const { ctx, runtime } = await harness([layer('a', 'builtin', [])], { rootEntry: () => entry, conflicts })
+    await expect(runtime.recompose()).rejects.toThrow('rejected')
+    expect(ctx.get('pluginFailures')).toBeUndefined()
   })
 
   it('refuses to recompose before the root include is mounted', async () => {

+ 5 - 5
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -1380,8 +1380,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       {
         signature: 'originOf(rowId: string): RowOrigin | undefined',
         description: 'Where one mounted row came from.',
-        parameters: [{ name: 'rowId', description: 'the row\'s tree-wide id.' }],
-        returns: 'the origin, or undefined for a row no bundle layer inserted (a user or overlay row).',
+        parameters: [{ name: 'rowId', description: 'the row\'s id as the composition declares it.' }],
+        returns: 'the origin, or undefined for a row no bundle layer owns (a user or overlay row, or a bundle left out by a conflict).',
       },
       {
         signature: 'userDisabledRowIds(): Set<string>',
@@ -1391,7 +1391,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: 'async recompose(options: { reloadBundles?: boolean } = {}): Promise<void>',
-        description: 'Recompose the host tree from the profile\'s layers and the user patch files as they stand now. The root Include re-applies the stack transactionally: a row whose options changed is updated in place, a row that appeared is created, a row that vanished is disposed, and a failure rolls the whole update back with the previous tree still running.',
+        description: 'Recompose the host tree from the profile\'s layers and the user patch files as they stand now. The root Include re-applies the stack transactionally: a row whose options changed is updated in place, a row that appeared is created, a row that vanished is disposed, and a failure rolls the whole update back with the previous tree still running. The rows the stack left out replace the failure registry\'s conflict records once the update holds.',
         parameters: [{ name: 'options', description: '`reloadBundles` re-reads the profile manifest first, so a bundle enabled or installed since boot joins the stack.' }],
         throws: ['when the root include is not mounted, or the Loader rejected the update.'],
       },
@@ -4662,7 +4662,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'PluginPackageRowView',
-    declaration: 'export interface PluginPackageRowView {\n    readonly entryId: string;\n    readonly originalId?: string;\n    readonly moduleName: string;\n    readonly enabled: boolean;\n    readonly disabledBy?: \'user\' | \'composition\';\n    readonly phase: PluginRowPhase;\n    readonly failure?: {\n        readonly stage: string;\n        readonly message: string;\n    };\n}',
+    declaration: 'export interface PluginPackageRowView {\n    readonly entryId: string;\n    readonly rowId: string;\n    readonly moduleName: string;\n    readonly enabled: boolean;\n    readonly disabledBy?: \'user\' | \'composition\';\n    readonly phase: PluginRowPhase;\n    readonly failure?: {\n        readonly stage: string;\n        readonly message: string;\n    };\n}',
   },
   {
     name: 'PluginPackageStage',
@@ -4898,7 +4898,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'RowOrigin',
-    declaration: 'export interface RowOrigin {\n    readonly trust: BundleTrust;\n    readonly packageName: string;\n    readonly version?: string;\n    readonly originalId?: string;\n}',
+    declaration: 'export interface RowOrigin {\n    readonly trust: BundleTrust;\n    readonly packageName: string;\n    readonly version?: string;\n}',
   },
   {
     name: 'RunnerFailureRule',

+ 6 - 3
packages/host/plugin-inventory/src/index.ts

@@ -27,11 +27,10 @@ function pluginEntryId(value: string): PluginEntryId {
 }
 
 /** The wire view of a row's package origin. */
-function packageRef(origin: { packageName: string; version?: string; originalId?: string }): PluginPackageRef {
+function packageRef(origin: { packageName: string; version?: string }): PluginPackageRef {
   return {
     name: origin.packageName,
     ...origin.version === undefined ? {} : { version: origin.version },
-    ...origin.originalId === undefined ? {} : { originalId: origin.originalId },
   }
 }
 
@@ -105,7 +104,11 @@ export class PluginInventoryGateway extends TypertRemoteService {
     // registry is its only record, and the list must still show it.
     for (const failure of failures?.list() ?? []) {
       if (listed.has(failure.entryId)) continue
-      const origin = runtime?.originOf(failure.rowId)
+      // A conflict record names the bundle that lost the id; asking the
+      // runtime would name the layer that owns it.
+      const origin = failure.stage === 'conflict'
+        ? failure.packageName === undefined ? undefined : { trust: 'external' as const, packageName: failure.packageName }
+        : runtime?.originOf(failure.rowId)
       entries.push({
         entryId: pluginEntryId(failure.entryId),
         moduleName: failure.moduleName,

+ 3 - 5
packages/host/plugin-inventory/src/types.ts

@@ -24,14 +24,12 @@ export interface PluginPackageRef {
   readonly name: string
   /** The package version, when its manifest declares one. */
   readonly version?: string
-  /** For an external row: the id the bundle's own patch declared, before prefixing. */
-  readonly originalId?: string
 }
 
-/** A recorded startup failure of a row inside an isolated external bundle. */
+/** A recorded failure of a row inside an isolated external bundle, or a row the composition left out. */
 export interface PluginFailure {
-  /** The lifecycle step that failed. */
-  readonly stage: 'import' | 'apply' | 'inject-pending' | 'unknown'
+  /** The lifecycle step that failed; `conflict` is a row another layer already declares. */
+  readonly stage: 'import' | 'apply' | 'inject-pending' | 'conflict' | 'unknown'
   /** The failure text. */
   readonly message: string
 }

+ 43 - 0
packages/host/plugin-inventory/tests/inventory.spec.ts

@@ -3,6 +3,7 @@ import { Context, FiberState, type Plugin } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
 import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets'
+import { ensurePluginFailures, type ProfileRuntime } from '@deepseek-ai/dsh-app-boot'
 import PluginInventoryGateway from '../src/index.ts'
 
 const contexts: Context[] = []
@@ -96,6 +97,48 @@ describe('PluginInventoryGateway', () => {
     expect((await inventory.list()).entries.some(entry => entry.entryId === pendingId)).toBe(false)
   })
 
+  it('attributes rows to their bundle through the profile runtime and lists recorded failures', async () => {
+    const { ctx, inventory } = await harness()
+    const versioned = await ctx.loader.create({ id: 'versioned', name: 'cordis:active' })
+    const bare = await ctx.loader.create({ id: 'bare', name: 'cordis:active' })
+    const off = await ctx.loader.create({ id: 'off', name: 'cordis:active', disabled: true })
+    const origins = new Map([
+      ['versioned', { trust: 'external', packageName: 'ext', version: '1.2.3' }],
+      ['bare', { trust: 'external', packageName: 'ext' }],
+      ['off', { trust: 'external', packageName: 'ext' }],
+      ['gone', { trust: 'external', packageName: 'ext' }],
+    ] as const)
+    ctx.provide('profileRuntime', {
+      originOf: (rowId: string) => origins.get(rowId as never),
+      userDisabledRowIds: () => new Set(['off']),
+    } as Partial<ProfileRuntime> as never)
+    const registry = ensurePluginFailures(ctx)
+    registry.record({ entryId: 'gone', rowId: 'gone', moduleName: 'cordis:throws', groupId: 'bundle/ext', stage: 'apply', message: 'boom' })
+    // A record of a row that later mounted rides on the live entry and is not listed twice.
+    registry.record({ entryId: bare, rowId: 'bare', moduleName: 'cordis:active', groupId: 'bundle/ext', stage: 'import', message: 'stale' })
+    registry.record({
+      entryId: 'conflict:bundle/late:versioned', rowId: 'versioned', moduleName: 'late', groupId: 'bundle/late',
+      packageName: 'late', stage: 'conflict', message: 'row "versioned" is already declared by ext',
+    })
+    registry.record({
+      entryId: 'conflict:/home/cordis.patch.yml:bare', rowId: 'bare', moduleName: 'mine', groupId: '/home/cordis.patch.yml',
+      stage: 'conflict', message: 'row "bare" is already declared by ext',
+    })
+
+    const { entries } = await inventory.list()
+    expect(entries).toEqual([
+      { entryId: versioned, moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', trust: 'external', package: { name: 'ext', version: '1.2.3' } },
+      { entryId: bare, moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', trust: 'external', package: { name: 'ext' }, failure: { stage: 'import', message: 'stale' } },
+      { entryId: off, moduleName: 'cordis:active', enabled: false, fiberPhase: null, trust: 'external', package: { name: 'ext' }, disabledBy: 'user' },
+      // A failed row the tree no longer holds is attributed through the runtime.
+      { entryId: 'gone', moduleName: 'cordis:throws', enabled: true, fiberPhase: 'failed', trust: 'external', package: { name: 'ext' }, failure: { stage: 'apply', message: 'boom' } },
+      // A conflict names the bundle that lost the id; the runtime would name the owner.
+      { entryId: 'conflict:bundle/late:versioned', moduleName: 'late', enabled: true, fiberPhase: 'failed', trust: 'external', package: { name: 'late' }, failure: { stage: 'conflict', message: 'row "versioned" is already declared by ext' } },
+      // A user-layer conflict belongs to no package.
+      { entryId: 'conflict:/home/cordis.patch.yml:bare', moduleName: 'mine', enabled: true, fiberPhase: 'failed', trust: 'external', failure: { stage: 'conflict', message: 'row "bare" is already declared by ext' } },
+    ])
+  })
+
   it('carries each composed preset with root-fiber states mapped to phases', async () => {
     const { ctx, inventory } = await harness()
     ctx.provide('agentPresets', {

+ 2 - 2
packages/host/plugin-manager/README.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 packages/host/plugin-manager/README.md
-README.md: 4e6b9fd69ad1aaa9e760c8ad1db4378ef0f0334f
-README.zh.md: 6f1d5796f7b8008ffa5a30336da166d6f8bf852e
+README.md: 6541e5230616643fe7c4dc2f2ff10d2ac52e2bb7
+README.zh.md: ef1bb5c2214dd18f4cdec35ba616bb84aeb96f93

+ 1 - 1
packages/host/plugin-manager/README.md

@@ -29,7 +29,7 @@ Mount the row in a host composition beside the plugin inventory; the web bundle
 
 ### What a package view says
 
-`plugins/list` returns one view per package the profile knows: its template and installed bundles and every other installed dependency. A view carries the manifest facts (name, version, title, description, `engines.dsh`), what the package is (`bundle`, `plugin`, or `library`), who supplied it (`builtin` or `external`), when its rows mount (`boot` or `runtime`), whether it is installed and enabled, and a folded `status`: `running`, `partial`, or `failed` for an enabled bundle by how many of its rows are active; `disabled` for an installed bundle outside the layer list; `not-enableable` when the probe refused it, with the reason; `restart-required` when the manifest and the live tree disagree on a profile that applies changes at its next start; `plain` for a library or plugin module, which is added to a composition rather than enabled. Rows come from the live tree while the bundle is composed — phase, disabled-by, and the recorded failure of an isolated row — and from the probe record otherwise, with the ids the launcher will prefix them with. `addable` lists the modules the package declares in `dsh.plugins`, each with its default config and the probe's verdict.
+`plugins/list` returns one view per package the profile knows: its template and installed bundles and every other installed dependency. A view carries the manifest facts (name, version, title, description, `engines.dsh`), what the package is (`bundle`, `plugin`, or `library`), who supplied it (`builtin` or `external`), when its rows mount (`boot` or `runtime`), whether it is installed and enabled, and a folded `status`: `running`, `partial`, or `failed` for an enabled bundle by how many of its rows are active; `disabled` for an installed bundle outside the layer list; `not-enableable` when the probe refused it, with the reason; `restart-required` when the manifest and the live tree disagree on a profile that applies changes at its next start; `plain` for a library or plugin module, which is added to a composition rather than enabled. Rows come from the live tree while the bundle is composed — phase, disabled-by, and the recorded failure of an isolated row — and from the probe record otherwise, under the ids their patches declare. `addable` lists the modules the package declares in `dsh.plugins`, each with its default config and the probe's verdict.
 
 ### Installing and enabling
 

+ 1 - 1
packages/host/plugin-manager/README.zh.md

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 ### 一份包视图说了什么
 
-`plugins/list` 为 profile 知道的每个包返回一份视图:模板组合包与已安装的组合包,以及其他每个已安装的依赖。视图携带 manifest 事实(名字、版本、标题、描述、`engines.dsh`),这个包是什么(`bundle`、`plugin` 或 `library`),谁提供它(`builtin` 或 `external`),它的行何时挂载(`boot` 或 `runtime`),是否已安装与已启用,以及折叠出的 `status`:已启用的组合包按活跃行的多少是 `running`、`partial` 或 `failed`;已安装但不在层列表中的组合包是 `disabled`;探针拒绝时是 `not-enableable` 并附原因;在启动时才应用变更的 profile 上 manifest 与在线树不一致时是 `restart-required`;库或插件模块是 `plain`,它们被添加进组合而不是被启用。组合包已组合时行来自在线树——阶段、被谁停用,以及隔离行记录的失败——否则来自探针记录,并带上 launcher 将加的前缀 id。`addable` 列出包在 `dsh.plugins` 里声明的模块,各自带默认配置与探针的判定。
+`plugins/list` 为 profile 知道的每个包返回一份视图:模板组合包与已安装的组合包,以及其他每个已安装的依赖。视图携带 manifest 事实(名字、版本、标题、描述、`engines.dsh`),这个包是什么(`bundle`、`plugin` 或 `library`),谁提供它(`builtin` 或 `external`),它的行何时挂载(`boot` 或 `runtime`),是否已安装与已启用,以及折叠出的 `status`:已启用的组合包按活跃行的多少是 `running`、`partial` 或 `failed`;已安装但不在层列表中的组合包是 `disabled`;探针拒绝时是 `not-enableable` 并附原因;在启动时才应用变更的 profile 上 manifest 与在线树不一致时是 `restart-required`;库或插件模块是 `plain`,它们被添加进组合而不是被启用。组合包已组合时行来自在线树——阶段、被谁停用,以及隔离行记录的失败——否则来自探针记录,id 保持各自 patch 声明的样子。`addable` 列出包在 `dsh.plugins` 里声明的模块,各自带默认配置与探针的判定。
 
 ### 安装与启用
 

+ 9 - 10
packages/host/plugin-manager/src/index.ts

@@ -27,7 +27,6 @@ import {
   bundleGroupId,
   disableBundle,
   enableBundle,
-  externalRowId,
   healProfilesModuleFallback,
   layerTrust,
   loadProfile,
@@ -220,7 +219,7 @@ export class PluginManager extends TypertRemoteService {
       ?? (manifest.dsh?.profile?.stages?.[name] ?? packageManifest?.dsh?.bundle?.stage ?? 'runtime')
     const kind = probe?.kind ?? (layer !== undefined || packageManifest?.dsh?.bundle !== undefined ? 'bundle' : 'library')
     const composed = layer !== undefined
-    const rows = composed ? this.composedRows(runtime, name) : this.probedRows(name, trust, probe)
+    const rows = composed ? this.composedRows(runtime, name) : this.probedRows(probe)
     const status = this.status({ kind, installed, enabled, composed, liveReload, probe, probeFailure, rows })
     const reason = probeFailure ?? probe?.reason ?? (status === 'restart-required'
       ? 'the profile applies layer changes at its next start'
@@ -279,11 +278,10 @@ export class PluginManager extends TypertRemoteService {
     const listed = new Set<string>()
     for (const { entry, rowId } of this.ownedEntries(runtime, name)) {
       listed.add(entry.id)
-      const origin = runtime.originOf(rowId)
       const failure = failures?.get(entry.id)
       rows.push({
         entryId: entry.id,
-        ...optional('originalId', origin?.originalId),
+        rowId,
         moduleName: entry.options.name,
         enabled: !entry.disabled,
         ...entry.disabled ? { disabledBy: userDisabled.has(rowId) ? 'user' as const : 'composition' as const } : {},
@@ -294,11 +292,12 @@ export class PluginManager extends TypertRemoteService {
     /* v8 ignore next -- the root include provides the registry on every boot; the guard answers its optional type */
     for (const failure of failures?.list() ?? []) {
       if (listed.has(failure.entryId)) continue
-      const origin = runtime.originOf(failure.rowId)
-      if (origin?.packageName !== name) continue
+      // A conflict record names its bundle: the id's owner is the other layer.
+      const owner = failure.packageName ?? runtime.originOf(failure.rowId)?.packageName
+      if (owner !== name) continue
       rows.push({
         entryId: failure.entryId,
-        ...optional('originalId', origin.originalId),
+        rowId: failure.rowId,
         moduleName: failure.moduleName,
         enabled: true,
         phase: 'failed',
@@ -321,10 +320,10 @@ export class PluginManager extends TypertRemoteService {
   }
 
   /** The rows a bundle would contribute, from its probe record, when it is not composed. */
-  private probedRows(name: string, trust: PluginPackageView['trust'], probe: PluginProbe | undefined): PluginPackageRowView[] {
+  private probedRows(probe: PluginProbe | undefined): PluginPackageRowView[] {
     return (probe?.rows ?? []).map(row => ({
-      entryId: row.id === undefined ? row.name : trust === 'external' ? externalRowId(name, row.id) : row.id,
-      ...optional('originalId', row.id),
+      entryId: row.id ?? row.name,
+      rowId: row.id ?? row.name,
       moduleName: row.name,
       enabled: !row.gated,
       ...row.gated ? { disabledBy: 'composition' as const } : {},

+ 3 - 3
packages/host/plugin-manager/src/types.ts

@@ -41,10 +41,10 @@ export type PluginRowPhase = 'pending' | 'loading' | 'active' | 'failed' | 'unlo
 
 /** One row a bundle contributes to the host tree, as the tree runs it. */
 export interface PluginPackageRowView {
-  /** The row's tree-wide id, prefixed for an isolated bundle. */
+  /** The row's tree-wide id, as `Entry.id` spells it (`include:<row id>` under the root include). */
   readonly entryId: string
-  /** The id the bundle's own patch declared, when the launcher prefixed it. */
-  readonly originalId?: string
+  /** The row id as the composition declares it — what a user patch or a row action targets. */
+  readonly rowId: string
   /** Module specifier the row names. */
   readonly moduleName: string
   /** Effective enablement, including a disabled owning group. */

+ 54 - 27
packages/host/plugin-manager/tests/plugin-manager.spec.ts

@@ -15,10 +15,9 @@ import type { ChildProcess } from 'node:child_process'
 import { afterEach, describe, expect, it } from 'vitest'
 import { Context, type Plugin } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
-import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import {
-  boot, bundleLayerPatches, loadOptionalPatches, loadProfile, ProfileRuntime, readProbeCache, rootIncludeEntry,
-  type Profile,
+  boot, composeProfileStack, recordRowConflicts, loadOptionalPatches, loadProfile, ProfileRuntime, readProbeCache, rootIncludeEntry,
+  type ComposedStack, type Profile,
 } from '@deepseek-ai/dsh-app-boot'
 import { remoteMethods, RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import PluginManager, {
@@ -194,12 +193,14 @@ interface Booted {
 /** Boot the profile the way the launcher does, then mount the manager over it. */
 async function bootProfile(staged: StagedHome, internals: PluginManagerInternals = {}, config: Partial<PluginManager['config']> = {}): Promise<Booted> {
   const load = (): Profile => loadProfile(NAME, 'web', staged.anchor, staged.home)
-  const composeFor = (profile: Profile): PatchOptions[] => structuredClone([
-    ...profile.layers.flatMap(bundleLayerPatches),
-    ...loadOptionalPatches(NAME, profile.patchPath) ?? [],
-  ])
+  const composeFor = (profile: Profile): ComposedStack => {
+    const stack = composeProfileStack(NAME, profile.layers, [
+      { label: profile.patchPath, patches: loadOptionalPatches(NAME, profile.patchPath) ?? [] },
+    ])
+    return { ...stack, patches: structuredClone(stack.patches) }
+  }
   const profile = load()
-  const ctx = await boot(NAME, join(staged.profileDir, 'cordis.yml'), composeFor(profile), prepare)
+  const ctx = await boot(NAME, join(staged.profileDir, 'cordis.yml'), composeFor(profile).patches, prepare)
   contexts.push(ctx)
   await ctx.plugin(ProfileRuntime, {
     profile,
@@ -209,6 +210,8 @@ async function bootProfile(staged: StagedHome, internals: PluginManagerInternals
     rootEntry: () => rootIncludeEntry(ctx),
     readUserPatches: () => loadOptionalPatches(NAME, profile.patchPath) ?? [],
   })
+  // As the launcher does after boot: the runtime records conflicts only on its own recompositions.
+  recordRowConflicts(ctx, composeFor(profile).conflicts)
   class TestManager extends PluginManager {
     constructor(context: Context, managerConfig: PluginManager['config']) {
       super(context, managerConfig, internals)
@@ -266,8 +269,8 @@ describe('PluginManager', () => {
       ])
       const bundle = views[0]
       expect(bundle).toMatchObject({ version: '1.0.0', title: 'Title of ext-bundle', description: 'staged ext-bundle', stage: 'runtime', liveReload: true })
-      // Rows come from the probe while the bundle is not composed, prefixed the way the launcher will prefix them.
-      expect(bundle?.rows).toEqual([{ entryId: 'ext-bundle/hello', originalId: 'hello', moduleName: 'cordis:good', enabled: true, phase: null }])
+      // Rows come from the probe while the bundle is not composed, under the ids the patch declares.
+      expect(bundle?.rows).toEqual([{ entryId: 'hello', rowId: 'hello', moduleName: 'cordis:good', enabled: true, phase: null }])
       expect(bundle?.addable).toEqual([{ moduleName: 'ext-bundle/extra.js', declaredName: './extra.js', title: 'Extra', ok: true }])
       expect(bundle?.probedAt).toEqual(expect.any(String) as string)
       expect(readProbeCache(staged.profileDir, 'ext-bundle', '1.0.0')?.kind).toBe('bundle')
@@ -287,11 +290,35 @@ describe('PluginManager', () => {
       expect(view).toMatchObject({ name: 'ext-mixed', status: 'partial', enabled: true })
       expect(view?.reason).toContain('boom at apply')
       expect(view?.rows.map(row => [row.entryId, row.phase, row.failure?.stage])).toEqual([
-        ['include:ext-mixed/ok', 'active', undefined],
-        ['include:ext-mixed/bad', 'failed', 'apply'],
+        ['include:ok', 'active', undefined],
+        ['include:bad', 'failed', 'apply'],
       ])
     })
 
+    it('lists a bundle left out by an id conflict as failed, with the conflict as its row', async () => {
+      const staged = await stageHome()
+      stagePackage(staged.profileDir, 'ext-one', { patch: BUNDLE_ONE_ROW })
+      addDependency(staged.profileDir, 'ext-one')
+      stagePackage(staged.profileDir, 'ext-two', { patch: BUNDLE_ONE_ROW })
+      addDependency(staged.profileDir, 'ext-two')
+      const manifest = manifestOf(staged.profileDir)
+      manifest.dsh.profile.bundles.push('ext-one', 'ext-two')
+      writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
+      const { ctx, manager } = await bootProfile(staged)
+
+      const views = await manager.list()
+
+      expect(entryIds(ctx)).toContain('include:hello')
+      expect(views.find(view => view.name === 'ext-one')).toMatchObject({ status: 'running' })
+      const two = views.find(view => view.name === 'ext-two')
+      expect(two).toMatchObject({ status: 'failed', enabled: true })
+      expect(two?.reason).toContain('already declared by ext-one')
+      expect(two?.rows).toEqual([{
+        entryId: 'conflict:bundle/ext-two:hello', rowId: 'hello', moduleName: 'cordis:good', enabled: true, phase: 'failed',
+        failure: { stage: 'conflict', message: 'row "hello" is already declared by ext-one' },
+      }])
+    })
+
     it('reports a package the probe refuses as not enableable, and a stale layer as restart-required', async () => {
       const staged = await stageHome('startup')
       stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' })
@@ -363,25 +390,25 @@ describe('PluginManager', () => {
       const { manager } = await bootProfile(staged)
       await manager.enable('ext-off')
       await manager.enable('ext-waiting')
-      await manager.setRowDisabled({ kind: 'global' }, 'ext-off/b', true)
+      await manager.setRowDisabled({ kind: 'global' }, 'b', true)
 
       const views = await manager.list()
 
       const off = views.find(view => view.name === 'ext-off')
       expect(off).toMatchObject({ status: 'running' })
-      expect(off?.rows.map(row => [row.originalId, row.enabled, row.disabledBy, row.phase])).toEqual([
+      expect(off?.rows.map(row => [row.rowId, row.enabled, row.disabledBy, row.phase])).toEqual([
         ['a', false, 'composition', null],
         ['b', false, 'user', null],
       ])
-      await manager.setRowDisabled({ kind: 'global' }, 'ext-off/b', true)
+      await manager.setRowDisabled({ kind: 'global' }, 'b', true)
       const waiting = views.find(view => view.name === 'ext-waiting')
       expect(waiting).toMatchObject({ status: 'failed', reason: expect.stringContaining('fixtureSvc') as string })
-      expect(waiting?.rows).toEqual([expect.objectContaining({ entryId: 'include:ext-waiting/w', phase: 'pending', failure: expect.objectContaining({ stage: 'inject-pending' }) as object })])
+      expect(waiting?.rows).toEqual([expect.objectContaining({ entryId: 'include:w', phase: 'pending', failure: expect.objectContaining({ stage: 'inject-pending' }) as object })])
       // Another bundle's recorded failure is not this one's row.
       expect(off?.rows.some(row => row.failure !== undefined)).toBe(false)
     })
 
-    it('reads anonymous and gated probe rows, unprefixed for a first-party package', async () => {
+    it('reads anonymous and gated probe rows', async () => {
       const staged = await stageHome()
       stagePackage(staged.profileDir, 'ext-anon', { patch: '- insert:\n    - name: cordis:good\n    - id: gated\n      name: cordis:good\n      disabled: true\n' })
       addDependency(staged.profileDir, 'ext-anon')
@@ -395,8 +422,8 @@ describe('PluginManager', () => {
       const views = await manager.list()
 
       expect(views.find(view => view.name === 'ext-anon')?.rows).toEqual([
-        { entryId: 'cordis:good', moduleName: 'cordis:good', enabled: true, phase: null },
-        { entryId: 'ext-anon/gated', originalId: 'gated', moduleName: 'cordis:good', enabled: false, disabledBy: 'composition', phase: null },
+        { entryId: 'cordis:good', rowId: 'cordis:good', moduleName: 'cordis:good', enabled: true, phase: null },
+        { entryId: 'gated', rowId: 'gated', moduleName: 'cordis:good', enabled: false, disabledBy: 'composition', phase: null },
       ])
       await manager.enable('fp-bundle')
       const firstParty = (await manager.list()).find(view => view.name === 'fp-bundle')
@@ -436,7 +463,7 @@ describe('PluginManager', () => {
 
       expect(result).toMatchObject({ installed: ['ext-new'], enabled: ['ext-new'], installedOnly: [], plain: [] })
       expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-new'])
-      expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:bundle/ext-new', 'include:ext-new/hello']))
+      expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:bundle/ext-new', 'include:hello']))
       expect(readProbeCache(staged.profileDir, 'ext-new')).toBeDefined()
       expect(changes.map(change => change.reason)).toEqual(['enable', 'install'])
       expect((await manager.list()).find(view => view.name === 'ext-new')?.status).toBe('running')
@@ -504,10 +531,10 @@ describe('PluginManager', () => {
       const { ctx, manager, changes } = await bootProfile(staged)
 
       expect(await manager.enable('ext-bundle')).toEqual({ changed: true, effect: 'live' })
-      expect(entryIds(ctx)).toContain('include:ext-bundle/hello')
+      expect(entryIds(ctx)).toContain('include:hello')
       expect(await manager.enable('ext-bundle')).toEqual({ changed: false, effect: 'live' })
       expect(await manager.disable('ext-bundle')).toEqual({ changed: true, effect: 'live' })
-      expect(entryIds(ctx)).not.toContain('include:ext-bundle/hello')
+      expect(entryIds(ctx)).not.toContain('include:hello')
       expect(await manager.disable('ext-bundle')).toEqual({ changed: false, effect: 'live' })
       expect(changes.map(change => [change.reason, change.packageName])).toEqual([
         ['enable', 'ext-bundle'], ['enable', 'ext-bundle'], ['disable', 'ext-bundle'], ['disable', 'ext-bundle'],
@@ -522,7 +549,7 @@ describe('PluginManager', () => {
 
       expect(await manager.enable('ext-bundle')).toEqual({ changed: true, effect: 'restart' })
       expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-bundle'])
-      expect(entryIds(ctx)).not.toContain('include:ext-bundle/hello')
+      expect(entryIds(ctx)).not.toContain('include:hello')
       expect(await manager.disable('ext-bundle')).toEqual({ changed: true, effect: 'restart' })
     })
 
@@ -559,7 +586,7 @@ describe('PluginManager', () => {
       })
 
       expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-fine'])
-      expect(entryIds(ctx)).toContain('include:ext-fine/hello')
+      expect(entryIds(ctx)).toContain('include:hello')
       expect(entryIds(ctx)).not.toContain('include:bad')
     })
 
@@ -706,7 +733,7 @@ describe('PluginManager', () => {
 
       const dependents = await manager.dependents('ext-provider')
 
-      expect(dependents.services).toEqual([{ service: 'fixtureSvc', providedBy: 'include:ext-provider/svc', injectedBy: ['include:needs-svc'] }])
+      expect(dependents.services).toEqual([{ service: 'fixtureSvc', providedBy: 'include:svc', injectedBy: ['include:needs-svc'] }])
       expect(dependents.references.map(reference => reference.rowId)).toEqual(['ref', 'nested-ref'])
       expect(dependents.references[0]).toEqual({ target: { kind: 'global' }, rowId: 'ref', moduleName: 'ext-provider/tools/x.js' })
     })
@@ -729,14 +756,14 @@ describe('PluginManager', () => {
       const { ctx, manager, changes } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
       await manager.enable('ext-bundle')
       await manager.addRow('ext-bundle', { kind: 'global' }, { module: './extra.js' })
-      expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:ext-bundle/hello', 'include:ext-bundle/extra.js']))
+      expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:hello', 'include:ext-bundle/extra.js']))
       expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(true)
 
       await manager.uninstall('ext-bundle')
 
       expect(calls).toEqual([['pnpm', 'remove', 'ext-bundle']])
       expect(manifestOf(staged.profileDir)).toMatchObject({ dependencies: {}, dsh: { profile: { bundles: [] } } })
-      expect(entryIds(ctx)).not.toContain('include:ext-bundle/hello')
+      expect(entryIds(ctx)).not.toContain('include:hello')
       expect(entryIds(ctx)).not.toContain('include:ext-bundle/extra.js')
       expect(readFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
       expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(false)