Просмотр исходного кода

Merge branch 'feat/plugin-mgmt-3-settings' into feat/plugin-mgmt-4-web

# Conflicts:
#	packages/boot/app-boot/src/probe.ts
Yichen Jiang 1 месяц назад
Родитель
Сommit
54d5b2ff8b
93 измененных файлов с 1588 добавлено и 652 удалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml
  2. 3 3
      .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md
  3. 3 3
      .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml
  5. 5 5
      .agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md
  6. 5 5
      .agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md
  7. 6 0
      .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml
  8. 31 0
      .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md
  9. 31 0
      .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md
  10. 6 0
      .agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.i18n.yaml
  11. 27 0
      .agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.md
  12. 27 0
      .agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.zh.md
  13. 12 21
      apps/cli/src/profile-boot.ts
  14. 24 9
      apps/cli/tests/built-bin.e2e.ts
  15. 7 2
      apps/web/tests/scaffold.ts
  16. 2 2
      docs/config-catalog.i18n.yaml
  17. 1 0
      docs/config-catalog.md
  18. 1 0
      docs/config-catalog.zh.md
  19. 2 2
      docs/module-graph.i18n.yaml
  20. 4 1
      docs/module-graph.md
  21. 4 1
      docs/module-graph.zh.md
  22. 2 2
      docs/subsystems/core.i18n.yaml
  23. 7 4
      docs/subsystems/core.md
  24. 7 4
      docs/subsystems/core.zh.md
  25. 2 2
      docs/user/develop/basic/publish.i18n.yaml
  26. 3 1
      docs/user/develop/basic/publish.md
  27. 3 1
      docs/user/develop/basic/publish.zh.md
  28. 1 0
      package.json
  29. 2 2
      packages/boot/app-boot/README.i18n.yaml
  30. 10 6
      packages/boot/app-boot/README.md
  31. 10 6
      packages/boot/app-boot/README.zh.md
  32. 2 0
      packages/boot/app-boot/package.json
  33. 74 49
      packages/boot/app-boot/src/compose-stack.ts
  34. 30 52
      packages/boot/app-boot/src/contained-group.ts
  35. 63 72
      packages/boot/app-boot/src/external-bundles.ts
  36. 23 27
      packages/boot/app-boot/src/index.ts
  37. 34 0
      packages/boot/app-boot/src/patch-rows.ts
  38. 42 0
      packages/boot/app-boot/src/probe-child.ts
  39. 60 0
      packages/boot/app-boot/src/probe-report.ts
  40. 113 74
      packages/boot/app-boot/src/probe.ts
  41. 50 42
      packages/boot/app-boot/src/profile-runtime.ts
  42. 3 49
      packages/boot/app-boot/src/profile.ts
  43. 42 47
      packages/boot/app-boot/tests/compose-stack.spec.ts
  44. 27 0
      packages/boot/app-boot/tests/contained-group.spec.ts
  45. 78 32
      packages/boot/app-boot/tests/external-bundles.spec.ts
  46. 60 2
      packages/boot/app-boot/tests/probe.spec.ts
  47. 22 14
      packages/boot/app-boot/tests/profile-runtime.spec.ts
  48. 49 3
      packages/boot/app-boot/tests/user-patches.spec.ts
  49. 3 0
      packages/boot/app-boot/tsconfig.json
  50. 26 12
      packages/boot/app-boot/tsdown.config.ts
  51. 2 2
      packages/client/modules/README.i18n.yaml
  52. 2 0
      packages/client/modules/README.md
  53. 2 0
      packages/client/modules/README.zh.md
  54. 2 1
      packages/client/modules/package.json
  55. 2 17
      packages/client/modules/src/index.ts
  56. 1 0
      packages/client/modules/tsconfig.json
  57. 2 2
      packages/experimental/webworker-packer/README.i18n.yaml
  58. 2 0
      packages/experimental/webworker-packer/README.md
  59. 2 0
      packages/experimental/webworker-packer/README.zh.md
  60. 2 1
      packages/experimental/webworker-packer/package.json
  61. 2 8
      packages/experimental/webworker-packer/src/repository.ts
  62. 3 0
      packages/experimental/webworker-packer/tsconfig.json
  63. 2 2
      packages/extensions/tool-cordis/src/api-catalog.ts
  64. 19 5
      packages/host/plugin-inventory/src/index.ts
  65. 1 1
      packages/host/plugin-inventory/src/types.ts
  66. 22 14
      packages/host/plugin-inventory/tests/inventory.spec.ts
  67. 2 0
      packages/host/plugin-manager/package.json
  68. 15 4
      packages/host/plugin-manager/src/index.ts
  69. 3 4
      packages/host/plugin-manager/tests/plugin-manager.spec.ts
  70. 3 0
      packages/host/plugin-manager/tsconfig.json
  71. 3 2
      packages/preset/agent-presets/tests/overlay.spec.ts
  72. 2 2
      packages/util/README.i18n.yaml
  73. 1 0
      packages/util/README.md
  74. 1 0
      packages/util/README.zh.md
  75. 6 0
      packages/util/package-manifest/README.i18n.yaml
  76. 85 0
      packages/util/package-manifest/README.md
  77. 85 0
      packages/util/package-manifest/README.zh.md
  78. 35 0
      packages/util/package-manifest/package.json
  79. 17 0
      packages/util/package-manifest/src/index.ts
  80. 131 0
      packages/util/package-manifest/src/types.ts
  81. 11 0
      packages/util/package-manifest/tsconfig.json
  82. 7 7
      packages/util/patch-file/src/index.ts
  83. 21 0
      pnpm-lock.yaml
  84. 2 0
      scripts/check-workspace-constraints.ts
  85. 1 0
      scripts/doc-standard.spec.ts
  86. 4 10
      scripts/gen-session-format-catalog.ts
  87. 1 0
      scripts/verify-package-readme-model-experience.ts
  88. 1 0
      tsconfig.base.json
  89. 1 0
      tsconfig.host.json
  90. 2 0
      vendor/README.md
  91. 12 6
      vendor/include/src/index.ts
  92. 13 3
      vendor/loader/src/config/entry.ts
  93. 2 0
      vitest.config.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # 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-boot-scoped-fail-loud-and-package-probe.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md
-2026-09-04-boot-scoped-fail-loud-and-package-probe.md: b97bfb57ccda65aa69832db4a9772e03853328a6
-2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: 0dffeb0699d8fa847110f33e1579dd4ab1227767
+2026-09-04-boot-scoped-fail-loud-and-package-probe.md: c0ae61e1323de3dcbeec826363dc953c32dbdcc5
+2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: d4e136e36dcf90ad78b499123bfe56add868484d

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md

@@ -12,15 +12,15 @@ Separately, nothing could say what an installed package was without importing it
 
 
 ## Decision
 ## Decision
 
 
-**Fail-loud ends when the tree is up.** The launcher keeps `installFailLoud`'s uninstaller and calls it once `boot()` returns, then installs `installRuntimeGuards`: an unhandled rejection after boot is reported to stderr as contained and the process keeps running; an uncaught exception is reported with its origin and the process exits, as Node would, because its state is unknown. Both handlers are removed on shutdown.
+**Fail-loud ends when the tree is up.** The launcher keeps `installFailLoud`'s uninstaller and calls it once `boot()` returns, then installs `installRuntimeGuards`: an unhandled rejection after boot is reported to stderr and the process keeps running — the guard neither stops the task that produced it nor attributes it to a plugin, built-in or external, so this is a process-level policy chosen over exiting, not isolation; an uncaught exception is reported with its origin and the process exits, as Node would, because its state is unknown. Both handlers are removed on shutdown.
 
 
 **Nested failures are reported, not yet fatal.** `warnNestedFiberFailures` walks every runtime's fibers and reports a `FAILED` fiber that belongs to a built-in entry but is not that entry's root fiber — a `ctx.inject()` continuation that threw, which the Loader stamps with the entry but the activation audit never sees. It runs after boot as advisory lines; it becomes part of the fatal audit once shipped compositions are known clean.
 **Nested failures are reported, not yet fatal.** `warnNestedFiberFailures` walks every runtime's fibers and reports a `FAILED` fiber that belongs to a built-in entry but is not that entry's root fiber — a `ctx.inject()` continuation that threw, which the Loader stamps with the entry but the activation audit never sees. It runs after boot as advisory lines; it becomes part of the fatal audit once shipped compositions are known clean.
 
 
-**The probe runs the package where it cannot hurt.** `probePackage` reads the installed package's manifest in the host — kind from `dsh.bundle`, the rows and overrides of its patch, `dsh.plugins` declarations, `engines.dsh`, title and description — and spawns one Node child that resolves `@deepseek-ai/cordis` from the package, imports the main export and every declared addable module, and reports whether each is a plugin and what `Config.toJSON()` it carries. A child that throws, exits, or hangs yields `ok: false` with the reason or a rejection naming the timeout; a package whose cordis resolves elsewhere than the harness's own is not enableable. Records are cached under the profile's `.dsh-plugins/` and invalidated by version.
+**The probe runs the package where it cannot hurt.** `probePackage` reads the installed package's manifest in the host — kind from `dsh.bundle`, the rows and overrides of its patch, `dsh.plugins` declarations, `engines.dsh`, title and description — and spawns one Node child — `probe-child.ts`, its own module beside the probe, run through tsx under a source launch and as `lib/probe-child.js` when built — that resolves `@deepseek-ai/cordis` from the package, imports the main export and every declared addable module, and sends one report over an IPC channel. stdout and stderr stay the imported modules' own, so a package that prints at import still reports, and the child is killed once the report arrived, so a package that keeps a timer alive costs nothing more. The report and the cached record are validated field by field as the process and file boundaries they cross: an unrecognized report is a rejection, an unrecognized record is probed again. A child that throws, exits, or hangs yields `ok: false` with the reason or a rejection naming the timeout; `ok` states only that the main export imported and cordis is not a second copy, and `kind` with `addable[].ok` decide what can be enabled or added. Records are cached under the profile's `.dsh-plugins/` and invalidated by version.
 
 
 ## Alternatives considered
 ## Alternatives considered
 
 
-**Attribute a runtime rejection to the plugin that produced it and mark that plugin failed.** The right end state, but a promise carries no fiber, and cordis's effect wrappers cover only what plugins register through them. Deferred: the guard reports and contains now; attribution needs an async-context seam.
+**Attribute a runtime rejection to the plugin that produced it and mark that plugin failed.** The right end state, but a promise carries no fiber, and cordis's effect wrappers cover only what plugins register through them. Deferred: the guard reports and lets the process continue; attribution needs an async-context seam.
 
 
 **Import the package in the host to learn its shape.** Rejected: import runs code with the host's privileges before the user enabled anything, and a hang or a `process.exit` in a package's module scope would be the host's.
 **Import the package in the host to learn its shape.** Rejected: import runs code with the host's privileges before the user enabled anything, and a hang or a `process.exit` in a package's module scope would be the host's.
 
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md

@@ -12,15 +12,15 @@ Status: implemented
 
 
 ## 决定
 ## 决定
 
 
-**fail-loud 在树起来时结束。** launcher 保留 `installFailLoud` 的卸载函数,在 `boot()` 返回后调用它,然后安装 `installRuntimeGuards`:启动后未处理的 rejection 以"已兜住"报告到 stderr,进程继续运行;未捕获的异常连同来源一起报告,进程退出,如 Node 所做,因为其状态未知。两个处理器在关闭时移除。
+**fail-loud 在树起来时结束。** launcher 保留 `installFailLoud` 的卸载函数,在 `boot()` 返回后调用它,然后安装 `installRuntimeGuards`:启动后未处理的 rejection 报告到 stderr,进程继续运行——守卫既不停止产生它的任务,也不把它归属到任何插件,内置或外部一视同仁,这是一条相对于退出而选择的进程级策略,不是隔离;未捕获的异常连同来源一起报告,进程退出,如 Node 所做,因为其状态未知。两个处理器在关闭时移除。
 
 
 **嵌套失败被报告,暂不致命。** `warnNestedFiberFailures` 遍历每个 runtime 的 fiber,报告属于内置条目却不是该条目根 fiber 的 `FAILED` fiber——抛错的 `ctx.inject()` 延续,Loader 给它盖了条目的章,而激活审计从未看见它。它在启动后以提示行运行;确认随附组合没有这类失败后再并入致命审计。
 **嵌套失败被报告,暂不致命。** `warnNestedFiberFailures` 遍历每个 runtime 的 fiber,报告属于内置条目却不是该条目根 fiber 的 `FAILED` fiber——抛错的 `ctx.inject()` 延续,Loader 给它盖了条目的章,而激活审计从未看见它。它在启动后以提示行运行;确认随附组合没有这类失败后再并入致命审计。
 
 
-**探针在伤不到宿主的地方运行包。** `probePackage` 在宿主里读取已安装包的 manifest——从 `dsh.bundle` 得到种类、其 patch 的行与覆盖、`dsh.plugins` 声明、`engines.dsh`、标题与描述——并生成一个 Node 子进程:从该包解析 `@deepseek-ai/cordis`,import 主导出与每个声明为可添加的模块,报告各自是否为插件以及携带的 `Config.toJSON()`。抛错、退出或挂起的子进程得到带原因的 `ok: false`,或点名超时的 rejection;cordis 解析到 harness 自有副本之外的包不可启用。记录缓存在 profile 的 `.dsh-plugins/` 下,按版本失效。
+**探针在伤不到宿主的地方运行包。** `probePackage` 在宿主里读取已安装包的 manifest——从 `dsh.bundle` 得到种类、其 patch 的行与覆盖、`dsh.plugins` 声明、`engines.dsh`、标题与描述——并生成一个 Node 子进程——`probe-child.ts`,探针旁边的独立模块,源码启动时经 tsx 运行,构建后是 `lib/probe-child.js`——从该包解析 `@deepseek-ai/cordis`,import 主导出与每个声明为可添加的模块,经 IPC 通道发出一份报告。stdout 与 stderr 仍归被 import 的模块自己,所以在 import 时打印的包照样能报告;报告一到子进程就被杀掉,因此让定时器一直活着的包不再多花任何代价。报告与缓存记录按各自跨越的进程边界与文件边界逐字段校验:无法识别的报告是一次 rejection,无法识别的记录重新探测。抛错、退出或挂起的子进程得到带原因的 `ok: false`,或点名超时的 rejection;`ok` 只表示主导出 import 成功且 cordis 不是第二份副本,能否启用或添加由 `kind` 与 `addable[].ok` 决定。记录缓存在 profile 的 `.dsh-plugins/` 下,按版本失效。
 
 
 ## 考虑过的替代方案
 ## 考虑过的替代方案
 
 
-**把运行时 rejection 归属到产生它的插件并把该插件标为失败。** 正确的终态,但 promise 不携带 fiber,cordis 的 effect 包装也只覆盖插件经由它注册的东西。延后:守卫现在只报告并兜住;归属需要一个 async-context seam。
+**把运行时 rejection 归属到产生它的插件并把该插件标为失败。** 正确的终态,但 promise 不携带 fiber,cordis 的 effect 包装也只覆盖插件经由它注册的东西。延后:守卫现在只报告并让进程继续;归属需要一个 async-context seam。
 
 
 **在宿主里 import 包来了解其形态。** 否决:import 会在用户启用任何东西之前以宿主权限运行代码,包的模块作用域里的一次挂起或 `process.exit` 就是宿主的。
 **在宿主里 import 包来了解其形态。** 否决:import 会在用户启用任何东西之前以宿主权限运行代码,包的模块作用域里的一次挂起或 `process.exit` 就是宿主的。
 
 

+ 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;
 # 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:
 # 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
 #   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: b8639bd48dfb99df7f90ad28f8eeaf4be9b803d2
-2026-09-04-external-bundles-as-contained-groups.zh.md: 04980f87429c231d539c367d6c5bf2cf51ba554f
+2026-09-04-external-bundles-as-contained-groups.md: 3319394d56630eb90dbce18f54483e22ad8577a5
+2026-09-04-external-bundles-as-contained-groups.zh.md: f439f96f01827ae95ebe2cbe7f94a1acfe833a27

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

@@ -10,17 +10,17 @@ A bundle installed with `dsh plugin add` mounted its rows exactly like the insta
 
 
 ## Decision
 ## 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 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.
+**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>`, inserted empty, followed by the bundle's patches in the order written: each root insert is re-targeted into that group, every insert into one built-in group lands in one contained wrapper group nested inside the target, and id-targeted patches pass through, reported as overrides when they address rows the bundle did not insert. Keeping the written order is what lets a patch that replaces a group's config and then appends to it mean the same thing under both stages. `/` 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.
+**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, or declares one of its own ids twice, is left out whole; a user-layer insert of a taken id is dropped. The rows left out are the composition's conflicts, each carrying its message: printed on stderr at boot, held by `ProfileRuntime` as part of the committed composition, and shown per package in the plugin list. They never enter `pluginFailures`, whose records name rows that reached the Loader. Boot, live recomposition, and `--dump-config` compose through the one function, which renders each contained layer once and returns the patches, the owner of every id, and the conflicts together.
 
 
-**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.
+**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. When the group unmounts — its bundle disabled or uninstalled — it drops its rows' records, so no failure outlives the composition that produced 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 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.
 **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 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.
+**Provenance and recomposition are one launcher service.** `ProfileRuntime` (`ctx.profileRuntime`) holds the committed composition — the profile, the owner of every row id (`originOf`), and the conflicts — reads which rows the user patch files disable with a literal `disabled: true`, and is the one entry point that recomposes the tree: it composes a candidate, applies it through the root include, and publishes the candidate only once the include accepted it, so a rejected update leaves the facts describing the tree still running. The patch watchers call its `recompose` instead of composing themselves. The plugin inventory reads it and the failure registry to serve `trust`, `package`, `disabledBy`, and `failure` per row, listing the conflicts and the rows the registry alone knows.
 
 
 ## Alternatives considered
 ## Alternatives considered
 
 
@@ -38,4 +38,4 @@ A community bundle that breaks on a harness upgrade no longer stops `dsh`; the p
 
 
 ## Testing
 ## Testing
 
 
-`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.
+`packages/boot/app-boot/tests/external-bundles.spec.ts` pins the composition (grouping, declared ids, written order, overrides, one wrapper per built-in target, repeated ids, 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 or repeats an id 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.

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

@@ -10,17 +10,17 @@ Status: implemented
 
 
 ## 决定
 ## 决定
 
 
-**每个外部组合包就是一个组。** profile launcher 按来源给每一层分类:作为 profile 的 pnpm 依赖存在的组合包是 `external`,模板组合包或 profile 在 `dsh.profile.firstParty` 下列出的是 `builtin`。`composeExternalLayer` 把 `runtime` 阶段的外部层渲染成一个 `cordis:contained-group` 条目 `bundle/<package>`,其中放着组合包插入的行,id 保持 patch 声明的样子;插入内置组的行在目标组内嵌套自己的受控组,组合包按 id 定位的 patch 原样通过,指向它没有插入的行时报告为覆盖。组 id 用 `/` 而不是 `:`,因为 `:` 是 Loader 的嵌套 id 分隔符。
+**每个外部组合包就是一个组。** profile launcher 按来源给每一层分类:作为 profile 的 pnpm 依赖存在的组合包是 `external`,模板组合包或 profile 在 `dsh.profile.firstParty` 下列出的是 `builtin`。`composeExternalLayer` 把 `runtime` 阶段的外部层渲染成一个 `cordis:contained-group` 条目 `bundle/<package>`,先空着插入,随后按书写顺序跟着组合包自己的 patch:根级插入改为插进这个组,插入同一个内置组的行全部落进目标组内嵌套的同一个受控包装组,按 id 定位的 patch 原样通过,指向它没有插入的行时报告为覆盖。保持书写顺序,才能让"先替换某个组的 config 再向它追加"这样的 patch 在两种 stage 下含义一致。组 id 用 `/` 而不是 `:`,因为 `:` 是 Loader 的嵌套 id 分隔符。
 
 
-**行 id 有归属,不改写。** `composeProfileStack` 在任何行挂载之前判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;受控组合包声明了别的层已占有的 id 时整层排除;用户层插入已被占用的 id 时该行丢弃。每一条被排除的行都是 `pluginFailures` 里的一条 `conflict` 记录——启动时打到 stderr,每次重组时替换,在插件列表里按包显示——启动、运行时重组与 `--dump-config` 走同一个函数。
+**行 id 有归属,不改写。** `composeProfileStack` 在任何行挂载之前判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;受控组合包声明了别的层已占有的 id、或把自己的某个 id 声明了两次时整层排除;用户层插入已被占用的 id 时该行丢弃。被排除的行就是这次组合的冲突,每条自带消息:启动时打到 stderr,由 `ProfileRuntime` 作为已提交组合的一部分持有,在插件列表里按包显示。它们从不进入 `pluginFailures`,那里的记录只指真正到达 Loader 的行。启动、运行时重组与 `--dump-config` 走同一个函数,它把每个受控层只渲染一次,并一并返回 patch、每个 id 的归属与冲突。
 
 
-**受控组隔离行的失败。** `ContainedGroup extends Group` 覆盖 `create()`——这是事务性 `update()` 逐行等待的那一步:被拒的行记录到根上的 `pluginFailures` 注册表——树内 id、声明的行 id、模块、组、从 Loader 包装信息解析出的阶段、消息——组在没有它的情况下激活。`assertEntriesActivated` 豁免受控行(失败或 pending 的行变成一条记录),内置行保留致命路径。一条兜底规则封住"隔离反而藏起核心已坏"的 corner case:只要有组合包被隔离,而某个内置行停在 pending,启动仍然失败,诊断点名被隔离的组合包以及 `stage: boot` 这条出路。
+**受控组隔离行的失败。** `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` 记录已启用的层。
 **安装与启用是两件事。** `reconcileInstalledBundles` 不再无条件把每个声明了组合包的依赖追加进 `dsh.profile.bundles`;`autoEnable` 保留 CLI 装即启用的语义,`enableBundle`/`disableBundle` 是插件管理器调用的 manifest 操作。`dependencies` 记录安装,`bundles` 记录已启用的层。
 
 
-**来源是 launcher 的服务。** `ProfileRuntime`(`ctx.profileRuntime`)持有已启动的 profile,把每一行归属到占有其 id 的层(`originOf`),读取用户 patch 文件用字面量 `disabled: true` 停用了哪些行,并经根 include 重新组合整棵树——patch 监视器走的正是同一条路。插件清单读取它与失败注册表,为每一行提供 `trust`、`package`、`disabledBy` 与 `failure`,并列出只有注册表知道的行。
+**来源与重组是同一个 launcher 服务。** `ProfileRuntime`(`ctx.profileRuntime`)持有已提交的组合——profile、每个行 id 的归属(`originOf`)与冲突——读取用户 patch 文件用字面量 `disabled: true` 停用了哪些行,并且是重组整棵树的唯一入口:它先组合候选结果,经根 include 应用,只有 include 接受之后才发布候选结果,因此被拒的更新留下的事实仍然描述正在运行的树。patch 监视器调用它的 `recompose`,不再自己组合。插件清单读取它与失败注册表,为每一行提供 `trust`、`package`、`disabledBy` 与 `failure`,并列出冲突以及只有注册表知道的行。
 
 
 ## 考虑过的替代方案
 ## 考虑过的替代方案
 
 
@@ -38,4 +38,4 @@ harness 升级后损坏的社区组合包不再让 `dsh` 停下;插件列表
 
 
 ## 测试
 ## 测试
 
 
-`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` 钉住新的行字段。
+`packages/boot/app-boot/tests/external-bundles.spec.ts` 钉住组合(分组、声明 id、书写顺序、覆盖、每个内置目标一个包装组、重复 id、不改动层自己的 patch)与 manifest 操作;`tests/compose-stack.spec.ts` 钉住归属:内置重复抛错,撞名或重复声明 id 的外部组合包整层排除,用户插入已占用 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` 钉住新的行字段。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md
+2026-09-05-package-manifest-types.md: 94317a9317ba12059e726612840e4a001be2a892
+2026-09-05-package-manifest-types.zh.md: 2c40facd2591921123b5eae71b80c516f3b1f697

+ 31 - 0
.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md

@@ -0,0 +1,31 @@
+# Agent Note: Package manifest declaration ownership
+
+Status: implemented
+
+English | [中文](2026-09-05-package-manifest-types.zh.md)
+
+## Problem
+
+External packages need Harness manifest types without depending on boot or client implementations. Keeping declarations beside individual readers obscures the complete configuration API and lets overlapping fields diverge.
+
+## Decision
+
+[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.md) owns `DshManifest` and its member declarations in one type-only file. The package belongs to the existing utility group and exports no runtime values. Author declarations and launcher-generated module fallback metadata are explicitly distinguished.
+
+Readers import the shared declarations directly. Boot retains profile loading, raw JSON checks, defaults, and resolved runtime data. Client modules retain their normalized boot graph. The image packer resolves declared paths into directories. The Session catalog generator derives a read-only validated entry with a resolved import path; raw inputs and discovery rules remain local.
+
+App-boot declares a production dependency because its published declarations reference the shared types. Client modules, the private packer, and root scripts use development dependencies because their published APIs do not expose these types. Every package consumer has a TypeScript project reference. External authors import from the utility package; app-boot provides no compatibility re-exports.
+
+## Alternatives considered
+
+**Keep declarations in individual readers.** External authors would depend on runtime implementations, and a partial profile-only definition would omit existing client and build fields.
+
+**Create a separate types group.** The existing utility group accommodates a type-only library without another package category. Runtime service and event declarations stay with their owners.
+
+**Unify the JSON parsers.** Sharing declarations does not require changing validation, errors, defaults, or parsed results; those remain owned by each reader.
+
+## Consequences
+
+Authors gain one public import path at the cost of a published package and explicit dependency edges. Existing app-boot manifest type imports must use the new package. The [profile composition design](2026-08-05-profile-plugin-bundles.md) continues to own runtime semantics; type extraction does not change configuration acceptance or model-visible behavior.
+
+Compiler and packaged NodeNext consumer checks cover public imports. Existing profile, client, image configuration, and Session catalog tests cover reader behavior; documentation checks cover the utility classification and generated package catalogs. Optional declaration fields still require deliberate consumer updates when added.

+ 31 - 0
.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: Package manifest 声明归属
+
+Status: implemented
+
+[English](2026-09-05-package-manifest-types.md) | 中文
+
+## 问题
+
+外部包需要 Harness manifest 类型,而无需依赖启动器或客户端实现。将声明放在各读取方旁边,会使完整配置 API 难以查找,也容易让重叠字段发生偏离。
+
+## 决策
+
+[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.zh.md) 在一个纯类型文件中拥有 `DshManifest` 及其成员声明。本包属于现有工具库分组,不导出运行时值。作者声明与启动器生成的模块后备元数据有明确区分。
+
+各读取方直接导入共享声明。启动器保留 profile 加载、原始 JSON 检查、默认值和解析后的运行时数据。客户端模块保留归一化的启动图。镜像打包器将声明路径解析为目录。Session 目录生成器派生带有已解析导入路径的只读校验结果;原始输入和发现规则仍由本地负责。
+
+App-boot 声明生产依赖,因为其发布的声明文件引用共享类型。客户端模块、私有打包器和根脚本使用开发依赖,因为其发布 API 不暴露这些类型。每个包消费方都有 TypeScript 项目引用。外部作者从工具包导入;app-boot 不提供兼容性再导出。
+
+## 考虑过的替代方案
+
+**将声明保留在各读取方。** 外部作者会依赖运行时实现,而仅覆盖 profile 的部分定义会遗漏已有的客户端和构建字段。
+
+**创建独立的 types 分组。** 现有工具库分组可以容纳纯类型库,无需增加包分类。运行时服务和事件声明仍由各自负责方拥有。
+
+**统一 JSON 解析器。** 共享声明不要求改变校验、错误、默认值或解析结果;这些仍由各读取方负责。
+
+## 后果
+
+作者获得统一的公共导入路径,代价是一个发布包和明确的依赖边。已有的 app-boot manifest 类型导入需要改用新包。[Profile 组合设计](2026-08-05-profile-plugin-bundles.zh.md) 继续负责运行时语义;类型提取不改变配置接受范围或模型可见行为。
+
+编译器与打包后的 NodeNext 消费方检查覆盖公共导入。已有 profile、客户端、镜像配置和 Session 目录测试覆盖读取行为;文档检查覆盖工具库分类与生成的包目录。新增可选声明字段时,仍需主动更新消费方。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.md
+2026-09-05-patch-plugin-file-urls.md: 48112483b4a3eef1fefcb774c2d19a2c901ccbdb
+2026-09-05-patch-plugin-file-urls.zh.md: 775a576324dd2856f490cf3ce3ba907965737fc2

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.md

@@ -0,0 +1,27 @@
+# Agent Note: File URLs for inserted patch plugins
+
+Status: implemented
+
+English | [中文](2026-09-05-patch-plugin-file-urls.zh.md)
+
+## Problem
+
+Node ESM interprets a Windows drive prefix as a URL scheme and treats filename fragments as URL syntax. Passing filesystem paths directly to plugin imports therefore fails for Windows absolute paths and filenames containing `#` or `%`.
+
+## Decision
+
+Patch-file parsing converts native absolute paths and patch-relative `./` or `../` names to file URLs inside `insert` rows and their nested groups. Package specifiers, existing URLs, existing-entry name assertions, and replacement `config` values retain their meaning. Parsing owns this conversion because it knows the originating patch directory before layers are composed.
+
+The optional `HostResolvedRootInclude` import override separately converts absolute paths for callers selecting an installed-host resolver base. Ordinary CLI profiles do not select that override; it cannot substitute for patch-file conversion.
+
+## Alternatives considered
+
+**Convert only the Python fixture with `Path.as_uri()`.** This avoids one failure but leaves user-authored profile and overlay patches exposed.
+
+**Change the shared Loader base.** This loses per-patch provenance and changes bare-package resolution. File URLs preserve the selected local file without changing the resolver base.
+
+## Consequences
+
+Optional and required patch readers share the conversion. Direct Loader imports and children introduced through replacement group configs remain outside its scope; extending those paths requires their own semantics and coverage.
+
+The patch-reader tests verify conversion and real activation. The built SDK acceptance loads an absolute-path overlay plugin with URL-sensitive filename characters and verifies its filesystem marker, initialization, and shutdown.

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: 插入补丁插件的文件 URL
+
+Status: implemented
+
+[English](2026-09-05-patch-plugin-file-urls.md) | 中文
+
+## Problem
+
+Node ESM 会把 Windows 盘符前缀解释为 URL scheme,并把文件名中的片段字符视为 URL 语法。因此,直接将文件系统路径传给插件导入会使 Windows 绝对路径以及包含 `#` 或 `%` 的文件名加载失败。
+
+## Decision
+
+补丁文件解析将 `insert` 条目及其嵌套分组中的本机绝对路径、相对于补丁文件的 `./` 或 `../` 名称转换为文件 URL。包标识符、已有 URL、已有条目的名称断言及替换用的 `config` 值保持原有含义。解析阶段负责转换,因为在合并各层之前它掌握来源补丁的目录。
+
+可选的 `HostResolvedRootInclude` 导入覆写会另外为选择安装宿主解析基址的调用方转换绝对路径。普通 CLI profile 不选择该覆写,因此它不能代替补丁文件转换。
+
+## Alternatives considered
+
+**只在 Python fixture 中使用 `Path.as_uri()`。** 这能避开一次失败,但用户编写的 profile 和 overlay 补丁仍会遇到问题。
+
+**修改共享 Loader 基址。** 这会丢失逐补丁来源信息并改变裸包解析。文件 URL 保留选定的本地文件,无需修改解析基址。
+
+## Consequences
+
+可选与必需补丁读取器共用该转换。直接 Loader 导入及替换分组 config 引入的子条目不在其范围内;扩展这些路径需要各自的语义与覆盖。
+
+补丁读取器测试验证转换与真实激活。构建后 SDK 验收加载文件名含 URL 敏感字符的绝对路径 overlay 插件,并验证其文件系统标记、初始化与关闭。

+ 12 - 21
apps/cli/src/profile-boot.ts

@@ -29,7 +29,6 @@ import {
   loadProfile,
   loadProfile,
   PROFILE_PATCH_FILENAME,
   PROFILE_PATCH_FILENAME,
   ProfileRuntime,
   ProfileRuntime,
-  recordRowConflicts,
   rootIncludeEntry,
   rootIncludeEntry,
   warnNestedFiberFailures,
   warnNestedFiberFailures,
   watchUserPatches,
   watchUserPatches,
@@ -275,17 +274,17 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
     const stack = composeProfileStack(NAME, profile.layers, [...userLayersOf(profile), ...composed.overlays])
     const stack = composeProfileStack(NAME, profile.layers, [...userLayersOf(profile), ...composed.overlays])
     return { ...stack, patches: structuredClone(stack.patches) }
     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. 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
+  // Every recomposition after boot — a watched user file, a bundle enabled
+  // or installed — goes through the profile runtime, which publishes the
+  // profile, the row ownership, and the conflicts only once the tree
+  // accepted the update.
+  const reapply = async (): Promise<void> => {
+    const runtime = app.runtime
+    if (runtime === undefined) throw new Error(`${NAME}: user patch reload needs the profile runtime`)
+    await runtime.recompose()
   }
   }
   for (const conflict of composed.stack.conflicts) process.stderr.write(`${NAME}: ${formatRowConflict(conflict)}\n`)
   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
+  // Cloned for the same insert-aliasing reason as composeFor: the boot
   // application must not mutate the objects later reloads recompose from.
   // application must not mutate the objects later reloads recompose from.
   const ctx = await boot(NAME, rootConfig, structuredClone(composed.stack.patches), (hostCtx) => {
   const ctx = await boot(NAME, rootConfig, structuredClone(composed.stack.patches), (hostCtx) => {
     app.current = hostCtx
     app.current = hostCtx
@@ -307,10 +306,10 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   uninstallRuntimeGuards = installRuntimeGuards(NAME, (line) => { process.stderr.write(`${line}\n`) })
   uninstallRuntimeGuards = installRuntimeGuards(NAME, (line) => { process.stderr.write(`${line}\n`) })
   if (!signalShutdown.signal.aborted && ctx.fiber.state === FiberState.ACTIVE && ctx.get('loader') !== undefined) {
   if (!signalShutdown.signal.aborted && ctx.fiber.state === FiberState.ACTIVE && ctx.get('loader') !== undefined) {
     warnNestedFiberFailures(ctx, NAME, (line) => { process.stderr.write(`${line}\n`) })
     warnNestedFiberFailures(ctx, NAME, (line) => { process.stderr.write(`${line}\n`) })
-    recordRowConflicts(ctx, composed.stack.conflicts)
     await ctx.plugin(ProfileRuntime, {
     await ctx.plugin(ProfileRuntime, {
       profile: composed.profile,
       profile: composed.profile,
       installAnchor: INSTALL_ANCHOR,
       installAnchor: INSTALL_ANCHOR,
+      stack: composed.stack,
       loadProfile: () => prepareProfile(options.profile),
       loadProfile: () => prepareProfile(options.profile),
       compose: composeFor,
       compose: composeFor,
       rootEntry: () => rootIncludeEntry(ctx),
       rootEntry: () => rootIncludeEntry(ctx),
@@ -344,16 +343,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
         }
         }
         await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
         await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
       }
       }
-      await watchUserPatches(ctx, {
-        binName: NAME,
-        filename: composed.profile.patchPath,
-        compose: composeLive,
-      })
-      await watchUserPatches(ctx, {
-        binName: NAME,
-        filename: homePatchPath(),
-        compose: composeLive,
-      })
+      await watchUserPatches(ctx, { binName: NAME, filename: composed.profile.patchPath, reapply })
+      await watchUserPatches(ctx, { binName: NAME, filename: homePatchPath(), reapply })
     } catch (error) {
     } catch (error) {
       suppressShutdownError(ctx, signalShutdown.signal, error)
       suppressShutdownError(ctx, signalShutdown.signal, error)
     }
     }

+ 24 - 9
apps/cli/tests/built-bin.e2e.ts

@@ -424,9 +424,20 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
     }
   }, SPAWN_TIMEOUT_MS + 30_000)
   }, SPAWN_TIMEOUT_MS + 30_000)
 
 
-  it('serves the SDK protocol through the sdk profile and exits after shutdown', async () => {
+  it('serves the SDK protocol with an absolute-path overlay plugin and exits after shutdown', async () => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-'))
     const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-'))
-    const child = execa(process.execPath, [dshBin, '--profile', 'sdk'], {
+    const pluginPath = join(home, 'plugin #100%.mjs')
+    const marker = join(home, 'plugin-loaded')
+    writeFileSync(pluginPath, [
+      "import { writeFileSync } from 'node:fs'",
+      'export function apply(ctx, config) { writeFileSync(config.marker, "loaded") }',
+      '',
+    ].join('\n'))
+    const patch = join(home, 'absolute.patch.yml')
+    writeFileSync(patch, JSON.stringify([{ insert: [
+      { id: 'absolute-plugin', name: pluginPath, config: { marker } },
+    ] }]))
+    const child = execa(process.execPath, [dshBin, '--profile', 'sdk', '--patch', patch], {
       cwd: home,
       cwd: home,
       reject: false,
       reject: false,
       timeout: SPAWN_TIMEOUT_MS,
       timeout: SPAWN_TIMEOUT_MS,
@@ -462,7 +473,8 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
         method: 'initialize',
         method: 'initialize',
         params: { cwd: home, provider: 'deepseek-official', model: 'deepseek-v4-flash' },
         params: { cwd: home, provider: 'deepseek-official', model: 'deepseek-v4-flash' },
       })}\n`)
       })}\n`)
-      expect(await response(1)).toMatchObject({
+      const initialized = await response(1)
+      expect(initialized, `${JSON.stringify(initialized)}\n${stderr}`).toMatchObject({
         jsonrpc: '2.0',
         jsonrpc: '2.0',
         id: 1,
         id: 1,
         result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } },
         result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } },
@@ -470,8 +482,11 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'shutdown' })}\n`)
       child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'shutdown' })}\n`)
       expect(await response(2)).toEqual({ jsonrpc: '2.0', id: 2, result: {} })
       expect(await response(2)).toEqual({ jsonrpc: '2.0', id: 2, result: {} })
       const result = await child
       const result = await child
-      expect(result.exitCode, `signal=${String(result.signal)}; stderr=${stderr}`).toBe(0)
+      expect(result.timedOut, stderr).toBe(false)
+      expect(result.signal, stderr).toBeUndefined()
+      expect(result.exitCode, stderr).toBe(0)
       expect(stderr).toBe('')
       expect(stderr).toBe('')
+      expect(readFileSync(marker, 'utf8')).toBe('loaded')
     } finally {
     } finally {
       child.kill('SIGKILL')
       child.kill('SIGKILL')
       await child
       await child
@@ -712,13 +727,12 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       ].join('\n'))
       ].join('\n'))
       await waitForFile(fixture.ready)
       await waitForFile(fixture.ready)
       expect(readFileSync(configFile, 'utf8')).toBe('2')
       expect(readFileSync(configFile, 'utf8')).toBe('2')
-      // Removal reverts: the bundle's inserted row must return to its own
-      // default config, not keep the removed override — the insert-aliasing
-      // regression (a shared patch object mutated in place by a former
-      // generation would make this impossible).
+      // Unlink exercises layer removal without racing Chokidar's change-event
+      // suppression window after the preceding edit. The bundle default must return.
       rmSync(fixture.ready)
       rmSync(fixture.ready)
-      writeFileSync(profilePatch, '[]\n')
+      rmSync(profilePatch)
       await waitForFile(fixture.ready)
       await waitForFile(fixture.ready)
+      expect(existsSync(profilePatch)).toBe(false)
       expect(readFileSync(configFile, 'utf8')).toBe('bundle-default')
       expect(readFileSync(configFile, 'utf8')).toBe('bundle-default')
       // The home-level user layer ($DSH_HOME/cordis.patch.yml) is live too
       // The home-level user layer ($DSH_HOME/cordis.patch.yml) is live too
       // and outranks the per-profile layer.
       // and outranks the per-profile layer.
@@ -738,6 +752,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(existsSync(fixture.disposed)).toBe(true)
       expect(existsSync(fixture.disposed)).toBe(true)
     } finally {
     } finally {
       child.kill('SIGKILL')
       child.kill('SIGKILL')
+      await child
       rmSync(fixture.home, { recursive: true, force: true })
       rmSync(fixture.home, { recursive: true, force: true })
     }
     }
   }, SPAWN_TIMEOUT_MS + 30_000)
   }, SPAWN_TIMEOUT_MS + 30_000)

+ 7 - 2
apps/web/tests/scaffold.ts

@@ -56,7 +56,7 @@ import {
   type NormalizeContext,
   type NormalizeContext,
 } from '@deepseek-ai/dsh-session-snapshot'
 } from '@deepseek-ai/dsh-session-snapshot'
 import {
 import {
-  assertEntriesLoaded,
+  assertEntriesLoaded, claimLayerIds,
   composeEntries,
   composeEntries,
   healProfilesModuleFallback,
   healProfilesModuleFallback,
   loadOptionalPatches,
   loadOptionalPatches,
@@ -738,11 +738,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       // recomposes a live-reload profile, and this one applies at startup.
       // recomposes a live-reload profile, and this one applies at startup.
       const readProfile = (): Profile => loadProfile('dsh', 'scaffold', INSTALL_ANCHOR, harnessHome)
       const readProfile = (): Profile => loadProfile('dsh', 'scaffold', INSTALL_ANCHOR, harnessHome)
       const profile = readProfile()
       const profile = readProfile()
+      // The scaffold's own patches, with the id ownership the runtime answers `originOf` from.
+      const compose = (): ComposedStack => ({
+        patches, layers: [{ label: 'scaffold', patches }], owners: claimLayerIds(profile.layers).owners, conflicts: [], skippedBundles: [],
+      })
       await ctx.plugin(ProfileRuntime, {
       await ctx.plugin(ProfileRuntime, {
         profile,
         profile,
+        stack: compose(),
         installAnchor: INSTALL_ANCHOR,
         installAnchor: INSTALL_ANCHOR,
         loadProfile: readProfile,
         loadProfile: readProfile,
-        compose: (): ComposedStack => ({ patches, layers: [{ label: 'scaffold', patches }], conflicts: [], skippedBundles: [] }),
+        compose,
         rootEntry: () => [...ctx.loader.entries()].find(entry => entry.id === rootIncludeId),
         rootEntry: () => [...ctx.loader.entries()].find(entry => entry.id === rootIncludeId),
         readUserPatches: () => loadOptionalPatches('dsh', profile.patchPath) ?? [],
         readUserPatches: () => loadOptionalPatches('dsh', profile.patchPath) ?? [],
       })
       })

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 4a6e55d6a21376021cf8bec86f95c3293136c7b8
-config-catalog.zh.md: 557349db15c05ea868cf55a9a71b734e97037166
+config-catalog.md: e5a74056ac76e19089996668eec4db14b926e48a
+config-catalog.zh.md: 82bb875119dc3c2ee10c74793206d785d5ccd3ae

+ 1 - 0
docs/config-catalog.md

@@ -3508,6 +3508,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
 - `@deepseek-ai/dsh-loader-smoke` ([`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts))
 - `@deepseek-ai/dsh-loader-smoke` ([`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts))
 - `@deepseek-ai/dsh-native-command` ([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts))
 - `@deepseek-ai/dsh-native-command` ([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts))
 - `@deepseek-ai/dsh-output-retention` ([`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts))
 - `@deepseek-ai/dsh-output-retention` ([`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts))
+- `@deepseek-ai/dsh-package-manifest` ([`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts))
 - `@deepseek-ai/dsh-patch-file` ([`packages/util/patch-file/src/index.ts`](../packages/util/patch-file/src/index.ts))
 - `@deepseek-ai/dsh-patch-file` ([`packages/util/patch-file/src/index.ts`](../packages/util/patch-file/src/index.ts))
 - `@deepseek-ai/dsh-sandbox-windows-acl` ([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts))
 - `@deepseek-ai/dsh-sandbox-windows-acl` ([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts))
 - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
 - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))

+ 1 - 0
docs/config-catalog.zh.md

@@ -3509,6 +3509,7 @@ export interface Config {
 - `@deepseek-ai/dsh-loader-smoke`([`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts))
 - `@deepseek-ai/dsh-loader-smoke`([`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts))
 - `@deepseek-ai/dsh-native-command`([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts))
 - `@deepseek-ai/dsh-native-command`([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts))
 - `@deepseek-ai/dsh-output-retention`([`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts))
 - `@deepseek-ai/dsh-output-retention`([`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts))
+- `@deepseek-ai/dsh-package-manifest` ([`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts))
 - `@deepseek-ai/dsh-patch-file`([`packages/util/patch-file/src/index.ts`](../packages/util/patch-file/src/index.ts))
 - `@deepseek-ai/dsh-patch-file`([`packages/util/patch-file/src/index.ts`](../packages/util/patch-file/src/index.ts))
 - `@deepseek-ai/dsh-sandbox-windows-acl`([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts))
 - `@deepseek-ai/dsh-sandbox-windows-acl`([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts))
 - `@deepseek-ai/dsh-scope`([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
 - `@deepseek-ai/dsh-scope`([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: dddb6dd6f930e18bf2e3b469494ee7a7a44ca46d
-module-graph.zh.md: 905bddf9773d21c5d6e1a2448b37bfe5ba0c5ef3
+module-graph.md: 7ab90aac45d53b8afed43ff96cf876abe6411f5c
+module-graph.zh.md: eac968d75a621f84697becfb77b58d97d1a89021

+ 4 - 1
docs/module-graph.md

@@ -16,6 +16,7 @@ flowchart TD
     pkg_launch_environment["launch-environment"]
     pkg_launch_environment["launch-environment"]
     pkg_native_command["native-command"]
     pkg_native_command["native-command"]
     pkg_output_retention["output-retention"]
     pkg_output_retention["output-retention"]
+    pkg_package_manifest["package-manifest"]
     pkg_patch_file["patch-file"]
     pkg_patch_file["patch-file"]
     pkg_timeout["timeout"]
     pkg_timeout["timeout"]
     pkg_util_crypto["util-crypto"]
     pkg_util_crypto["util-crypto"]
@@ -936,6 +937,7 @@ flowchart TD
   pkg_host_plugin_manager --> pkg_agent
   pkg_host_plugin_manager --> pkg_agent
   pkg_host_plugin_manager --> pkg_agent_presets
   pkg_host_plugin_manager --> pkg_agent_presets
   pkg_host_plugin_manager --> pkg_app_boot
   pkg_host_plugin_manager --> pkg_app_boot
+  pkg_host_plugin_manager --> pkg_package_manifest
   pkg_host_plugin_manager --> pkg_patch_file
   pkg_host_plugin_manager --> pkg_patch_file
   pkg_host_plugin_manager --> pkg_typert_protocol
   pkg_host_plugin_manager --> pkg_typert_protocol
   pkg_host_plugin_manager --> pkg_util_values
   pkg_host_plugin_manager --> pkg_util_values
@@ -1177,6 +1179,7 @@ flowchart TD
 | [`launch-environment`](../packages/util/launch-environment) | `util` | — |
 | [`launch-environment`](../packages/util/launch-environment) | `util` | — |
 | [`native-command`](../packages/util/native-command) | `util` | — |
 | [`native-command`](../packages/util/native-command) | `util` | — |
 | [`output-retention`](../packages/util/output-retention) | `util` | — |
 | [`output-retention`](../packages/util/output-retention) | `util` | — |
+| [`package-manifest`](../packages/util/package-manifest) | `util` | — |
 | [`patch-file`](../packages/util/patch-file) | `util` | — |
 | [`patch-file`](../packages/util/patch-file) | `util` | — |
 | [`timeout`](../packages/util/timeout) | `util` | — |
 | [`timeout`](../packages/util/timeout) | `util` | — |
 | [`util-crypto`](../packages/util/crypto) | `util` | — |
 | [`util-crypto`](../packages/util/crypto) | `util` | — |
@@ -1397,7 +1400,7 @@ flowchart TD
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
 | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
-| [`host-plugin-manager`](../packages/host/plugin-manager) | `host` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`patch-file`](../packages/util/patch-file), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) |
+| [`host-plugin-manager`](../packages/host/plugin-manager) | `host` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`package-manifest`](../packages/util/package-manifest), [`patch-file`](../packages/util/patch-file), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) |
 | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
 | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |

+ 4 - 1
docs/module-graph.zh.md

@@ -18,6 +18,7 @@ flowchart TD
     pkg_launch_environment["launch-environment"]
     pkg_launch_environment["launch-environment"]
     pkg_native_command["native-command"]
     pkg_native_command["native-command"]
     pkg_output_retention["output-retention"]
     pkg_output_retention["output-retention"]
+    pkg_package_manifest["package-manifest"]
     pkg_patch_file["patch-file"]
     pkg_patch_file["patch-file"]
     pkg_timeout["timeout"]
     pkg_timeout["timeout"]
     pkg_util_crypto["util-crypto"]
     pkg_util_crypto["util-crypto"]
@@ -938,6 +939,7 @@ flowchart TD
   pkg_host_plugin_manager --> pkg_agent
   pkg_host_plugin_manager --> pkg_agent
   pkg_host_plugin_manager --> pkg_agent_presets
   pkg_host_plugin_manager --> pkg_agent_presets
   pkg_host_plugin_manager --> pkg_app_boot
   pkg_host_plugin_manager --> pkg_app_boot
+  pkg_host_plugin_manager --> pkg_package_manifest
   pkg_host_plugin_manager --> pkg_patch_file
   pkg_host_plugin_manager --> pkg_patch_file
   pkg_host_plugin_manager --> pkg_typert_protocol
   pkg_host_plugin_manager --> pkg_typert_protocol
   pkg_host_plugin_manager --> pkg_util_values
   pkg_host_plugin_manager --> pkg_util_values
@@ -1179,6 +1181,7 @@ flowchart TD
 | [`launch-environment`](../packages/util/launch-environment) | `util` | — |
 | [`launch-environment`](../packages/util/launch-environment) | `util` | — |
 | [`native-command`](../packages/util/native-command) | `util` | — |
 | [`native-command`](../packages/util/native-command) | `util` | — |
 | [`output-retention`](../packages/util/output-retention) | `util` | — |
 | [`output-retention`](../packages/util/output-retention) | `util` | — |
+| [`package-manifest`](../packages/util/package-manifest) | `util` | — |
 | [`patch-file`](../packages/util/patch-file) | `util` | — |
 | [`patch-file`](../packages/util/patch-file) | `util` | — |
 | [`timeout`](../packages/util/timeout) | `util` | — |
 | [`timeout`](../packages/util/timeout) | `util` | — |
 | [`util-crypto`](../packages/util/crypto) | `util` | — |
 | [`util-crypto`](../packages/util/crypto) | `util` | — |
@@ -1399,7 +1402,7 @@ flowchart TD
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
 | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
-| [`host-plugin-manager`](../packages/host/plugin-manager) | `host` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`patch-file`](../packages/util/patch-file), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) |
+| [`host-plugin-manager`](../packages/host/plugin-manager) | `host` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`package-manifest`](../packages/util/package-manifest), [`patch-file`](../packages/util/patch-file), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) |
 | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
 | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/core.md
 #   pnpm run verify-translation-pairing --write docs/subsystems/core.md
-core.md: b71337c5386f7a5cf4484a7a9a393fcd5db48296
-core.zh.md: 665072b80527ded6df0e2d19cf7ffdf25768b5ad
+core.md: 43b8cddfc74f4a2cf734a40c1ac9538cd62ed91f
+core.zh.md: 86bd3baea8d2398da415077e22024dde7e9078fb

+ 7 - 4
docs/subsystems/core.md

@@ -987,8 +987,9 @@ originOf(rowId: string): RowOrigin | undefined
 
 
 /**
 /**
  * Row ids the user patch layers disable with a literal `disabled: true`.
  * Row ids the user patch layers disable with a literal `disabled: true`.
- * A `!!js` gate in a user file is a condition, not a user decision, and is
- * left to the composition.
+ * A `!!js` gate in a user file stays an expression node when read from
+ * disk, so it is a condition, not a user decision, and is left to the
+ * composition.
  * @returns the ids, re-read from disk on every call.
  * @returns the ids, re-read from disk on every call.
  */
  */
 userDisabledRowIds(): Set<string>
 userDisabledRowIds(): Set<string>
@@ -998,8 +999,10 @@ userDisabledRowIds(): Set<string>
  * as they stand now. The root Include re-applies the stack transactionally:
  * 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
  * 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
  * 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.
+ * update back with the previous tree still running. The candidate profile,
+ * its ownership, and its conflicts become the committed composition only
+ * once the update holds; until then, and after a rejection, `current`,
+ * `layers`, `originOf`, and `conflicts` keep describing the running tree.
  * @param options - `reloadBundles` re-reads the profile manifest first, so a
  * @param options - `reloadBundles` re-reads the profile manifest first, so a
  * bundle enabled or installed since boot joins the stack.
  * bundle enabled or installed since boot joins the stack.
  * @throws when the root include is not mounted, or the Loader rejected the update.
  * @throws when the root include is not mounted, or the Loader rejected the update.

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

@@ -997,8 +997,9 @@ originOf(rowId: string): RowOrigin | undefined
 
 
 /**
 /**
  * Row ids the user patch layers disable with a literal `disabled: true`.
  * Row ids the user patch layers disable with a literal `disabled: true`.
- * A `!!js` gate in a user file is a condition, not a user decision, and is
- * left to the composition.
+ * A `!!js` gate in a user file stays an expression node when read from
+ * disk, so it is a condition, not a user decision, and is left to the
+ * composition.
  * @returns the ids, re-read from disk on every call.
  * @returns the ids, re-read from disk on every call.
  */
  */
 userDisabledRowIds(): Set<string>
 userDisabledRowIds(): Set<string>
@@ -1008,8 +1009,10 @@ userDisabledRowIds(): Set<string>
  * as they stand now. The root Include re-applies the stack transactionally:
  * 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
  * 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
  * 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.
+ * update back with the previous tree still running. The candidate profile,
+ * its ownership, and its conflicts become the committed composition only
+ * once the update holds; until then, and after a rejection, `current`,
+ * `layers`, `originOf`, and `conflicts` keep describing the running tree.
  * @param options - `reloadBundles` re-reads the profile manifest first, so a
  * @param options - `reloadBundles` re-reads the profile manifest first, so a
  * bundle enabled or installed since boot joins the stack.
  * bundle enabled or installed since boot joins the stack.
  * @throws when the root include is not mounted, or the Loader rejected the update.
  * @throws when the root include is not mounted, or the Loader rejected the update.

+ 2 - 2
docs/user/develop/basic/publish.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/user/develop/basic/publish.md
 #   pnpm run verify-translation-pairing --write docs/user/develop/basic/publish.md
-publish.md: 816b36ac99695c95332ae99439c1ccfc3c4a8732
-publish.zh.md: c3918913d6cb09b212eae35902a375539bb047cb
+publish.md: 6ebac3de6d364bb4bc05d364197d11ada85a0c8c
+publish.zh.md: 012878d3be0eb15704423107a4cbd1b79790966e

+ 3 - 1
docs/user/develop/basic/publish.md

@@ -43,6 +43,8 @@ Create `hello-plugin/package.json`:
 }
 }
 ```
 ```
 
 
+Two more `dsh` keys describe the package to people: `dsh.title` names it in the plugin list, and `dsh.plugins` lists modules a user can add to a composition one at a time, beside the layer the bundle mounts as a whole — each entry names the module (`"name": "dsh-hello-plugin/extra"`) with an optional `title` and default `config`. Every `dsh` key is declared in [`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.md).
+
 Create `hello-plugin/index.js` with the plugin entry point:
 Create `hello-plugin/index.js` with the plugin entry point:
 
 
 ```js
 ```js
@@ -109,7 +111,7 @@ dsh --profile demo
 
 
 `dsh plugin --profile demo remove dsh-hello-plugin` removes both the dependency and the layer.
 `dsh plugin --profile demo remove dsh-hello-plugin` removes both the dependency and the layer.
 
 
-An installed bundle mounts as an external layer: the dump shows its rows inside one group named `bundle/dsh-hello-plugin`, and each row id carries the package prefix, so the row above is `dsh-hello-plugin/hello` in the mounted tree — a user patch that targets it names that id. A row of yours that fails to start is isolated and reported in the plugin list instead of stopping `dsh`; if your bundle provides a service the built-in rows inject, declare `dsh.bundle.stage: boot` so it mounts like a built-in one and fails loud. Prefixing does not rewrite string literals, so a `!!js` disabled expression that compares `e.options.id` to your own id should compare `e.options.name` instead.
+An installed bundle mounts as an external layer: the dump shows its rows inside one group named `bundle/dsh-hello-plugin`, and each row keeps the id your patch declares, so the row above is `hello` in the mounted tree and a user patch that targets it names that id. Row ids are shared across every layer: if another layer already declares one of yours, or your patch declares one twice, the whole bundle is left out and the reason is printed at boot and shown in the plugin list. A row of yours that fails to start is isolated and reported in the plugin list instead of stopping `dsh`; if your bundle provides a service the built-in rows inject, declare `dsh.bundle.stage: boot` so it mounts like a built-in one and fails loud.
 
 
 ## The loading order
 ## The loading order
 
 

+ 3 - 1
docs/user/develop/basic/publish.zh.md

@@ -43,6 +43,8 @@ hello-plugin/
 }
 }
 ```
 ```
 
 
+另有两个 `dsh` 键面向使用者描述这个包:`dsh.title` 是它在插件列表里的名字,`dsh.plugins` 列出使用者可以逐个加进组合的模块,与组合包整体挂载的层并列——每一项写模块名(`"name": "dsh-hello-plugin/extra"`),可选 `title` 与默认 `config`。所有 `dsh` 键都声明在 [`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.zh.md) 里。
+
 创建 `hello-plugin/index.js`,写入插件入口:
 创建 `hello-plugin/index.js`,写入插件入口:
 
 
 ```js
 ```js
@@ -109,7 +111,7 @@ dsh --profile demo
 
 
 `dsh plugin --profile demo remove dsh-hello-plugin` 会同时移除依赖和对应的层。
 `dsh plugin --profile demo remove dsh-hello-plugin` 会同时移除依赖和对应的层。
 
 
-已安装的组合包作为外部层挂载:dump 会把它的行显示在一个名为 `bundle/dsh-hello-plugin` 的组里,每个行 id 带上包名前缀,因此上面那一行在挂载后的树里是 `dsh-hello-plugin/hello`——针对它的用户 patch 要写这个 id。你的某一行启动失败时会被隔离并在插件列表里报告,而不是让 `dsh` 停下;如果你的组合包提供内置行所注入的服务,请声明 `dsh.bundle.stage: boot`,让它像内置行一样挂载并明确失败。前缀不会改写字符串字面量,因此用 `e.options.id` 与自己 id 比较的 `!!js` disabled 表达式应改为比较 `e.options.name`。
+已安装的组合包作为外部层挂载:dump 会把它的行显示在一个名为 `bundle/dsh-hello-plugin` 的组里,每一行保持你的 patch 所声明的 id,因此上面那一行在挂载后的树里仍是 `hello`,针对它的用户 patch 就写这个 id。行 id 在所有层之间共用:如果别的层已经声明了你的某个 id,或者你的 patch 把一个 id 声明了两次,整个组合包会被排除,原因在启动时打印并显示在插件列表里。你的某一行启动失败时会被隔离并在插件列表里报告,而不是让 `dsh` 停下;如果你的组合包提供内置行所注入的服务,请声明 `dsh.bundle.stage: boot`,让它像内置行一样挂载并明确失败。
 
 
 ## 加载顺序
 ## 加载顺序
 
 

+ 1 - 0
package.json

@@ -161,6 +161,7 @@
     "postinstall": "node scripts/install-lefthook.mjs"
     "postinstall": "node scripts/install-lefthook.mjs"
   },
   },
   "devDependencies": {
   "devDependencies": {
+    "@deepseek-ai/dsh-package-manifest": "workspace:^",
     "@deepseek-ai/dsh-tool-session-query": "workspace:^",
     "@deepseek-ai/dsh-tool-session-query": "workspace:^",
     "@deepseek-ai/dsh-web-fetch-http": "workspace:^",
     "@deepseek-ai/dsh-web-fetch-http": "workspace:^",
     "@stylistic/eslint-plugin": "^5.10.0",
     "@stylistic/eslint-plugin": "^5.10.0",

+ 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;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
 #   pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
-README.md: 51ccb305c5afce576093f6b432dbb6211d800b75
-README.zh.md: 16dbc5f3caac0de66c9e6bf8d73b43274f71d598
+README.md: 8b30b608608aa73278739cda624a0b32f4c304a2
+README.zh.md: 50d0d7d4a835e9b6b10556ead060a551fbe311a3

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

@@ -45,6 +45,8 @@ With that entry point, success looks like a running app with every plugin active
 <a id="profiles"></a>
 <a id="profiles"></a>
 ### Profiles
 ### Profiles
 
 
+Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot owns profile loading, JSON validation, and resolved runtime data.
+
 A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles, its own `cordis.patch.yml`, and `patchReload: live | startup`; omitted reload policy keeps the historical `live` default for custom profiles. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh plugin` creates custom profiles, and a missing bundle or one without a patch declaration fails startup loudly.
 A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles, its own `cordis.patch.yml`, and `patchReload: live | startup`; omitted reload policy keeps the historical `live` default for custom profiles. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh plugin` creates custom profiles, and a missing bundle or one without a patch declaration fails startup loudly.
 
 
 Your machine-local preferences also live in the Harness home:
 Your machine-local preferences also live in the Harness home:
@@ -54,9 +56,11 @@ 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.
 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>` 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.
+Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal.
+
+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, or declares one of its own ids twice, 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.
+After the tree is up the launcher provides `ctx.profileRuntime`, which holds the composition the tree runs — the profile, the installation anchor, the layer that owns each row, and the rows the composition left out — reads which rows the user patch files disable, and is the one entry point that recomposes the tree: the patch watchers and the [plugin manager](../../host/plugin-manager/README.md), enabling or retrying a bundle, all call it, and a rejected update leaves its facts describing the tree still running; `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 the process keeps running, with nothing stopped or attributed to a plugin; an uncaught exception is reported and exits.
 
 
 ### Previewing the effective configuration
 ### Previewing the effective configuration
 
 
@@ -86,10 +90,10 @@ 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.
 - **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.
 - **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, 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.
+- **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, a group that unmounts drops its rows' records, 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.
 - **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.
 - **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. It calls a package a `plugin` only when the package declares itself to dsh — a `dsh` section or a dependency on `@deepseek-ai/cordis` — and its main export is plugin-shaped; a bare function export (`lodash`) is a `library`. Records are cached under the profile's `.dsh-plugins/` with a format number, so a record an older probe wrote is probed again rather than trusted.
+- **The probe never runs a package in the host.** `probePackage` reads an installed package's manifest here and imports it in a child process that reports over an IPC channel, so a package that throws, exits, hangs, prints at import, or brings its own copy of cordis costs one child and yields a record with the reason; the child's report and the cached record are validated field by field before either is trusted. It calls a package a `plugin` only when the package declares itself to dsh — a `dsh` section or a dependency on `@deepseek-ai/cordis` — and its main export is plugin-shaped; a bare function export (`lodash`) is a `library`. Records are cached under the profile's `.dsh-plugins/` with a format number, so a record an older probe wrote is probed again rather than trusted.
 - **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.
 - **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.
 - **One rejection checkpoint.** `assertEntriesActivated` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal.
 - **One rejection checkpoint.** `assertEntriesActivated` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal.
 - **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack so the startup diagnostic preserves the original activation error instead of only the wrap chain.
 - **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack so the startup diagnostic preserves the original activation error instead of only the wrap chain.
@@ -107,8 +111,8 @@ The exports each own one stage of the boot: config resolution and snapshot repla
 | [`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/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/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/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 |
+| [`src/profile-runtime.ts`](src/profile-runtime.ts) | The `profileRuntime` service: the committed composition (profile, row provenance, conflicts), user-disabled rows, recomposition |
+| [`src/probe.ts`](src/probe.ts) | The package probe and its per-profile cache; [`src/probe-child.ts`](src/probe-child.ts) is the child entry it spawns and [`src/probe-report.ts`](src/probe-report.ts) the report it validates |
 | — | No runtime invariant companion is published; this presentation adapter owns no durable package-local event stream; boundary and replay tests cover its protocol mapping. |
 | — | No runtime invariant companion is published; this presentation adapter owns no durable package-local event stream; boundary and replay tests cover its protocol mapping. |
 
 
 </details>
 </details>

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

@@ -45,6 +45,8 @@ const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHO
 <a id="profiles"></a>
 <a id="profiles"></a>
 ### Profile
 ### Profile
 
 
+Profile 与 bundle 的声明类型从 [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.zh.md) 导入。App-boot 负责 profile 加载、JSON 校验和解析后的运行时数据。
+
 profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装 bundle、自身 `cordis.patch.yml` 与 `patchReload: live | startup` 组成;自定义 profile 省略 reload 策略时保留历史 `live` 默认值。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立 bundle,其他模板保留 base 加模式 bundle 的栈。`dsh plugin` 创建自定义 profile;缺失 bundle 或未声明 patch 的 bundle 会让启动明确失败。
 profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装 bundle、自身 `cordis.patch.yml` 与 `patchReload: live | startup` 组成;自定义 profile 省略 reload 策略时保留历史 `live` 默认值。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立 bundle,其他模板保留 base 加模式 bundle 的栈。`dsh plugin` 创建自定义 profile;缺失 bundle 或未声明 patch 的 bundle 会让启动明确失败。
 
 
 你的机器本地偏好同样位于 harness home 中:
 你的机器本地偏好同样位于 harness home 中:
@@ -54,9 +56,11 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 
 带 `patchReload: live` 的 profile 会监视两份用户 patch 文件:有效编辑无需重启即可重新组合,被拒绝的编辑则让最后一个可用应用继续运行。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR 回退。
 带 `patchReload: live` 的 profile 会监视两份用户 patch 文件:有效编辑无需重启即可重新组合,被拒绝的编辑则让最后一个可用应用继续运行。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR 回退。
 
 
-用 `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 时该行被丢弃,同样报告。
+插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。
+
+用 `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,或把自己的某个 id 声明了两次,就整层被排除,并在 stderr 与插件列表里报告;用户层插入已被占用的 id 时该行被丢弃,同样报告。
 
 
-树起来之后 launcher 提供 `ctx.profileRuntime`:它持有已启动 profile 的事实与安装锚点,把每一行归属到插入它的层,读取用户 patch 文件停用了哪些行,并重新组合整棵树——patch 监视器走的正是这条路,[插件管理器](../../host/plugin-manager/README.zh.md) 启用或重试组合包也走它;这样的调用方之后会运行 `recordContainedStates`,因为启动审计不会再跑一次。启动期的 fail-loud rejection 守卫在树起来后卸载:启动后未处理的 rejection 会被报告并兜住,未捕获的异常会被报告并退出。
+树起来之后 launcher 提供 `ctx.profileRuntime`:它持有树正在运行的组合——profile、安装锚点、每一行的归属层、被组合排除的行——读取用户 patch 文件停用了哪些行,并且是重组整棵树的唯一入口:patch 监视器与启用或重试组合包的[插件管理器](../../host/plugin-manager/README.zh.md)都调用它,被拒的更新留下的事实仍然描述正在运行的树;这样的调用方之后会运行 `recordContainedStates`,因为启动审计不会再跑一次。启动期的 fail-loud rejection 守卫在树起来后卸载:启动后未处理的 rejection 会被报告,进程继续运行,不停止任何任务也不归属到任何插件;未捕获的异常会被报告并退出。
 
 
 ### 预览生效配置
 ### 预览生效配置
 
 
@@ -86,10 +90,10 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 
 - **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。
 - **与渠道无关的库。** 此包不包含 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`,受控组则是外部组合包挂载的位置。三者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
 - **三个 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` 是整组事务,因此 `composeExternalLayer` 把每个 `runtime` 阶段外部层的插入行按组合包声明的 id 包进一个 `cordis:contained-group`;该组的 `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` 走同一个函数。
 - **行 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` 以提示行报告。
 - **fail-loud 只在启动期。** `installFailLoud` 对任何未处理 rejection 退出,因为启动期间它就是加载失败;树起来后 launcher 卸载它并安装 `installRuntimeGuards`:rejection 被报告并继续运行,未捕获异常被报告并退出。内置条目下失败的嵌套 fiber(`ctx.inject()` 的延续)由 `warnNestedFiberFailures` 以提示行报告。
-- **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它,因此抛错、退出、挂起或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录。只有包向 dsh 声明了自己——有 `dsh` 段或依赖 `@deepseek-ai/cordis`——且主导出是插件形状时才判为 `plugin`;光是导出一个函数(`lodash`)的包是 `library`。记录缓存在 profile 的 `.dsh-plugins/` 下并带格式号,旧版探针写的记录会重新探测而不是被信任。
+- **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它并经 IPC 通道接收报告,因此抛错、退出、挂起、import 时打印或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录;子进程的报告与缓存记录都逐字段校验之后才被信任。只有包向 dsh 声明了自己——有 `dsh` 段或依赖 `@deepseek-ai/cordis`——且主导出是插件形状时才判为 `plugin`;光是导出一个函数(`lodash`)的包是 `library`。记录缓存在 profile 的 `.dsh-plugins/` 下并带格式号,旧版探针写的记录会重新探测而不是被信任。
 - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部 bundle 若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。
 - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部 bundle 若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。
 - **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。
 - **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。
 - **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。
 - **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。
@@ -107,8 +111,8 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 | [`src/external-bundles.ts`](src/external-bundles.ts) | 外部层组合(受控组、覆盖报告)、`bundleLayerPatches`,与安装、启用、停用背后的 manifest 操作 |
 | [`src/external-bundles.ts`](src/external-bundles.ts) | 外部层组合(受控组、覆盖报告)、`bundleLayerPatches`,与安装、启用、停用背后的 manifest 操作 |
 | [`src/compose-stack.ts`](src/compose-stack.ts) | 整叠层的行 id 归属:`claimLayerIds`、`composeProfileStack`、冲突记录 |
 | [`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/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 的缓存 |
+| [`src/profile-runtime.ts`](src/profile-runtime.ts) | `profileRuntime` 服务:已提交的组合(profile、行来源、冲突)、用户停用的行、重新组合 |
+| [`src/probe.ts`](src/probe.ts) | 包探针及其按 profile 的缓存;[`src/probe-child.ts`](src/probe-child.ts) 是它生成的子进程入口,[`src/probe-report.ts`](src/probe-report.ts) 是它校验的报告 |
 | — | 不发布运行时不变式伴生入口;边界与回放测试覆盖其协议映射。 |
 | — | 不发布运行时不变式伴生入口;边界与回放测试覆盖其协议映射。 |
 
 
 </details>
 </details>

+ 2 - 0
packages/boot/app-boot/package.json

@@ -23,11 +23,13 @@
   },
   },
   "files": [
   "files": [
     "lib/index.js",
     "lib/index.js",
+    "lib/probe-child.js",
     "lib/types/**/*.d.ts"
     "lib/types/**/*.d.ts"
   ],
   ],
   "license": "MIT",
   "license": "MIT",
   "dependencies": {
   "dependencies": {
     "@deepseek-ai/dsh-atomic-write": "workspace:^",
     "@deepseek-ai/dsh-atomic-write": "workspace:^",
+    "@deepseek-ai/dsh-package-manifest": "workspace:^",
     "@deepseek-ai/dsh-patch-file": "workspace:^",
     "@deepseek-ai/dsh-patch-file": "workspace:^",
     "js-yaml": "^4.2.0",
     "js-yaml": "^4.2.0",
     "resolve.exports": "^2.0.3"
     "resolve.exports": "^2.0.3"

+ 74 - 49
packages/boot/app-boot/src/compose-stack.ts

@@ -3,16 +3,20 @@
  * are unique per Loader tree, and the vendored group `create()` re-parents an
  * 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
  * 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
  * 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.
+ * ids first and a duplicate among or inside them fails loud; an external
+ * bundle whose id is already claimed, or which declares one of its own ids
+ * twice, is skipped whole and recorded as a conflict; a user layer's insert
+ * of a claimed id drops that row and records it. One composition yields the
+ * patches, the owner of every id, and the conflicts, and each contained
+ * layer is rendered once. Boot, live recomposition, and the config dump all
+ * compose through here, so they agree.
  * @module @deepseek-ai/dsh-app-boot/compose-stack
  * @module @deepseek-ai/dsh-app-boot/compose-stack
  */
  */
 
 
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import { bundleLayerPatches, composeExternalLayer, isContainedLayer } from './external-bundles.ts'
+import { composeExternalLayer, isContainedLayer, type ComposedExternalLayer } from './external-bundles.ts'
+import { visitInsertedRows, visitRowTree } from './patch-rows.ts'
 import type { ProfileLayer } from './profile.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. */
 /** One user-owned patch list in the stack: the profile file, the home file, or a `--patch` overlay. */
@@ -23,9 +27,9 @@ export interface StackUserLayer {
   readonly patches: PatchOptions[]
   readonly patches: PatchOptions[]
 }
 }
 
 
-/** One row a layer could not mount because another layer already declares its id. */
+/** One row a layer could not mount because its id is already declared. */
 export interface RowConflict {
 export interface RowConflict {
-  /** The id both layers declare. */
+  /** The id declared more than once. */
   readonly rowId: string
   readonly rowId: string
   /** The module the losing row named. */
   /** The module the losing row named. */
   readonly moduleName: string
   readonly moduleName: string
@@ -33,8 +37,13 @@ export interface RowConflict {
   readonly layer: string
   readonly layer: string
   /** The external bundle that lost, when the loser is one; its whole layer is left out. */
   /** The external bundle that lost, when the loser is one; its whole layer is left out. */
   readonly packageName?: string
   readonly packageName?: string
-  /** The layer that owns the id: a bundle's package name or a user layer's label. */
+  /**
+   * The layer that already declares the id: a bundle's package name or a user
+   * layer's label, or the losing layer itself when it declares the id twice.
+   */
   readonly declaredBy: string
   readonly declaredBy: string
+  /** The reason as diagnostics and the plugin list state it, without the layer that lost. */
+  readonly message: string
 }
 }
 
 
 /** The stack as the root include should mount it, with what it left out. */
 /** The stack as the root include should mount it, with what it left out. */
@@ -43,80 +52,90 @@ export interface ComposedStack {
   readonly patches: PatchOptions[]
   readonly patches: PatchOptions[]
   /** The same patches per layer, labelled by package name or user-layer label; a skipped bundle has no entry. */
   /** The same patches per layer, labelled by package name or user-layer label; a skipped bundle has no entry. */
   readonly layers: StackUserLayer[]
   readonly layers: StackUserLayer[]
+  /** The bundle layer that owns each id a bundle layer introduces; rows user layers insert are not listed. */
+  readonly owners: ReadonlyMap<string, ProfileLayer>
   /** Every row left out, in stack order. */
   /** Every row left out, in stack order. */
   readonly conflicts: RowConflict[]
   readonly conflicts: RowConflict[]
-  /** External bundles left out because a row id was already claimed. */
+  /** External bundles left out because a row id was already claimed or repeated. */
   readonly skippedBundles: string[]
   readonly skippedBundles: string[]
 }
 }
 
 
-/** Row-id ownership across the bundle layers: who owns each id, and which external bundles lost. */
+/** Row-id ownership across the bundle layers: who owns each id, which external bundles lost, and how the rest mount. */
 export interface LayerOwnership {
 export interface LayerOwnership {
   /** The layer that owns each id a bundle layer introduces. */
   /** The layer that owns each id a bundle layer introduces. */
   readonly owners: Map<string, ProfileLayer>
   readonly owners: Map<string, ProfileLayer>
   /** The conflicts of each external bundle left out, by package name. */
   /** The conflicts of each external bundle left out, by package name. */
   readonly skipped: Map<string, RowConflict[]>
   readonly skipped: Map<string, RowConflict[]>
+  /** The composition of each contained layer that owns its ids, by package name; rendered once and mounted as is. */
+  readonly composed: Map<string, ComposedExternalLayer>
 }
 }
 
 
-/** 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
+/** One conflict with its message: the id's other declarer, or the losing layer itself declaring it twice. */
+function rowConflict(fields: Omit<RowConflict, 'message'>): RowConflict {
+  const id = JSON.stringify(fields.rowId)
+  const message = fields.declaredBy === fields.layer
+    ? `row ${id} is declared twice by ${fields.layer}`
+    : `row ${id} is already declared by ${fields.declaredBy}`
+  return { ...fields, message }
 }
 }
 
 
 /** The ids one inserted row carries: its own and, for a group, its children's. */
 /** The ids one inserted row carries: its own and, for a group, its children's. */
 function rowIds(row: EntryOptions): string[] {
 function rowIds(row: EntryOptions): string[] {
   const ids: string[] = []
   const ids: string[] = []
-  const visit = (entry: EntryOptions): void => {
+  visitRowTree(row, (entry) => {
     if (typeof entry.id === 'string') ids.push(entry.id)
     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
   return ids
 }
 }
 
 
 /**
 /**
  * Decide row-id ownership across the bundle layers. Built-in and boot-staged
  * 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.
+ * layers claim first, in manifest order; an id two of them declare, or one
+ * declares twice, is a defect of the shipped composition and throws.
+ * Contained external layers then claim in manifest order, each rendered once
+ * here; one whose id is already owned, or which declares an id twice, is left
+ * out whole.
  * @param layers - the profile's bundle layers, in manifest order.
  * @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.
+ * @returns the owner of every claimed id, the conflicts of each skipped bundle, and the composition of each mounted one.
+ * @throws when two built-in or boot-staged layers declare the same id, or one of them declares an id twice.
  */
  */
 export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
 export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
   const owners = new Map<string, ProfileLayer>()
   const owners = new Map<string, ProfileLayer>()
   for (const layer of layers) {
   for (const layer of layers) {
     if (isContainedLayer(layer)) continue
     if (isContainedLayer(layer)) continue
-    for (const id of insertedRows(layer.patches).keys()) {
-      const owner = owners.get(id)
+    visitInsertedRows(layer.patches, (row) => {
+      if (typeof row.id !== 'string') return
+      const owner = owners.get(row.id)
+      if (owner === layer) throw new Error(`row ${JSON.stringify(row.id)} is declared twice by ${layer.packageName}`)
       if (owner !== undefined) {
       if (owner !== undefined) {
-        throw new Error(`row ${JSON.stringify(id)} is declared by both ${owner.packageName} and ${layer.packageName}`)
+        throw new Error(`row ${JSON.stringify(row.id)} is declared by both ${owner.packageName} and ${layer.packageName}`)
       }
       }
-      owners.set(id, layer)
-    }
+      owners.set(row.id, layer)
+    })
   }
   }
   const skipped = new Map<string, RowConflict[]>()
   const skipped = new Map<string, RowConflict[]>()
+  const composed = new Map<string, ComposedExternalLayer>()
   for (const layer of layers) {
   for (const layer of layers) {
     if (!isContainedLayer(layer)) continue
     if (!isContainedLayer(layer)) continue
-    const { rows } = composeExternalLayer(layer)
-    const conflicts: RowConflict[] = []
-    for (const [rowId, moduleName] of rows) {
+    const { packageName } = layer
+    const composition = composeExternalLayer(layer)
+    const conflicts: RowConflict[] = composition.duplicates.map(({ rowId, moduleName }) => (
+      rowConflict({ rowId, moduleName, layer: packageName, packageName, declaredBy: packageName })
+    ))
+    for (const [rowId, moduleName] of composition.rows) {
       const owner = owners.get(rowId)
       const owner = owners.get(rowId)
-      if (owner === undefined) continue
-      conflicts.push({ rowId, moduleName, layer: layer.packageName, packageName: layer.packageName, declaredBy: owner.packageName })
+      if (owner !== undefined) {
+        conflicts.push(rowConflict({ rowId, moduleName, layer: packageName, packageName, declaredBy: owner.packageName }))
+      }
     }
     }
     if (conflicts.length > 0) {
     if (conflicts.length > 0) {
-      skipped.set(layer.packageName, conflicts)
+      skipped.set(packageName, conflicts)
       continue
       continue
     }
     }
-    for (const id of rows.keys()) owners.set(id, layer)
+    for (const id of composition.rows.keys()) owners.set(id, layer)
+    composed.set(packageName, composition)
   }
   }
-  return { owners, skipped }
+  return { owners, skipped, composed }
 }
 }
 
 
 /**
 /**
@@ -127,8 +146,8 @@ export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
  * @param binName - the diagnostic prefix on a thrown built-in duplicate.
  * @param binName - the diagnostic prefix on a thrown built-in duplicate.
  * @param layers - the profile's bundle layers, in manifest order.
  * @param layers - the profile's bundle layers, in manifest order.
  * @param userLayers - the user-owned layers, in application 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.
+ * @returns the patches to mount, the owner of every bundle id, the conflicts, and the bundles left out.
+ * @throws when two built-in or boot-staged layers declare the same id, or one of them declares an id twice.
  */
  */
 export function composeProfileStack(
 export function composeProfileStack(
   binName: string, layers: readonly ProfileLayer[], userLayers: readonly StackUserLayer[],
   binName: string, layers: readonly ProfileLayer[], userLayers: readonly StackUserLayer[],
@@ -149,7 +168,8 @@ export function composeProfileStack(
       skippedBundles.push(layer.packageName)
       skippedBundles.push(layer.packageName)
       continue
       continue
     }
     }
-    composedLayers.push({ label: layer.packageName, patches: bundleLayerPatches(layer) })
+    const composition = ownership.composed.get(layer.packageName)
+    composedLayers.push({ label: layer.packageName, patches: composition === undefined ? layer.patches : composition.patches })
   }
   }
   const claimed = new Map<string, string>()
   const claimed = new Map<string, string>()
   for (const [id, layer] of ownership.owners) claimed.set(id, layer.packageName)
   for (const [id, layer] of ownership.owners) claimed.set(id, layer.packageName)
@@ -165,7 +185,7 @@ export function composeProfileStack(
         const ids = rowIds(row)
         const ids = rowIds(row)
         const taken = ids.map(id => [id, claimed.get(id)] as const).find(([, owner]) => owner !== undefined)
         const taken = ids.map(id => [id, claimed.get(id)] as const).find(([, owner]) => owner !== undefined)
         if (taken?.[1] !== undefined) {
         if (taken?.[1] !== undefined) {
-          conflicts.push({ rowId: taken[0], moduleName: row.name, layer: userLayer.label, declaredBy: taken[1] })
+          conflicts.push(rowConflict({ rowId: taken[0], moduleName: row.name, layer: userLayer.label, declaredBy: taken[1] }))
           continue
           continue
         }
         }
         for (const id of ids) claimed.set(id, userLayer.label)
         for (const id of ids) claimed.set(id, userLayer.label)
@@ -176,7 +196,13 @@ export function composeProfileStack(
     }
     }
     composedLayers.push({ label: userLayer.label, patches })
     composedLayers.push({ label: userLayer.label, patches })
   }
   }
-  return { patches: composedLayers.flatMap(layer => layer.patches), layers: composedLayers, conflicts, skippedBundles }
+  return {
+    patches: composedLayers.flatMap(layer => layer.patches),
+    layers: composedLayers,
+    owners: ownership.owners,
+    conflicts,
+    skippedBundles,
+  }
 }
 }
 
 
 /**
 /**
@@ -185,8 +211,7 @@ export function composeProfileStack(
  * @returns the line, without a binary-name prefix.
  * @returns the line, without a binary-name prefix.
  */
  */
 export function formatRowConflict(conflict: RowConflict): string {
 export function formatRowConflict(conflict: RowConflict): string {
-  const owner = `row ${JSON.stringify(conflict.rowId)} is already declared by ${conflict.declaredBy}`
   return conflict.packageName === undefined
   return conflict.packageName === undefined
-    ? `${conflict.layer}: insert of ${conflict.moduleName} skipped — ${owner}`
-    : `bundle ${conflict.packageName} left out — ${owner}`
+    ? `${conflict.layer}: insert of ${conflict.moduleName} skipped — ${conflict.message}`
+    : `bundle ${conflict.packageName} left out — ${conflict.message}`
 }
 }

+ 30 - 52
packages/boot/app-boot/src/contained-group.ts

@@ -9,9 +9,7 @@
  */
  */
 
 
 import type { Context, Fiber, FiberState } from '@deepseek-ai/cordis'
 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'
+import { EntryUpdateError, Group, type Entry, type EntryGroup, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 
 
 /** Runtime mirror: FiberState is a cross-package const enum. */
 /** Runtime mirror: FiberState is a cross-package const enum. */
 const FIBER_PENDING = 0 as FiberState.PENDING
 const FIBER_PENDING = 0 as FiberState.PENDING
@@ -28,11 +26,8 @@ export function pendingMessage(fiber: Fiber): string {
   return `pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`
   return `pending (waiting for ${subject}: ${missing.join(', ') || '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'
+/** The lifecycle step at which a contained row failed. */
+export type ContainedFailureStage = 'import' | 'apply' | 'inject-pending' | 'unknown'
 
 
 /** One recorded failure of a row inside a contained group. */
 /** One recorded failure of a row inside a contained group. */
 export interface ContainedFailure {
 export interface ContainedFailure {
@@ -42,10 +37,8 @@ export interface ContainedFailure {
   readonly rowId: string
   readonly rowId: string
   /** The module specifier the row named. */
   /** The module specifier the row named. */
   readonly moduleName: string
   readonly moduleName: string
-  /** The contained group the row belongs to; for a conflict, the group the bundle would have mounted, or the user layer's label. */
+  /** The contained group the row belongs to, as the tree names it. */
   readonly groupId: string
   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. */
   /** Which lifecycle step failed. */
   readonly stage: ContainedFailureStage
   readonly stage: ContainedFailureStage
   /** The failure text, with the Loader's per-row wrapper folded in. */
   /** The failure text, with the Loader's per-row wrapper folded in. */
@@ -54,8 +47,10 @@ export interface ContainedFailure {
 
 
 /**
 /**
  * Failures recorded by contained groups of one runtime. Rows are keyed by
  * Failures recorded by contained groups of one runtime. Rows are keyed by
- * their tree-wide id; recording a row again replaces its earlier record, and a
- * row that later mounts clears it.
+ * their tree-wide id; recording a row again replaces its earlier record, a
+ * row that later mounts clears it, and a group that unmounts clears its rows'.
+ * Rows the composition left out never reach a group and are not recorded
+ * here; `ProfileRuntime.conflicts` holds them.
  */
  */
 export class ContainedFailureRegistry {
 export class ContainedFailureRegistry {
   private readonly failures = new Map<string, ContainedFailure>()
   private readonly failures = new Map<string, ContainedFailure>()
@@ -77,12 +72,12 @@ export class ContainedFailureRegistry {
   }
   }
 
 
   /**
   /**
-   * Forget every record of one stage, before the stage's records are remade.
-   * @param stage - the stage whose records to drop.
+   * Forget every record of one contained group, when the group unmounts.
+   * @param groupId - the group's tree-wide id.
    */
    */
-  clearStage(stage: ContainedFailureStage): void {
+  clearGroup(groupId: string): void {
     for (const [entryId, failure] of this.failures) {
     for (const [entryId, failure] of this.failures) {
-      if (failure.stage === stage) this.failures.delete(entryId)
+      if (failure.groupId === groupId) this.failures.delete(entryId)
     }
     }
   }
   }
 
 
@@ -112,22 +107,25 @@ declare module '@deepseek-ai/cordis' {
 }
 }
 
 
 /**
 /**
- * Parse the Loader's `failed to <stage> loader entry` wrapper into a stage.
- * Every row failure reaches {@link ContainedGroup.create} through that
- * wrapper, so `unknown` is the guard for a message shape this build has not
- * seen rather than a path a row can take.
+ * The stage a row failure reached, from the Loader's typed update error.
+ * A fresh row fails at import or apply; the wrapper's other stages replace
+ * an existing entry, which a contained row does not go through, and a value
+ * the Loader did not wrap has no stage.
  */
  */
-function stageOf(message: string): ContainedFailureStage {
-  const match = /^failed to (import|apply) loader entry /.exec(message)
-  /* v8 ignore next -- the Loader wraps every row failure with one of the two stages, so the `unknown` arm is unreachable */
-  return match === null ? 'unknown' : match[1] === 'import' ? 'import' : 'apply'
+function stageOf(error: unknown): ContainedFailureStage {
+  /* v8 ignore next -- the Loader wraps every row failure; the arm keeps the type total */
+  if (!(error instanceof EntryUpdateError)) return 'unknown'
+  /* v8 ignore next -- dispose and rollback replace an existing entry, which a fresh contained row does not go through */
+  return error.stage === 'import' || error.stage === 'apply' ? error.stage : 'unknown'
 }
 }
 
 
 /**
 /**
  * A group that contains its rows' startup failures. `create()` is the one
  * A group that contains its rows' startup failures. `create()` is the one
  * per-row step `EntryGroup.update` awaits, so catching there is what turns a
  * per-row step `EntryGroup.update` awaits, so catching there is what turns a
  * row failure from a group rejection into a record: the group activates, the
  * row failure from a group rejection into a record: the group activates, the
- * failed row is absent from the tree, and the record names it.
+ * failed row is absent from the tree, and the record names it. When the group
+ * unmounts — its bundle disabled or uninstalled — its rows' records go with
+ * it, so no failure outlives the composition that produced it.
  */
  */
 export class ContainedGroup extends Group {
 export class ContainedGroup extends Group {
   override async create(options: Omit<EntryOptions, 'id'>): Promise<string> {
   override async create(options: Omit<EntryOptions, 'id'>): Promise<string> {
@@ -168,7 +166,7 @@ export class ContainedGroup extends Group {
         rowId,
         rowId,
         moduleName: options.name,
         moduleName: options.name,
         groupId: this.groupId(),
         groupId: this.groupId(),
-        stage: stageOf(message),
+        stage: stageOf(error),
         message,
         message,
       })
       })
       this.ctx.logger.warn(`contained group ${this.groupId()}: row ${rowId} failed and was isolated: ${message}`)
       this.ctx.logger.warn(`contained group ${this.groupId()}: row ${rowId} failed and was isolated: ${message}`)
@@ -176,6 +174,11 @@ export class ContainedGroup extends Group {
     }
     }
   }
   }
 
 
+  override async stop(): Promise<void> {
+    await super.stop()
+    this.registry()?.clearGroup(this.groupId())
+  }
+
   /** The registry provided on the runtime root, if the boot glue provided one. */
   /** The registry provided on the runtime root, if the boot glue provided one. */
   private registry(): ContainedFailureRegistry | undefined {
   private registry(): ContainedFailureRegistry | undefined {
     return this.ctx.get('pluginFailures')
     return this.ctx.get('pluginFailures')
@@ -215,28 +218,3 @@ export function ensurePluginFailures(ctx: Context): ContainedFailureRegistry {
   ctx.root.provide('pluginFailures', registry)
   ctx.root.provide('pluginFailures', registry)
   return 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}`,
-    })
-  }
-}

+ 63 - 72
packages/boot/app-boot/src/external-bundles.ts

@@ -5,26 +5,18 @@
  * An external (`runtime` stage) bundle never mounts its rows directly into the
  * 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
  * 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
  * 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.
+ * updates transactionally, and the contained variant catches each row's
+ * failure inside that transaction: the failing row is recorded, its siblings
+ * mount, and the built-in tree never sees a rejection. 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
  * @module @deepseek-ai/dsh-app-boot/external-bundles
  */
  */
 
 
 import { join } from 'node:path'
 import { join } from 'node:path'
-import { isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-
-/**
- * Whether a `disabled` node is a `!!js` expression rather than a literal.
- * @param value - the raw `disabled` node of a row or patch.
- * @returns true for an expression node.
- */
-export function isJsDisabled(value: unknown): boolean {
-  return isJsExpr(value)
-}
+import { visitInsertedRows } from './patch-rows.ts'
 import {
 import {
   readProfileManifest, resolveBundleDir, writeProfileManifest, type ProfileLayer, type ProfileManifest,
   readProfileManifest, resolveBundleDir, writeProfileManifest, type ProfileLayer, type ProfileManifest,
 } from './profile.ts'
 } from './profile.ts'
@@ -46,26 +38,32 @@ export function bundleGroupId(packageName: string): string {
   return `${BUNDLE_GROUP_PREFIX}${packageName}`
   return `${BUNDLE_GROUP_PREFIX}${packageName}`
 }
 }
 
 
+/** One row id an external bundle's own patch inserts more than once. */
+export interface DuplicateRow {
+  /** The repeated id. */
+  readonly rowId: string
+  /** The module the repeat names. */
+  readonly moduleName: string
+}
+
 /** One external layer rendered as the patches the tree mounts. */
 /** One external layer rendered as the patches the tree mounts. */
 export interface ComposedExternalLayer {
 export interface ComposedExternalLayer {
-  /** Patches in application order: the group insert first, then the bundle's own patches. */
+  /**
+   * Patches in application order: the empty group insert, then the bundle's
+   * patches as written, each insert re-targeted into the group or a wrapper.
+   */
   patches: PatchOptions[]
   patches: PatchOptions[]
-  /** Every id the layer introduces — its rows, its group, and any nested group — with the module each names. */
+  /**
+   * Every id the layer introduces — its rows, its group, and each wrapper
+   * group — with the module each names; a repeated id keeps its first module.
+   */
   rows: Map<string, string>
   rows: Map<string, string>
+  /** Ids the bundle's own inserts declare more than once, in order of repetition. */
+  duplicates: DuplicateRow[]
   /** Ids outside the bundle that its patch overrides; not containable, reported for visibility. */
   /** Ids outside the bundle that its patch overrides; not containable, reported for visibility. */
   overrides: string[]
   overrides: string[]
 }
 }
 
 
-/** 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') rows.set(cloned.id, cloned.name)
-  if (cloned.group && Array.isArray(cloned.config)) {
-    cloned.config = (cloned.config as EntryOptions[]).map(child => indexRow(child, rows))
-  }
-  return cloned
-}
-
 /**
 /**
  * Whether a layer mounts isolated: an external bundle the profile does not
  * Whether a layer mounts isolated: an external bundle the profile does not
  * stage at boot.
  * stage at boot.
@@ -77,62 +75,55 @@ export function isContainedLayer(layer: ProfileLayer): boolean {
 }
 }
 
 
 /**
 /**
- * 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.
+ * Render one external bundle layer as contained patches in the order written.
+ * The bundle's group is inserted empty first; each root insert becomes an
+ * insert into that group, an insert into a row the bundle itself inserts
+ * passes through, and every insert into one built-in group lands in one
+ * wrapper group nested inside that target — the first insert creates it,
+ * later ones insert into it. An id-targeted patch passes through unchanged
+ * and is reported as an override when it addresses a row the bundle did not
+ * insert. Ids are indexed before any patch is emitted, so an insert into a
+ * group the bundle inserts later in its list still counts as its own.
  * @param layer - the resolved external layer.
  * @param layer - the resolved external layer.
- * @returns the patches to mount and the ids the layer introduces.
+ * @returns the patches to mount, the ids the layer introduces, and the ids it repeats.
  */
  */
 export function composeExternalLayer(layer: ProfileLayer): ComposedExternalLayer {
 export function composeExternalLayer(layer: ProfileLayer): ComposedExternalLayer {
-  const { packageName } = layer
+  const groupId = bundleGroupId(layer.packageName)
   const rows = new Map<string, string>()
   const rows = new Map<string, string>()
-  const groupRows: EntryOptions[] = []
-  const trailing: PatchOptions[] = []
+  const duplicates: DuplicateRow[] = []
+  visitInsertedRows(layer.patches, (row) => {
+    if (typeof row.id !== 'string') return
+    if (rows.has(row.id)) duplicates.push({ rowId: row.id, moduleName: row.name })
+    else rows.set(row.id, row.name)
+  })
+  if (rows.has(groupId)) duplicates.push({ rowId: groupId, moduleName: CONTAINED_GROUP_MODULE })
+  rows.set(groupId, CONTAINED_GROUP_MODULE)
+  const wrappers = new Map<string, string>()
   const overrides: string[] = []
   const overrides: string[] = []
-  // First pass: inserts, so the ids this bundle introduces are known before
-  // its id-targeted patches are classified.
+  const patches: PatchOptions[] = [{ insert: [{ id: groupId, name: CONTAINED_GROUP_MODULE, group: true, config: [] }] }]
   for (const patch of layer.patches) {
   for (const patch of layer.patches) {
-    if (patch.insert === undefined) continue
-    const inserted = patch.insert.map(row => indexRow(row, rows))
-    if (patch.id === undefined) {
-      groupRows.push(...inserted)
+    if (patch.insert === undefined) {
+      if (patch.id !== undefined && !rows.has(patch.id)) overrides.push(patch.id)
+      patches.push(structuredClone(patch))
       continue
       continue
     }
     }
-    if (rows.has(patch.id)) {
-      trailing.push({ id: patch.id, insert: inserted })
+    const inserted = structuredClone(patch.insert)
+    const target = patch.id ?? groupId
+    if (rows.has(target)) {
+      patches.push({ id: target, insert: inserted })
       continue
       continue
     }
     }
-    const nestedId = `${bundleGroupId(packageName)}/in/${patch.id}`
-    rows.set(nestedId, CONTAINED_GROUP_MODULE)
-    trailing.push({
-      id: patch.id,
-      insert: [{ id: nestedId, name: CONTAINED_GROUP_MODULE, group: true, config: inserted }],
-    })
-  }
-  for (const patch of layer.patches) {
-    if (patch.insert !== undefined || patch.id === undefined) continue
-    if (!rows.has(patch.id)) overrides.push(patch.id)
-    trailing.push(structuredClone(patch))
+    const wrapper = wrappers.get(target)
+    if (wrapper !== undefined) {
+      patches.push({ id: wrapper, insert: inserted })
+      continue
+    }
+    const wrapperId = `${groupId}/in/${target}`
+    wrappers.set(target, wrapperId)
+    rows.set(wrapperId, CONTAINED_GROUP_MODULE)
+    patches.push({ id: target, insert: [{ id: wrapperId, name: CONTAINED_GROUP_MODULE, group: true, config: inserted }] })
   }
   }
-  const groupId = bundleGroupId(packageName)
-  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 }
-}
-
-/**
- * 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 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 (isContainedLayer(layer)) return composeExternalLayer(layer).patches
-  return layer.patches
+  return { patches, rows, duplicates, overrides }
 }
 }
 
 
 /**
 /**

+ 23 - 27
packages/boot/app-boot/src/index.ts

@@ -20,8 +20,7 @@ import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
 import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
 import type {} from '@deepseek-ai/cordis-plugin-hmr'
 import type {} from '@deepseek-ai/cordis-plugin-hmr'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 import type {} from '@deepseek-ai/dsh-system-prompt'
-import {
-  pendingMessage, ContainedGroup, ensurePluginFailures, isContainedEntry } from './contained-group.ts'
+import { ContainedGroup, ensurePluginFailures, isContainedEntry, pendingMessage } from './contained-group.ts'
 
 
 declare module '@deepseek-ai/cordis' {
 declare module '@deepseek-ai/cordis' {
   interface Context {
   interface Context {
@@ -46,26 +45,21 @@ export {
   resolveProfileDir,
   resolveProfileDir,
   resolveProfileLayer,
   resolveProfileLayer,
   writeProfileManifest,
   writeProfileManifest,
-  type BundleStage,
   type BundleTrust,
   type BundleTrust,
-  type DshBundleManifest,
-  type DshManifestSection,
-  type DshProfileManifest,
   type Profile,
   type Profile,
   type ProfileLayer,
   type ProfileLayer,
   type ProfileManifest,
   type ProfileManifest,
   type ProfileModuleFallbackOptions,
   type ProfileModuleFallbackOptions,
-  type ProfilePatchReload,
   type ProfileTemplate,
   type ProfileTemplate,
 } from './profile.ts'
 } from './profile.ts'
 export {
 export {
-  ContainedFailureRegistry, ContainedGroup, ensurePluginFailures, isContainedEntry, recordRowConflicts,
+  ContainedFailureRegistry, ContainedGroup, ensurePluginFailures, isContainedEntry,
   type ContainedFailure, type ContainedFailureStage,
   type ContainedFailure, type ContainedFailureStage,
 } from './contained-group.ts'
 } from './contained-group.ts'
 export {
 export {
-  BUNDLE_GROUP_PREFIX, bundleGroupId, bundleLayerPatches, composeExternalLayer, CONTAINED_GROUP_MODULE, disableBundle,
-  enableBundle, exportsBundlePatch, isContainedLayer, isJsDisabled, reconcileInstalledBundles,
-  type BundleReconciliation, type ComposedExternalLayer,
+  BUNDLE_GROUP_PREFIX, bundleGroupId, composeExternalLayer, CONTAINED_GROUP_MODULE, disableBundle,
+  enableBundle, exportsBundlePatch, isContainedLayer, reconcileInstalledBundles,
+  type BundleReconciliation, type ComposedExternalLayer, type DuplicateRow,
 } from './external-bundles.ts'
 } from './external-bundles.ts'
 export {
 export {
   claimLayerIds, composeProfileStack, formatRowConflict,
   claimLayerIds, composeProfileStack, formatRowConflict,
@@ -255,19 +249,19 @@ export interface UserPatchWatchOptions {
   /** Absolute path of the watched patch file (a profile's `cordis.patch.yml`). */
   /** Absolute path of the watched patch file (a profile's `cordis.patch.yml`). */
   filename: string
   filename: string
   /**
   /**
-   * Compose the full patch list for a fresh user-layer generation —
-   * the same composition the app booted with, so a reload can interleave the
-   * new user patches between app-owned layers (bundle layers below,
-   * overlays above). Identity when omitted: the user layer
-   * is the whole patch list.
+   * Re-apply the composition after the watched file changed. When omitted,
+   * the file's patches are re-read and mounted as the whole patch list. A
+   * launcher with a profile runtime passes its `recompose`, so the user layer
+   * is interleaved between the app-owned layers and every recomposition,
+   * watched or requested, goes through that one entry point.
    */
    */
-  compose?: (userPatches: PatchOptions[]) => PatchOptions[]
+  reapply?: () => Promise<void>
 }
 }
 
 
 /**
 /**
  * Watch the user patch layer through Cordis HMR and transactionally reapply it to the boot include.
  * Watch the user patch layer through Cordis HMR and transactionally reapply it to the boot include.
  * @param ctx - settled app context containing the root Include and an active HMR service.
  * @param ctx - settled app context containing the root Include and an active HMR service.
- * @param options - diagnostic, file, and patch-composition inputs.
+ * @param options - diagnostic, file, and re-application inputs.
  * @returns an asynchronous disposer after the exact-path watcher is ready.
  * @returns an asynchronous disposer after the exact-path watcher is ready.
  * @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails.
  * @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails.
  */
  */
@@ -275,24 +269,23 @@ export async function watchUserPatches(
   ctx: Context,
   ctx: Context,
   options: UserPatchWatchOptions,
   options: UserPatchWatchOptions,
 ): Promise<() => Promise<void>> {
 ): Promise<() => Promise<void>> {
-  const { binName, filename, compose = (patches: PatchOptions[]) => patches } = options
+  const { binName, filename } = options
   const hmr = ctx.get('hmr')
   const hmr = ctx.get('hmr')
   if (hmr === undefined) throw new Error(`${binName}: user patch-layer watching requires the Cordis HMR service`)
   if (hmr === undefined) throw new Error(`${binName}: user patch-layer watching requires the Cordis HMR service`)
   const entry = bootstrapIncludes.get(ctx)
   const entry = bootstrapIncludes.get(ctx)
   if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`)
   if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`)
-  const register = hmr.registerConfig(filename, async () => {
+  const reapply = options.reapply ?? (async (): Promise<void> => {
     // Re-read the include's non-patch options per refresh so a writer that
     // Re-read the include's non-patch options per refresh so a writer that
     // updates another option between refreshes is not silently reverted.
     // updates another option between refreshes is not silently reverted.
     const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
     const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
-    const userPatches = loadOptionalPatches(binName, filename) ?? []
-    const patches = compose(userPatches)
     await entry.update({
     await entry.update({
       config: {
       config: {
         ...includeConfig,
         ...includeConfig,
-        patches,
+        patches: loadOptionalPatches(binName, filename) ?? [],
       },
       },
     })
     })
   })
   })
+  const register = hmr.registerConfig(filename, reapply)
   try {
   try {
     return await register
     return await register
   } catch (error) {
   } catch (error) {
@@ -681,9 +674,12 @@ export interface RuntimeGuardProcess {
  * The post-boot replacement for {@link installFailLoud}. Once the tree is up,
  * The post-boot replacement for {@link installFailLoud}. Once the tree is up,
  * an unhandled rejection is no longer a load failure: it is most likely a
  * an unhandled rejection is no longer a load failure: it is most likely a
  * plugin's stray continuation, and exiting would take every session down for
  * plugin's stray continuation, and exiting would take every session down for
- * it. The rejection is reported and the process keeps running. An uncaught
- * exception leaves the process in an unknown state, so it is reported and
- * the process exits, as Node would — but with the origin named.
+ * it. The rejection is reported and the process keeps running; nothing is
+ * stopped or attributed to a plugin, built-in or external, so this is a
+ * process-level policy chosen over exiting, not an isolation of the plugin
+ * that produced it. An uncaught exception leaves the process in an unknown
+ * state, so it is reported and the process exits, as Node would — but with
+ * the origin named.
  * @param binName - the diagnostic prefix on each report.
  * @param binName - the diagnostic prefix on each report.
  * @param report - sink for the report lines.
  * @param report - sink for the report lines.
  * @param proc - the process slice to register on; defaults to `process`.
  * @param proc - the process slice to register on; defaults to `process`.
@@ -695,7 +691,7 @@ export function installRuntimeGuards(
   proc: RuntimeGuardProcess = process,
   proc: RuntimeGuardProcess = process,
 ): () => void {
 ): () => void {
   const onRejection = (err: unknown): void => {
   const onRejection = (err: unknown): void => {
-    report(`${binName}: unhandled rejection after boot (contained; the process keeps running): ${formatActivationError(err)}`)
+    report(`${binName}: unhandled rejection after boot (not attributed to a plugin; the process keeps running): ${formatActivationError(err)}`)
   }
   }
   const onException = (err: unknown): void => {
   const onException = (err: unknown): void => {
     report(`${binName}: uncaught exception after boot; exiting: ${formatActivationError(err)}`)
     report(`${binName}: uncaught exception after boot; exiting: ${formatActivationError(err)}`)

+ 34 - 0
packages/boot/app-boot/src/patch-rows.ts

@@ -0,0 +1,34 @@
+/**
+ * The one walk over the rows a patch list inserts. Composition, ownership,
+ * the package probe, and patch loading all read inserted rows and their
+ * group children; sharing the walk keeps them reading the same tree.
+ * @module @deepseek-ai/dsh-app-boot/patch-rows
+ */
+
+import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
+
+/**
+ * Visit one inserted row and, for a group with a list config, each of its
+ * children, parents before children, in written order.
+ * @param row - the inserted row.
+ * @param visit - called once per row.
+ */
+export function visitRowTree(row: EntryOptions, visit: (row: EntryOptions) => void): void {
+  visit(row)
+  if (row.group && Array.isArray(row.config)) {
+    for (const child of row.config as EntryOptions[]) visitRowTree(child, visit)
+  }
+}
+
+/**
+ * Visit every row a patch list inserts, at any depth, in written order.
+ * Id-targeted patches insert nothing and are skipped.
+ * @param patches - the patch list.
+ * @param visit - called once per inserted row.
+ */
+export function visitInsertedRows(patches: readonly PatchOptions[], visit: (row: EntryOptions) => void): void {
+  for (const patch of patches) {
+    for (const row of patch.insert ?? []) visitRowTree(row, visit)
+  }
+}

+ 42 - 0
packages/boot/app-boot/src/probe-child.ts

@@ -0,0 +1,42 @@
+/**
+ * The probe's child process: resolve cordis from the probed package, import
+ * its main export and every declared addable module, and send one report
+ * over the IPC channel the parent opened. stdout and stderr stay the
+ * imported modules' own, so a package that prints at import still reports.
+ * Arguments: the package directory, the main specifier (empty for none), and
+ * the addable specifiers as a JSON array. Nothing here runs inside the host.
+ * @module @deepseek-ai/dsh-app-boot/probe-child
+ */
+
+import { pathToFileURL } from 'node:url'
+import type { ChildInspection, ChildReport } from './probe-report.ts'
+
+const [dir = '', mainSpecifier = '', addableJson = '[]'] = process.argv.slice(2)
+const base = pathToFileURL(`${dir}/package.json`).href
+
+/** Import one module from the package and describe what it exports. */
+async function inspect(specifier: string): Promise<ChildInspection> {
+  try {
+    const mod = await import(import.meta.resolve(specifier, base)) as Record<string, unknown>
+    const plugin = mod.default ?? mod
+    const schema = (plugin as { Config?: unknown }).Config ?? mod.Config
+    const toJSON = (schema as { toJSON?: unknown } | null | undefined)?.toJSON
+    return {
+      ok: true,
+      isPlugin: typeof plugin === 'function' || typeof (plugin as { apply?: unknown }).apply === 'function',
+      configSchema: typeof toJSON === 'function' ? (toJSON as () => unknown).call(schema) : null,
+    }
+  } catch (error) {
+    return { ok: false, isPlugin: false, configSchema: null, error: String((error as { stack?: unknown } | null)?.stack ?? error) }
+  }
+}
+
+const report: ChildReport = { cordis: null, main: { ok: false, isPlugin: false, configSchema: null }, addable: {} }
+try {
+  report.cordis = import.meta.resolve('@deepseek-ai/cordis', base)
+} catch {
+  report.cordis = null // the package resolves no cordis at all: a library, or a plugin without the peer installed
+}
+if (mainSpecifier !== '') report.main = await inspect(mainSpecifier)
+for (const name of JSON.parse(addableJson) as string[]) report.addable[name] = await inspect(name)
+process.send?.(report, undefined, undefined, () => { process.disconnect() })

+ 60 - 0
packages/boot/app-boot/src/probe-report.ts

@@ -0,0 +1,60 @@
+/**
+ * What the probe's child process reports, and the check the parent runs on
+ * it. The report crosses a process boundary as an IPC message, so the parent
+ * validates every field before trusting it; the child only imports the
+ * types.
+ * @module @deepseek-ai/dsh-app-boot/probe-report
+ */
+
+/** What importing one module in the child found. */
+export interface ChildInspection {
+  /** Whether the import succeeded. */
+  ok: boolean
+  /** Whether the module's default or namespace export is a cordis plugin. */
+  isPlugin: boolean
+  /** The plugin's `Config.toJSON()`, or null when it declares none. */
+  configSchema: unknown
+  /** The import failure, when `ok` is false. */
+  error?: string
+}
+
+/** The child's one message: where cordis resolves from the package, and what each module imported as. */
+export interface ChildReport {
+  /** The URL the package resolves `@deepseek-ai/cordis` to, or null when it does not resolve it. */
+  cordis: string | null
+  /** The main export's inspection; the not-imported default when the package declares no main. */
+  main: ChildInspection
+  /** The inspection of each declared addable module, by its specifier. */
+  addable: Record<string, ChildInspection>
+}
+
+/**
+ * Whether a value is a plain object: the only JSON value with named fields.
+ * @param value - the value to test.
+ * @returns true for a non-null, non-array object.
+ */
+export function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/** Whether a value has the fields of one inspection. */
+function isInspection(value: unknown): value is ChildInspection {
+  return isRecord(value)
+    && typeof value.ok === 'boolean'
+    && typeof value.isPlugin === 'boolean'
+    && 'configSchema' in value
+    && (value.error === undefined || typeof value.error === 'string')
+}
+
+/**
+ * Validate a message the child sent as its report.
+ * @param value - the message as received.
+ * @returns the report, or undefined when the message is not one.
+ */
+export function parseChildReport(value: unknown): ChildReport | undefined {
+  if (!isRecord(value)) return undefined
+  if (value.cordis !== null && typeof value.cordis !== 'string') return undefined
+  if (!isInspection(value.main) || !isRecord(value.addable)) return undefined
+  if (!Object.values(value.addable).every(isInspection)) return undefined
+  return value as unknown as ChildReport
+}

+ 113 - 74
packages/boot/app-boot/src/probe.ts

@@ -2,19 +2,21 @@
  * The install-time probe: what an installed package is and whether this
  * The install-time probe: what an installed package is and whether this
  * harness can load it, answered in a child process so a package that throws,
  * harness can load it, answered in a child process so a package that throws,
  * hangs, or brings its own copy of cordis never runs inside the host. The
  * hangs, or brings its own copy of cordis never runs inside the host. The
- * manifest facts (kind, rows, declared modules) are read here; the child only
- * imports.
+ * manifest facts (kind, rows, declared modules) are read here; the child
+ * (`probe-child.ts`) only imports and reports over IPC, and the report and
+ * the cached record are validated as the process and file boundaries they
+ * cross.
  * @module @deepseek-ai/dsh-app-boot/probe
  * @module @deepseek-ai/dsh-app-boot/probe
  */
  */
 
 
 import { spawn } from 'node:child_process'
 import { spawn } from 'node:child_process'
-import { awaitChildClose } from './child-close.ts'
 import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
 import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
 import { dirname, join } from 'node:path'
 import { dirname, join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { fileURLToPath } from 'node:url'
-import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import { loadOverlayPatches } from './index.ts'
 import { loadOverlayPatches } from './index.ts'
 import { readProfileManifest, resolveBundleDir, type ProfileManifest } from './profile.ts'
 import { readProfileManifest, resolveBundleDir, type ProfileManifest } from './profile.ts'
+import { visitInsertedRows } from './patch-rows.ts'
+import { isRecord, parseChildReport, type ChildReport } from './probe-report.ts'
 
 
 /** Directory under a profile holding one probe record per package. */
 /** Directory under a profile holding one probe record per package. */
 export const PLUGIN_PROBE_DIR = '.dsh-plugins'
 export const PLUGIN_PROBE_DIR = '.dsh-plugins'
@@ -69,9 +71,14 @@ export interface PluginProbe {
    * not a plugin: `lodash` exports one too.
    * not a plugin: `lodash` exports one too.
    */
    */
   readonly kind: 'bundle' | 'plugin' | 'library'
   readonly kind: 'bundle' | 'plugin' | 'library'
-  /** Whether the package can be enabled or added: it imported and shares the harness's cordis. */
+  /**
+   * Whether the main export imported and cordis is not a second copy. True
+   * for a `library`, for a package whose cordis resolution is unknown, and
+   * for one whose addable modules failed: `kind` and `addable[].ok` decide
+   * what can be enabled or added.
+   */
   readonly ok: boolean
   readonly ok: boolean
-  /** Why it cannot, when `ok` is false. */
+  /** Why the import or the cordis check failed, when `ok` is false. */
   readonly reason?: string
   readonly reason?: string
   /** Whether the package resolves `@deepseek-ai/cordis` to the harness's own copy; null when unknown. */
   /** Whether the package resolves `@deepseek-ai/cordis` to the harness's own copy; null when unknown. */
   readonly cordisSameCopy: boolean | null
   readonly cordisSameCopy: boolean | null
@@ -110,72 +117,69 @@ interface ProbedManifest extends ProfileManifest {
   main?: string
   main?: string
   exports?: unknown
   exports?: unknown
   engines?: Record<string, string>
   engines?: Record<string, string>
-  dsh?: ProfileManifest['dsh'] & {
-    title?: string
-    plugins?: { name: string; title?: string; config?: unknown }[]
-  }
-}
-
-/** What the child process reports. */
-interface ChildReport {
-  cordis: string | null
-  main: { ok: boolean; isPlugin: boolean; configSchema: unknown; error?: string }
-  addable: Record<string, { ok: boolean; isPlugin: boolean; configSchema: unknown; error?: string }>
 }
 }
 
 
 /**
 /**
- * The script the child runs: resolve cordis from the package, import the main
- * export and every declared addable module, and report. Parameters arrive
- * as argv so no value is interpolated into code.
+ * The child entry beside this module: the TypeScript source under a source
+ * launch, run through tsx; the bundled `lib/probe-child.js` otherwise.
  */
  */
-const CHILD_SCRIPT = `
-import { pathToFileURL } from 'node:url'
-const [dir, mainSpecifier, addableJson] = process.argv.slice(1)
-const base = pathToFileURL(dir + '/package.json').href
-const report = { cordis: null, main: { ok: false, isPlugin: false, configSchema: null }, addable: {} }
-try { report.cordis = import.meta.resolve('@deepseek-ai/cordis', base) } catch { report.cordis = null }
-const inspect = async (specifier) => {
-  try {
-    const mod = await import(import.meta.resolve(specifier, base))
-    const plugin = mod.default ?? mod
-    const schema = plugin?.Config ?? mod.Config
-    return {
-      ok: true,
-      isPlugin: typeof plugin === 'function' || typeof plugin?.apply === 'function',
-      configSchema: typeof schema?.toJSON === 'function' ? schema.toJSON() : null,
-    }
-  } catch (error) {
-    return { ok: false, isPlugin: false, configSchema: null, error: String(error?.stack ?? error) }
+function childEntryArgs(): string[] {
+  /* v8 ignore next 3 -- the built-output arm: tests run from src */
+  if (!import.meta.url.endsWith('.ts')) {
+    return [fileURLToPath(new URL('./probe-child.js', import.meta.url))]
   }
   }
+  return ['--import', import.meta.resolve('tsx/esm'), fileURLToPath(new URL('./probe-child.ts', import.meta.url))]
 }
 }
-if (mainSpecifier !== '') report.main = await inspect(mainSpecifier)
-for (const name of JSON.parse(addableJson)) report.addable[name] = await inspect(name)
-process.stdout.write(JSON.stringify(report))
-`
 
 
-/** Run the child and parse its report. */
-async function runChild(options: ProbeOptions, packageDir: string, mainSpecifier: string, addable: string[]): Promise<ChildReport> {
+/**
+ * Run the child and take its report from the IPC channel. The report is all
+ * the probe needs, so the child is killed once it arrived: a package that
+ * keeps a timer alive after import costs nothing more. stdout is not read
+ * at all, so whatever the imported modules print cannot corrupt the report.
+ */
+function runChild(options: ProbeOptions, packageDir: string, mainSpecifier: string, addable: string[]): Promise<ChildReport> {
   const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
   const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
-  const child = spawn(
-    options.nodeExecutable ?? process.execPath,
-    ['--experimental-import-meta-resolve', '--input-type=module', '-e', CHILD_SCRIPT, '--', packageDir, mainSpecifier, JSON.stringify(addable)],
-    { cwd: options.profileDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NODE_NO_WARNINGS: '1' } },
-  )
-  const out: Buffer[] = []
-  const err: Buffer[] = []
-  child.stdout.on('data', (chunk: Buffer) => out.push(chunk))
-  child.stderr.on('data', (chunk: Buffer) => err.push(chunk))
-  const code = await awaitChildClose(
-    child, timeoutMs, () => new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`),
-  )
-  const stdout = Buffer.concat(out).toString('utf8')
-  try {
-    return JSON.parse(stdout) as ChildReport
-  } catch {
-    throw new Error(
-      `${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${Buffer.concat(err).toString('utf8').trim()}`,
+  return new Promise((resolve, reject) => {
+    const child = spawn(
+      options.nodeExecutable ?? process.execPath,
+      ['--experimental-import-meta-resolve', ...childEntryArgs(), packageDir, mainSpecifier, JSON.stringify(addable)],
+      { cwd: options.profileDir, stdio: ['ignore', 'ignore', 'pipe', 'ipc'], env: { ...process.env, NODE_NO_WARNINGS: '1' } },
     )
     )
-  }
+    const err: Buffer[] = []
+    child.stderr?.on('data', (chunk: Buffer) => err.push(chunk))
+    // One settlement: a spawn failure emits `error` and then `close`, a
+    // timeout kill emits `close` after the rejection below, and the kill
+    // after a report emits `close` after the resolution.
+    let settled = false
+    let unrecognized = false
+    const settle = (outcome: () => void): void => {
+      if (settled) return
+      settled = true
+      clearTimeout(timer)
+      outcome()
+    }
+    const timer = setTimeout(() => {
+      child.kill('SIGKILL')
+      settle(() => { reject(new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`)) })
+    }, timeoutMs)
+    child.on('message', (message) => {
+      const report = parseChildReport(message)
+      if (report === undefined) {
+        unrecognized = true
+        return
+      }
+      child.kill('SIGKILL')
+      settle(() => { resolve(report) })
+    })
+    child.on('error', (error) => { settle(() => { reject(error) }) })
+    child.on('close', (code) => {
+      settle(() => {
+        reject(new Error(unrecognized
+          ? `${options.binName}: probe of ${options.packageName} reported an unrecognized value`
+          : `${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${Buffer.concat(err).toString('utf8').trim()}`))
+      })
+    })
+  })
 }
 }
 
 
 const DEFAULT_TIMEOUT_MS = 20_000
 const DEFAULT_TIMEOUT_MS = 20_000
@@ -184,13 +188,11 @@ const DEFAULT_TIMEOUT_MS = 20_000
 function describeBundlePatch(binName: string, patchPath: string): { rows: PluginProbeRow[]; overrides: string[] } {
 function describeBundlePatch(binName: string, patchPath: string): { rows: PluginProbeRow[]; overrides: string[] } {
   const rows: PluginProbeRow[] = []
   const rows: PluginProbeRow[] = []
   const own = new Set<string>()
   const own = new Set<string>()
-  const visit = (row: EntryOptions): void => {
+  const patches = loadOverlayPatches(binName, patchPath)
+  visitInsertedRows(patches, (row) => {
     if (typeof row.id === 'string') own.add(row.id)
     if (typeof row.id === 'string') own.add(row.id)
     rows.push({ ...typeof row.id === 'string' ? { id: row.id } : {}, name: row.name, gated: row.disabled !== undefined })
     rows.push({ ...typeof row.id === 'string' ? { id: row.id } : {}, name: row.name, gated: row.disabled !== undefined })
-    if (row.group && Array.isArray(row.config)) (row.config as EntryOptions[]).forEach(visit)
-  }
-  const patches = loadOverlayPatches(binName, patchPath)
-  for (const patch of patches) patch.insert?.forEach(visit)
+  })
   const overrides = patches
   const overrides = patches
     .filter(patch => patch.insert === undefined && typeof patch.id === 'string' && !own.has(patch.id))
     .filter(patch => patch.insert === undefined && typeof patch.id === 'string' && !own.has(patch.id))
     .map(patch => patch.id as string)
     .map(patch => patch.id as string)
@@ -332,23 +334,60 @@ function probeCachePath(profileDir: string, packageName: string): string {
  * @param profileDir - the profile directory.
  * @param profileDir - the profile directory.
  * @param packageName - the package.
  * @param packageName - the package.
  * @param version - when given, a record for a different version is treated as absent.
  * @param version - when given, a record for a different version is treated as absent.
- * @returns the record, or undefined when none is cached or the cached one was written by another probe format.
+ * @returns the record, or undefined when none is cached, the cached one was written by another probe format, or it is not a record.
  */
  */
 export function readProbeCache(profileDir: string, packageName: string, version?: string): PluginProbe | undefined {
 export function readProbeCache(profileDir: string, packageName: string, version?: string): PluginProbe | undefined {
   const path = probeCachePath(profileDir, packageName)
   const path = probeCachePath(profileDir, packageName)
   if (!existsSync(path)) return undefined
   if (!existsSync(path)) return undefined
-  let stored: PluginProbe & { format?: number }
+  let stored: unknown
   try {
   try {
-    stored = JSON.parse(readFileSync(path, 'utf8')) as PluginProbe & { format?: number }
+    stored = JSON.parse(readFileSync(path, 'utf8'))
   } catch {
   } catch {
-    return undefined
+    return undefined // not JSON: a truncated or hand-edited file is probed again
   }
   }
-  const { format, ...record } = stored
-  if (format !== PLUGIN_PROBE_FORMAT) return undefined
+  if (!isRecord(stored) || stored.format !== PLUGIN_PROBE_FORMAT) return undefined
+  const { format: _format, ...fields } = stored
+  const record = parseProbeRecord(fields)
+  if (record === undefined) return undefined
   if (version !== undefined && record.version !== version) return undefined
   if (version !== undefined && record.version !== version) return undefined
   return record
   return record
 }
 }
 
 
+const PROBE_KINDS: ReadonlySet<string> = new Set<PluginProbe['kind']>(['bundle', 'plugin', 'library'])
+
+/** Whether a value is absent or a string. */
+function optionalString(value: unknown): boolean {
+  return value === undefined || typeof value === 'string'
+}
+
+/** Whether a value has the fields of one probed row. */
+function isProbeRow(value: unknown): value is PluginProbeRow {
+  return isRecord(value) && optionalString(value.id) && typeof value.name === 'string' && typeof value.gated === 'boolean'
+}
+
+/** Whether a value has the fields of one addable module. */
+function isProbeAddable(value: unknown): value is PluginProbeAddable {
+  return isRecord(value) && typeof value.name === 'string' && optionalString(value.title) && typeof value.ok === 'boolean' && optionalString(value.error)
+}
+
+/**
+ * Validate a stored probe record, as the cache file is a boundary this
+ * process does not control.
+ * @param value - the parsed file without its `format` field.
+ * @returns the record, or undefined when a field is missing or mistyped.
+ */
+export function parseProbeRecord(value: unknown): PluginProbe | undefined {
+  if (!isRecord(value)) return undefined
+  if (typeof value.packageName !== 'string' || typeof value.checkedAt !== 'string') return undefined
+  if (![value.version, value.description, value.title, value.reason, value.enginesDsh].every(optionalString)) return undefined
+  if (typeof value.kind !== 'string' || !PROBE_KINDS.has(value.kind) || typeof value.ok !== 'boolean') return undefined
+  if (value.cordisSameCopy !== null && typeof value.cordisSameCopy !== 'boolean') return undefined
+  if (!Array.isArray(value.rows) || !value.rows.every(isProbeRow)) return undefined
+  if (!Array.isArray(value.overrides) || !value.overrides.every(item => typeof item === 'string')) return undefined
+  if (!Array.isArray(value.addable) || !value.addable.every(isProbeAddable)) return undefined
+  return value as unknown as PluginProbe
+}
+
 /**
 /**
  * Persist one package's probe record under the profile.
  * Persist one package's probe record under the profile.
  * @param profileDir - the profile directory.
  * @param profileDir - the profile directory.

+ 50 - 42
packages/boot/app-boot/src/profile-runtime.ts

@@ -1,9 +1,13 @@
 /**
 /**
  * The `profileRuntime` service: the booted profile's facts and the one
  * The `profileRuntime` service: the booted profile's facts and the one
  * recomposition entry point every live change to the host tree goes through —
  * recomposition entry point every live change to the host tree goes through —
- * user patch-file reloads, bundle enable/disable, and hot install. Before
- * this service the composition closure lived in the launcher and bundle
- * layers were frozen at boot, so nothing in the tree could learn which
+ * user patch-file reloads, bundle enable/disable, and hot install. A
+ * recomposition composes a candidate stack, applies it through the root
+ * include, and publishes the profile, the stack's id ownership, and its
+ * conflicts only once the include accepted it; a rejected update leaves the
+ * committed composition in place, which describes the tree still running.
+ * Before this service the composition closure lived in the launcher and
+ * bundle layers were frozen at boot, so nothing in the tree could learn which
  * profile it ran in or add a layer while running.
  * profile it ran in or add a layer while running.
  * @module @deepseek-ai/dsh-app-boot/profile-runtime
  * @module @deepseek-ai/dsh-app-boot/profile-runtime
  */
  */
@@ -12,10 +16,9 @@ import { Context, Service } from '@deepseek-ai/cordis'
 import type { Entry } 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 Include from '@deepseek-ai/cordis-plugin-include'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-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'
+import type { ProfilePatchReload } from '@deepseek-ai/dsh-package-manifest'
+import type { ComposedStack, RowConflict } from './compose-stack.ts'
+import type { BundleTrust, Profile, ProfileLayer } from './profile.ts'
 
 
 declare module '@deepseek-ai/cordis' {
 declare module '@deepseek-ai/cordis' {
   interface Context {
   interface Context {
@@ -40,6 +43,8 @@ export interface ProfileRuntimeOptions {
   profile: Profile
   profile: Profile
   /** Absolute path of the dsh app's package.json: the first resolution anchor for profile packages. */
   /** Absolute path of the dsh app's package.json: the first resolution anchor for profile packages. */
   installAnchor: string
   installAnchor: string
+  /** The stack the tree booted with, as `compose` rendered it for `profile`. */
+  stack: ComposedStack
   /** Re-read the profile from disk, re-resolving its bundle layers. */
   /** Re-read the profile from disk, re-resolving its bundle layers. */
   loadProfile: () => Profile
   loadProfile: () => Profile
   /** The complete patch stack for a profile — bundle layers, user layers, overlays — with the rows it left out. */
   /** The complete patch stack for a profile — bundle layers, user layers, overlays — with the rows it left out. */
@@ -50,24 +55,29 @@ export interface ProfileRuntimeOptions {
   readUserPatches: () => PatchOptions[]
   readUserPatches: () => PatchOptions[]
 }
 }
 
 
+/** The profile and stack the tree runs, published together once the root include accepted the stack. */
+interface CommittedComposition {
+  readonly profile: Profile
+  readonly stack: ComposedStack
+}
+
 /** Facts and recomposition of the booted profile. */
 /** Facts and recomposition of the booted profile. */
 export class ProfileRuntime extends Service {
 export class ProfileRuntime extends Service {
-  private profile: Profile
-  private origins: Map<string, RowOrigin> | undefined
+  private committed: CommittedComposition
 
 
   constructor(ctx: Context, private readonly options: ProfileRuntimeOptions) {
   constructor(ctx: Context, private readonly options: ProfileRuntimeOptions) {
     super(ctx, 'profileRuntime')
     super(ctx, 'profileRuntime')
-    this.profile = options.profile
+    this.committed = { profile: options.profile, stack: options.stack }
   }
   }
 
 
-  /** The profile as currently composed; re-read by a `recompose({ reloadBundles: true })`. */
+  /** The profile as last composed; re-read by a `recompose({ reloadBundles: true })` the include accepted. */
   get current(): Profile {
   get current(): Profile {
-    return this.profile
+    return this.committed.profile
   }
   }
 
 
   /** The profile name (`dsh --profile <name>`). */
   /** The profile name (`dsh --profile <name>`). */
   get profileName(): string {
   get profileName(): string {
-    return this.profile.name
+    return this.committed.profile.name
   }
   }
 
 
   /** Absolute path of the dsh app's package.json, the anchor profile packages resolve from. */
   /** Absolute path of the dsh app's package.json, the anchor profile packages resolve from. */
@@ -77,22 +87,27 @@ export class ProfileRuntime extends Service {
 
 
   /** Absolute profile directory. */
   /** Absolute profile directory. */
   get dir(): string {
   get dir(): string {
-    return this.profile.dir
+    return this.committed.profile.dir
   }
   }
 
 
   /** Absolute path of the profile's own user patch file. */
   /** Absolute path of the profile's own user patch file. */
   get patchPath(): string {
   get patchPath(): string {
-    return this.profile.patchPath
+    return this.committed.profile.patchPath
   }
   }
 
 
   /** Whether user patch files reload while the profile runs. */
   /** Whether user patch files reload while the profile runs. */
   get patchReload(): ProfilePatchReload {
   get patchReload(): ProfilePatchReload {
-    return this.profile.patchReload
+    return this.committed.profile.patchReload
   }
   }
 
 
   /** The bundle layers currently composed, in application order. */
   /** The bundle layers currently composed, in application order. */
   get layers(): readonly ProfileLayer[] {
   get layers(): readonly ProfileLayer[] {
-    return this.profile.layers
+    return this.committed.profile.layers
+  }
+
+  /** The rows the current composition left out: bundles skipped over a row id and user inserts of taken ids. */
+  get conflicts(): readonly RowConflict[] {
+    return this.committed.stack.conflicts
   }
   }
 
 
   /**
   /**
@@ -101,21 +116,27 @@ export class ProfileRuntime extends Service {
    * @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).
    * @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 {
   originOf(rowId: string): RowOrigin | undefined {
-    this.origins ??= this.computeOrigins()
-    return this.origins.get(rowId)
+    const layer = this.committed.stack.owners.get(rowId)
+    if (layer === undefined) return undefined
+    return {
+      trust: layer.trust,
+      packageName: layer.packageName,
+      ...layer.version === undefined ? {} : { version: layer.version },
+    }
   }
   }
 
 
   /**
   /**
    * Row ids the user patch layers disable with a literal `disabled: true`.
    * Row ids the user patch layers disable with a literal `disabled: true`.
-   * A `!!js` gate in a user file is a condition, not a user decision, and is
-   * left to the composition.
+   * A `!!js` gate in a user file stays an expression node when read from
+   * disk, so it is a condition, not a user decision, and is left to the
+   * composition.
    * @returns the ids, re-read from disk on every call.
    * @returns the ids, re-read from disk on every call.
    */
    */
   userDisabledRowIds(): Set<string> {
   userDisabledRowIds(): Set<string> {
     const ids = new Set<string>()
     const ids = new Set<string>()
     for (const patch of this.options.readUserPatches()) {
     for (const patch of this.options.readUserPatches()) {
       if (patch.insert !== undefined || patch.id === undefined) continue
       if (patch.insert !== undefined || patch.id === undefined) continue
-      if (patch.disabled === true && !isJsDisabled(patch.disabled)) ids.add(patch.id)
+      if (patch.disabled === true) ids.add(patch.id)
     }
     }
     return ids
     return ids
   }
   }
@@ -125,8 +146,10 @@ export class ProfileRuntime extends Service {
    * as they stand now. The root Include re-applies the stack transactionally:
    * 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
    * 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
    * 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.
+   * update back with the previous tree still running. The candidate profile,
+   * its ownership, and its conflicts become the committed composition only
+   * once the update holds; until then, and after a rejection, `current`,
+   * `layers`, `originOf`, and `conflicts` keep describing the running tree.
    * @param options - `reloadBundles` re-reads the profile manifest first, so a
    * @param options - `reloadBundles` re-reads the profile manifest first, so a
    * bundle enabled or installed since boot joins the stack.
    * bundle enabled or installed since boot joins the stack.
    * @throws when the root include is not mounted, or the Loader rejected the update.
    * @throws when the root include is not mounted, or the Loader rejected the update.
@@ -134,30 +157,15 @@ export class ProfileRuntime extends Service {
   async recompose(options: { reloadBundles?: boolean } = {}): Promise<void> {
   async recompose(options: { reloadBundles?: boolean } = {}): Promise<void> {
     const entry = this.options.rootEntry()
     const entry = this.options.rootEntry()
     if (entry === undefined) throw new Error('profileRuntime: the root include is not mounted')
     if (entry === undefined) throw new Error('profileRuntime: the root include is not mounted')
-    if (options.reloadBundles === true) {
-      this.profile = this.options.loadProfile()
-      this.origins = undefined
-    }
+    const profile = options.reloadBundles === true ? this.options.loadProfile() : this.committed.profile
+    const stack = this.options.compose(profile)
     const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
     const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
-    const stack = this.options.compose(this.profile)
     await entry.update({
     await entry.update({
       config: {
       config: {
         ...includeConfig,
         ...includeConfig,
         patches: stack.patches,
         patches: stack.patches,
       },
       },
     })
     })
-    recordRowConflicts(this.ctx, stack.conflicts)
-  }
-
-  private computeOrigins(): Map<string, RowOrigin> {
-    const origins = new Map<string, RowOrigin>()
-    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
+    this.committed = { profile, stack }
   }
   }
 }
 }

+ 3 - 49
packages/boot/app-boot/src/profile.ts

@@ -34,6 +34,7 @@ import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
+import type { BundleStage, DshManifest, DshModuleFallbackManifest, ProfilePatchReload } from '@deepseek-ai/dsh-package-manifest'
 import { resolve as resolvePackage, type Package as ResolvePackageManifest } from 'resolve.exports'
 import { resolve as resolvePackage, type Package as ResolvePackageManifest } from 'resolve.exports'
 import { loadOverlayPatches } from './index.ts'
 import { loadOverlayPatches } from './index.ts'
 
 
@@ -46,39 +47,6 @@ export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml'
 /** Profile-private package links projected into its pnpm-managed node_modules. */
 /** Profile-private package links projected into its pnpm-managed node_modules. */
 const PROFILE_MODULE_FALLBACK_DIR = '.dsh-module-fallback'
 const PROFILE_MODULE_FALLBACK_DIR = '.dsh-module-fallback'
 
 
-/**
- * When an external bundle's rows mount relative to the built-in tree.
- * `runtime` (the default) wraps the bundle's inserted rows in a contained
- * group whose failure is isolated and reported; `boot` mounts them like
- * built-in rows, so a failure stops the process — the choice for a bundle
- * that provides a service built-in rows inject.
- */
-export type BundleStage = 'boot' | 'runtime'
-
-/** The bundle half of the `dsh` manifest section: what a bundle package exports. */
-export interface DshBundleManifest {
-  /** The patch layer this bundle exports, relative to its package root. */
-  patch: string
-  /** Mount stage the bundle author asks for; the profile's `stages` overrides it. */
-  stage?: BundleStage
-}
-
-/** The profile half of the `dsh` manifest section: what a profile directory composes. */
-export interface DshProfileManifest {
-  /** Ordered bundle layer list (package names). */
-  bundles?: string[]
-  /** Whether user patch files reload while this profile remains active. */
-  patchReload?: ProfilePatchReload
-  /** Deployer overrides of each external bundle's mount stage, by package name. */
-  stages?: Record<string, BundleStage>
-  /**
-   * Installed packages treated as built-in: not wrapped, not prefixed, and
-   * fatal on failure. For first-party packages linked into a profile during
-   * development, where provenance alone would classify them external.
-   */
-  firstParty?: string[]
-}
-
 /**
 /**
  * Who supplied a bundle layer. `builtin` layers come with the installation
  * Who supplied a bundle layer. `builtin` layers come with the installation
  * (template bundles) or are declared first-party by the profile; `external`
  * (template bundles) or are declared first-party by the profile; `external`
@@ -86,9 +54,6 @@ export interface DshProfileManifest {
  */
  */
 export type BundleTrust = 'builtin' | 'external'
 export type BundleTrust = 'builtin' | 'external'
 
 
-/** User patch-file lifecycle selected by a profile. */
-export type ProfilePatchReload = 'live' | 'startup'
-
 /** Installation-owned defaults used when a shipped profile is first opened. */
 /** Installation-owned defaults used when a shipped profile is first opened. */
 export interface ProfileTemplate {
 export interface ProfileTemplate {
   /** Ordered bundle layer list. */
   /** Ordered bundle layer list. */
@@ -97,17 +62,6 @@ export interface ProfileTemplate {
   patchReload: ProfilePatchReload
   patchReload: ProfilePatchReload
 }
 }
 
 
-/**
- * The profile-launcher slice of the `dsh`-owned package.json section. A
- * manifest may declare both roles; other consumers own additional keys.
- */
-export interface DshManifestSection {
-  /** Bundle metadata consumed by the profile launcher. */
-  bundle?: DshBundleManifest
-  /** Profile metadata consumed by the profile launcher. */
-  profile?: DshProfileManifest
-}
-
 /** The slice of package.json both profiles and bundles use. */
 /** The slice of package.json both profiles and bundles use. */
 export interface ProfileManifest {
 export interface ProfileManifest {
   name?: string
   name?: string
@@ -115,7 +69,7 @@ export interface ProfileManifest {
   description?: string
   description?: string
   dependencies?: Record<string, string>
   dependencies?: Record<string, string>
   peerDependencies?: Record<string, string>
   peerDependencies?: Record<string, string>
-  dsh?: DshManifestSection
+  dsh?: DshManifest
 }
 }
 
 
 /** One resolved bundle layer of a profile. */
 /** One resolved bundle layer of a profile. */
@@ -369,7 +323,7 @@ interface ModuleProxyManifest {
   private: true
   private: true
   type: 'module'
   type: 'module'
   exports: Record<string, string>
   exports: Record<string, string>
-  dsh: { moduleFallback: { targets: Record<string, string> } }
+  dsh: { moduleFallback: DshModuleFallbackManifest }
 }
 }
 
 
 interface ModuleProxyRecord {
 interface ModuleProxyRecord {

+ 42 - 47
packages/boot/app-boot/tests/compose-stack.spec.ts

@@ -1,18 +1,15 @@
 /**
 /**
  * Tree-wide row-id ownership across the profile stack: built-in layers claim
  * 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
  * 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.
+ * out and recorded, a bundle that repeats one of its own ids is left out the
+ * same way, a user insert of a taken id is dropped, and every conflict
+ * carries its message.
  */
  */
 
 
-import { afterEach, describe, expect, it } from 'vitest'
-import { Context } from '@deepseek-ai/cordis'
+import { describe, expect, it } from 'vitest'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import {
-  claimLayerIds, composeProfileStack, CONTAINED_GROUP_MODULE, ensurePluginFailures, formatRowConflict,
-  recordRowConflicts, type ProfileLayer,
-} from '../src/index.ts'
+import { claimLayerIds, composeProfileStack, CONTAINED_GROUP_MODULE, formatRowConflict, type ProfileLayer } from '../src/index.ts'
 
 
 const NAME = 'dsh-test-bin'
 const NAME = 'dsh-test-bin'
 
 
@@ -27,11 +24,6 @@ const base = layer('@deepseek-ai/dsh-base', 'builtin', [{ insert: [
   { id: 'tools', name: 'cordis:group', group: true, config: [{ id: 'tool-bash', name: 'bash' }] },
   { 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', () => {
 describe('claimLayerIds', () => {
   it('lets built-in layers own their ids, including group children, before any external layer', () => {
   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 ext = layer('ext', 'external', [{ insert: [{ id: 'tool-bash', name: 'ext' }] }])
@@ -39,13 +31,31 @@ describe('claimLayerIds', () => {
     expect(owners.get('tool-bash')?.packageName).toBe('@deepseek-ai/dsh-base')
     expect(owners.get('tool-bash')?.packageName).toBe('@deepseek-ai/dsh-base')
     expect(owners.get('tools')?.packageName).toBe('@deepseek-ai/dsh-base')
     expect(owners.get('tools')?.packageName).toBe('@deepseek-ai/dsh-base')
     expect(skipped.get('ext')).toEqual([
     expect(skipped.get('ext')).toEqual([
-      { rowId: 'tool-bash', moduleName: 'ext', layer: 'ext', packageName: 'ext', declaredBy: '@deepseek-ai/dsh-base' },
+      {
+        rowId: 'tool-bash', moduleName: 'ext', layer: 'ext', packageName: 'ext', declaredBy: '@deepseek-ai/dsh-base',
+        message: 'row "tool-bash" is already declared by @deepseek-ai/dsh-base',
+      },
     ])
     ])
   })
   })
 
 
-  it('throws when two built-in or boot-staged layers declare one id', () => {
+  it('throws when two built-in or boot-staged layers declare one id, or one declares it twice', () => {
     const twin = layer('twin', 'external', [{ insert: [{ id: 'settings', name: 'twin' }] }], 'boot')
     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/)
     expect(() => claimLayerIds([base, twin])).toThrow(/row "settings" is declared by both @deepseek-ai\/dsh-base and twin/)
+    const stutter = layer('stutter', 'builtin', [{ insert: [{ id: 'x', name: 'a' }] }, { insert: [{ id: 'x', name: 'b' }] }])
+    expect(() => claimLayerIds([stutter])).toThrow(/row "x" is declared twice by stutter/)
+  })
+
+  it('leaves out a bundle that declares one of its own ids twice and composes each mounted bundle once', () => {
+    const stutter = layer('stutter', 'external', [{ insert: [{ id: 'x', name: 'stutter/a' }, { id: 'x', name: 'stutter/b' }] }])
+    const clean = layer('clean', 'external', [{ insert: [{ id: 'y', name: 'clean' }] }])
+    const { owners, skipped, composed } = claimLayerIds([base, stutter, clean])
+    expect(skipped.get('stutter')).toEqual([
+      { rowId: 'x', moduleName: 'stutter/b', layer: 'stutter', packageName: 'stutter', declaredBy: 'stutter', message: 'row "x" is declared twice by stutter' },
+    ])
+    expect(owners.has('x')).toBe(false)
+    expect(owners.get('y')?.packageName).toBe('clean')
+    expect([...composed.keys()]).toEqual(['clean'])
+    expect(composed.get('clean')?.patches[1]).toEqual({ id: 'bundle/clean', insert: [{ id: 'y', name: 'clean' }] })
   })
   })
 
 
   it('gives the earlier external bundle the id and leaves the later one out whole', () => {
   it('gives the earlier external bundle the id and leaves the later one out whole', () => {
@@ -75,6 +85,7 @@ describe('composeProfileStack', () => {
     ])
     ])
     expect(stack.layers.map(current => current.label)).toEqual(['@deepseek-ai/dsh-base', 'ext', '/p/cordis.patch.yml', '/home/cordis.patch.yml'])
     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[1]?.patches[0]?.insert?.[0]).toMatchObject({ id: 'bundle/ext', name: CONTAINED_GROUP_MODULE })
+    expect([...stack.owners.keys()]).toEqual(['settings', 'tools', 'tool-bash', 'ext-tool', 'bundle/ext'])
     expect(stack.layers[2]?.patches).toEqual([
     expect(stack.layers[2]?.patches).toEqual([
       { id: 'settings', config: { path: '/x' } },
       { id: 'settings', config: { path: '/x' } },
       { insert: [{ id: 'mine', name: 'mine' }] },
       { insert: [{ id: 'mine', name: 'mine' }] },
@@ -84,9 +95,15 @@ describe('composeProfileStack', () => {
     expect(stack.patches).toEqual(stack.layers.flatMap(current => current.patches))
     expect(stack.patches).toEqual(stack.layers.flatMap(current => current.patches))
     expect(stack.skippedBundles).toEqual([])
     expect(stack.skippedBundles).toEqual([])
     expect(stack.conflicts).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' },
+      { rowId: 'ext-tool', moduleName: 'clash', layer: '/p/cordis.patch.yml', declaredBy: 'ext', message: 'row "ext-tool" is already declared by ext' },
+      {
+        rowId: 'tool-bash', moduleName: 'cordis:group', layer: '/p/cordis.patch.yml', declaredBy: '@deepseek-ai/dsh-base',
+        message: 'row "tool-bash" is already declared by @deepseek-ai/dsh-base',
+      },
+      {
+        rowId: 'mine', moduleName: 'twice', layer: '/home/cordis.patch.yml', declaredBy: '/p/cordis.patch.yml',
+        message: 'row "mine" is already declared by /p/cordis.patch.yml',
+      },
     ])
     ])
   })
   })
 
 
@@ -96,7 +113,10 @@ describe('composeProfileStack', () => {
     expect(stack.layers.map(current => current.label)).toEqual(['@deepseek-ai/dsh-base'])
     expect(stack.layers.map(current => current.label)).toEqual(['@deepseek-ai/dsh-base'])
     expect(stack.skippedBundles).toEqual(['clash'])
     expect(stack.skippedBundles).toEqual(['clash'])
     expect(stack.conflicts).toEqual([
     expect(stack.conflicts).toEqual([
-      { rowId: 'settings', moduleName: 'clash', layer: 'clash', packageName: 'clash', declaredBy: '@deepseek-ai/dsh-base' },
+      {
+        rowId: 'settings', moduleName: 'clash', layer: 'clash', packageName: 'clash', declaredBy: '@deepseek-ai/dsh-base',
+        message: 'row "settings" is already declared by @deepseek-ai/dsh-base',
+      },
     ])
     ])
   })
   })
 
 
@@ -108,35 +128,10 @@ describe('composeProfileStack', () => {
 
 
 describe('formatRowConflict', () => {
 describe('formatRowConflict', () => {
   it('names the bundle left out, or the user layer whose insert was skipped', () => {
   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' }))
+    const message = 'row "x" is already declared by base'
+    expect(formatRowConflict({ rowId: 'x', moduleName: 'm', layer: 'pkg', packageName: 'pkg', declaredBy: 'base', message }))
       .toBe('bundle pkg left out — row "x" is already declared by 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' }))
+    expect(formatRowConflict({ rowId: 'x', moduleName: 'm', layer: '/p/cordis.patch.yml', declaredBy: 'base', message }))
       .toBe('/p/cordis.patch.yml: insert of m skipped — row "x" is already declared by 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'])
-  })
-})

+ 27 - 0
packages/boot/app-boot/tests/contained-group.spec.ts

@@ -191,6 +191,33 @@ describe('cordis:contained-group', () => {
     expect(registry.get('include:ext/flaky')?.message).toContain('flaky on reload')
     expect(registry.get('include:ext/flaky')?.message).toContain('flaky on reload')
   })
   })
 
 
+  it('forgets its rows\' records when the group unmounts', async () => {
+    const ctx = await boot(NAME, stage(`
+- id: bundle/ext
+  name: cordis:contained-group
+  group: true
+  config:
+    - id: ext/bad
+      name: cordis:throws
+    - id: ext/ok
+      name: cordis:good
+- id: bundle/other
+  name: cordis:contained-group
+  group: true
+  config:
+    - id: other/bad
+      name: cordis:throws
+`), [], prepare)
+    contexts.push(ctx)
+    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
+    expect(registry.list().map(failure => failure.entryId).sort()).toEqual(['include:ext/bad', 'include:other/bad'])
+    // The group's row id keys the tree store; `Entry.id` carries the include prefix.
+    const group = ctx.loader.resolve('include:bundle/ext')
+    await group.parent.remove(group.options.id)
+    expect([...ctx.loader.entries()].some(entry => entry.id === 'include:ext/ok')).toBe(false)
+    expect(registry.list().map(failure => failure.entryId)).toEqual(['include:other/bad'])
+  })
+
   it('provides one registry per runtime', async () => {
   it('provides one registry per runtime', async () => {
     const ctx = new Context()
     const ctx = new Context()
     contexts.push(ctx)
     contexts.push(ctx)

+ 78 - 32
packages/boot/app-boot/tests/external-bundles.spec.ts

@@ -9,10 +9,10 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { join } from 'node:path'
 import { describe, expect, it } from 'vitest'
 import { describe, expect, it } from 'vitest'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
-import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
+import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import {
 import {
-  bundleGroupId, bundleLayerPatches, composeExternalLayer, CONTAINED_GROUP_MODULE, disableBundle, enableBundle,
-  exportsBundlePatch, isContainedLayer, isJsDisabled, reconcileInstalledBundles, readProfileManifest, type ProfileLayer,
+  bundleGroupId, composeExternalLayer, CONTAINED_GROUP_MODULE, disableBundle, enableBundle,
+  exportsBundlePatch, isContainedLayer, reconcileInstalledBundles, readProfileManifest, type ProfileLayer,
 } from '../src/index.ts'
 } from '../src/index.ts'
 
 
 const NAME = 'dsh-test-bin'
 const NAME = 'dsh-test-bin'
@@ -27,30 +27,34 @@ function layer(packageName: string, patches: PatchOptions[]): ProfileLayer {
 }
 }
 
 
 describe('composeExternalLayer', () => {
 describe('composeExternalLayer', () => {
-  it('wraps root inserts in one contained group and keeps every row id as declared', () => {
+  it('inserts an empty contained group first and re-targets root inserts into it, ids as declared', () => {
     const composed = composeExternalLayer(layer('pkg-a', [
     const composed = composeExternalLayer(layer('pkg-a', [
       { insert: [{ id: 'tool', name: 'pkg-a' }, { name: 'pkg-a/anonymous' } as EntryOptions] },
       { 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' }] }] },
       { insert: [{ id: 'grp', name: 'cordis:group', group: true, config: [{ id: 'inner', name: 'pkg-a/inner' }] }] },
     ]))
     ]))
-    expect(composed.patches).toEqual([{
-      insert: [{
-        id: 'bundle/pkg-a',
-        name: CONTAINED_GROUP_MODULE,
-        group: true,
-        config: [
-          { id: 'tool', name: 'pkg-a' },
-          { name: 'pkg-a/anonymous' },
-          { id: 'grp', name: 'cordis:group', group: true, config: [{ id: 'inner', name: 'pkg-a/inner' }] },
-        ],
-      }],
+    expect(composed.patches).toEqual([
+      { insert: [{ id: 'bundle/pkg-a', name: CONTAINED_GROUP_MODULE, group: true, config: [] }] },
+      { id: 'bundle/pkg-a', insert: [{ id: 'tool', name: 'pkg-a' }, { name: 'pkg-a/anonymous' }] },
+      { id: 'bundle/pkg-a', insert: [{ id: 'grp', name: 'cordis:group', group: true, config: [{ id: 'inner', name: 'pkg-a/inner' }] }] },
+    ])
+    expect(applyEntryPatches([], composed.patches, () => {})).toEqual([{
+      id: 'bundle/pkg-a',
+      name: CONTAINED_GROUP_MODULE,
+      group: true,
+      config: [
+        { id: 'tool', name: 'pkg-a' },
+        { name: 'pkg-a/anonymous' },
+        { id: 'grp', name: 'cordis:group', group: true, config: [{ id: 'inner', name: 'pkg-a/inner' }] },
+      ],
     }])
     }])
     expect([...composed.rows]).toEqual([
     expect([...composed.rows]).toEqual([
       ['tool', 'pkg-a'], ['grp', 'cordis:group'], ['inner', 'pkg-a/inner'], ['bundle/pkg-a', CONTAINED_GROUP_MODULE],
       ['tool', 'pkg-a'], ['grp', 'cordis:group'], ['inner', 'pkg-a/inner'], ['bundle/pkg-a', CONTAINED_GROUP_MODULE],
     ])
     ])
+    expect(composed.duplicates).toEqual([])
     expect(composed.overrides).toEqual([])
     expect(composed.overrides).toEqual([])
   })
   })
 
 
-  it('passes patches on the bundle\'s own rows through and reports patches on other rows as overrides', () => {
+  it('keeps the written order of inserts and id-targeted patches, reporting patches on other rows as overrides', () => {
     const composed = composeExternalLayer(layer('pkg-b', [
     const composed = composeExternalLayer(layer('pkg-b', [
       { insert: [{ id: 'own', name: 'pkg-b' }] },
       { insert: [{ id: 'own', name: 'pkg-b' }] },
       { id: 'own', config: { flag: true } },
       { id: 'own', config: { flag: true } },
@@ -58,16 +62,32 @@ describe('composeExternalLayer', () => {
       { id: 'own', insert: [{ id: 'child', name: 'pkg-b/child' }] },
       { id: 'own', insert: [{ id: 'child', name: 'pkg-b/child' }] },
     ]))
     ]))
     expect(composed.patches.slice(1)).toEqual([
     expect(composed.patches.slice(1)).toEqual([
-      { id: 'own', insert: [{ id: 'child', name: 'pkg-b/child' }] },
+      { id: 'bundle/pkg-b', insert: [{ id: 'own', name: 'pkg-b' }] },
       { id: 'own', config: { flag: true } },
       { id: 'own', config: { flag: true } },
       { id: 'settings', config: { path: '/x' } },
       { id: 'settings', config: { path: '/x' } },
+      { id: 'own', insert: [{ id: 'child', name: 'pkg-b/child' }] },
     ])
     ])
     expect(composed.overrides).toEqual(['settings'])
     expect(composed.overrides).toEqual(['settings'])
   })
   })
 
 
-  it('nests rows inserted into a built-in group inside their own contained group', () => {
+  it('mounts a group config replaced and then appended to the same way a built-in layer would', () => {
+    const written = (): PatchOptions[] => [
+      { insert: [{ id: 'g', name: 'cordis:group', group: true, config: [{ id: 'x', name: 'pkg-o/x' }] }] },
+      { id: 'g', config: [{ id: 'a', name: 'pkg-o/a' }] },
+      { id: 'g', insert: [{ id: 'b', name: 'pkg-o/b' }] },
+    ]
+    const asBuiltin = applyEntryPatches([], written(), () => {})
+    const contained = applyEntryPatches([], composeExternalLayer(layer('pkg-o', written())).patches, () => {})
+    const childrenOf = (rows: EntryOptions[]): string[] => (rows.find(row => row.id === 'g')?.config as EntryOptions[]).map(row => row.id)
+    expect(childrenOf(asBuiltin)).toEqual(['a', 'b'])
+    expect(childrenOf(contained[0]?.config as EntryOptions[])).toEqual(['a', 'b'])
+  })
+
+  it('nests rows inserted into a built-in group inside one contained group per target', () => {
     const composed = composeExternalLayer(layer('pkg-c', [
     const composed = composeExternalLayer(layer('pkg-c', [
       { id: 'persistent-shell', insert: [{ id: 'extra', name: 'pkg-c/extra' }] },
       { id: 'persistent-shell', insert: [{ id: 'extra', name: 'pkg-c/extra' }] },
+      { id: 'tools', insert: [{ id: 'tool-a', name: 'pkg-c/a' }] },
+      { id: 'persistent-shell', insert: [{ id: 'more', name: 'pkg-c/more' }] },
     ]))
     ]))
     expect(composed.patches).toEqual([
     expect(composed.patches).toEqual([
       { insert: [{ id: 'bundle/pkg-c', name: CONTAINED_GROUP_MODULE, group: true, config: [] }] },
       { insert: [{ id: 'bundle/pkg-c', name: CONTAINED_GROUP_MODULE, group: true, config: [] }] },
@@ -75,8 +95,30 @@ describe('composeExternalLayer', () => {
         id: 'persistent-shell',
         id: 'persistent-shell',
         insert: [{ id: 'bundle/pkg-c/in/persistent-shell', name: CONTAINED_GROUP_MODULE, group: true, config: [{ id: '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' }] }],
       },
       },
+      { id: 'tools', insert: [{ id: 'bundle/pkg-c/in/tools', name: CONTAINED_GROUP_MODULE, group: true, config: [{ id: 'tool-a', name: 'pkg-c/a' }] }] },
+      { id: 'bundle/pkg-c/in/persistent-shell', insert: [{ id: 'more', name: 'pkg-c/more' }] },
     ])
     ])
-    expect(composed.rows.get('bundle/pkg-c/in/persistent-shell')).toBe(CONTAINED_GROUP_MODULE)
+    expect([...composed.rows.keys()].filter(id => id.includes('/in/'))).toEqual(['bundle/pkg-c/in/persistent-shell', 'bundle/pkg-c/in/tools'])
+    const tree = applyEntryPatches([
+      { id: 'persistent-shell', name: 'cordis:group', group: true, config: [] },
+      { id: 'tools', name: 'cordis:group', group: true, config: [] },
+    ], composed.patches, () => {})
+    const shell = tree.find(row => row.id === 'persistent-shell')?.config as EntryOptions[]
+    expect(shell.map(row => row.id)).toEqual(['bundle/pkg-c/in/persistent-shell'])
+    expect((shell[0]?.config as EntryOptions[]).map(row => row.id)).toEqual(['extra', 'more'])
+  })
+
+  it('reports an id the bundle inserts twice and keeps the first module for it', () => {
+    const composed = composeExternalLayer(layer('pkg-r', [
+      { insert: [{ id: 'dup', name: 'pkg-r/one' }] },
+      { insert: [{ id: 'g', name: 'cordis:group', group: true, config: [{ id: 'dup', name: 'pkg-r/two' }] }] },
+      { insert: [{ id: 'bundle/pkg-r', name: 'pkg-r/steals-the-group-id' }] },
+    ]))
+    expect(composed.duplicates).toEqual([
+      { rowId: 'dup', moduleName: 'pkg-r/two' },
+      { rowId: 'bundle/pkg-r', moduleName: CONTAINED_GROUP_MODULE },
+    ])
+    expect(composed.rows.get('dup')).toBe('pkg-r/one')
   })
   })
 
 
   it('never mutates the layer\'s own patch objects', () => {
   it('never mutates the layer\'s own patch objects', () => {
@@ -86,26 +128,30 @@ describe('composeExternalLayer', () => {
     expect(patches).toEqual(snapshot)
     expect(patches).toEqual(snapshot)
   })
   })
 
 
+  it('mounts the same tree when its patches are applied twice, as a rolled-back update re-applies them', () => {
+    const composed = composeExternalLayer(layer('pkg-t', [
+      { insert: [{ id: 'row', name: 'pkg-t' }] },
+      { id: 'tools', insert: [{ id: 'tool', name: 'pkg-t/tool' }] },
+    ]))
+    const snapshot = structuredClone(composed.patches)
+    const base = (): EntryOptions[] => [{ id: 'tools', name: 'cordis:group', group: true, config: [] }]
+    const first = applyEntryPatches(base(), composed.patches, () => {})
+    const second = applyEntryPatches(base(), composed.patches, () => {})
+    expect(second).toEqual(first)
+    expect(composed.patches).toEqual(snapshot)
+    expect((first[1]?.config as EntryOptions[]).map(row => row.id)).toEqual(['row'])
+  })
+
   it('spells the group id 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(bundleGroupId('@scope/pkg')).toBe('bundle/@scope/pkg')
     expect(bundleGroupId('x')).not.toContain(':')
     expect(bundleGroupId('x')).not.toContain(':')
   })
   })
 
 
-  it('mounts only runtime-staged external layers as contained groups', () => {
+  it('contains only runtime-staged external layers', () => {
     const contained = layer('pkg-e', [{ insert: [{ id: 'row', name: 'pkg-e' }] }])
     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(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)
+    expect(isContainedLayer({ ...contained, stage: 'boot' })).toBe(false)
+    expect(isContainedLayer({ ...contained, trust: 'builtin' })).toBe(false)
   })
   })
 })
 })
 
 

+ 60 - 2
packages/boot/app-boot/tests/probe.spec.ts

@@ -9,6 +9,8 @@ import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { fileURLToPath } from 'node:url'
 import { describe, expect, it } from 'vitest'
 import { describe, expect, it } from 'vitest'
 import { PLUGIN_PROBE_DIR, PLUGIN_PROBE_FORMAT, probePackage, readProbeCache, writeProbeCache, type PluginProbe } from '../src/index.ts'
 import { PLUGIN_PROBE_DIR, PLUGIN_PROBE_FORMAT, probePackage, readProbeCache, writeProbeCache, type PluginProbe } from '../src/index.ts'
+import { parseProbeRecord } from '../src/probe.ts'
+import { parseChildReport, type ChildReport } from '../src/probe-report.ts'
 
 
 const NAME = 'dsh-test-bin'
 const NAME = 'dsh-test-bin'
 
 
@@ -190,15 +192,30 @@ describe('probePackage', () => {
     expect(probe.addable[0]?.error).toBeDefined()
     expect(probe.addable[0]?.error).toBeDefined()
   })
   })
 
 
-  it('kills a child that never reports and refuses an unresolvable package', async () => {
+  it('keeps probing a package that prints at import, and kills one that lingers once it reported', async () => {
     const { profileDir, installAnchor } = stage({
     const { profileDir, installAnchor } = stage({
-      'hangs': { main: 'setInterval(() => {}, 1000)\nexport function apply() {}\n' },
+      'chatty': { main: 'console.log("initializing logging-plugin")\nexport function apply() {}\n', manifest: { dsh: { title: 'Chatty' } } },
+      'lingers': { main: 'setInterval(() => {}, 1000)\nexport function apply() {}\n', manifest: { dsh: { title: 'Lingers' } } },
+    })
+    const chatty = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'chatty' })
+    expect(chatty).toMatchObject({ kind: 'plugin', ok: true })
+    const lingers = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'lingers', timeoutMs: 5_000 })
+    expect(lingers).toMatchObject({ kind: 'plugin', ok: true })
+  })
+
+  it('kills a child that never reports, rejects an unrecognized report, and refuses an unresolvable package', async () => {
+    const { profileDir, installAnchor } = stage({
+      // Blocks the child's thread inside the import: an unsettled top-level await would make Node exit instead.
+      'hangs': { main: 'Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0)\nexport function apply() {}\n' },
       'exits': { main: 'process.stderr.write("refusing to report"); process.exit(3)\n' },
       'exits': { main: 'process.stderr.write("refusing to report"); process.exit(3)\n' },
+      'spoofs': { main: 'process.send({ nope: true }); process.exit(0)\n' },
     })
     })
     await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'hangs', timeoutMs: 300 }))
     await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'hangs', timeoutMs: 300 }))
       .rejects.toThrow(/timed out after 300ms/)
       .rejects.toThrow(/timed out after 300ms/)
     await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'exits' }))
     await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'exits' }))
       .rejects.toThrow(/exited with 3 without a report: refusing to report/)
       .rejects.toThrow(/exited with 3 without a report: refusing to report/)
+    await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'spoofs' }))
+      .rejects.toThrow(/reported an unrecognized value/)
     await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'ghost' }))
     await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'ghost' }))
       .rejects.toThrow(/cannot resolve profile bundle "ghost"/)
       .rejects.toThrow(/cannot resolve profile bundle "ghost"/)
     await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'exits', nodeExecutable: '/no/such/node' }))
     await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'exits', nodeExecutable: '/no/such/node' }))
@@ -225,5 +242,46 @@ describe('probe cache', () => {
     expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
     expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
     writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), JSON.stringify({ format: PLUGIN_PROBE_FORMAT - 1, ...record }))
     writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), JSON.stringify({ format: PLUGIN_PROBE_FORMAT - 1, ...record }))
     expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
     expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
+    // A file with the right format but not the fields of a record is probed again, not returned as one.
+    writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), JSON.stringify({ format: PLUGIN_PROBE_FORMAT }))
+    expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
+    writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), JSON.stringify({ format: PLUGIN_PROBE_FORMAT, ...record, kind: 'weird' }))
+    expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
+    writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), JSON.stringify([record]))
+    expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
+  })
+
+  it('accepts only a complete record', () => {
+    const record: PluginProbe = {
+      packageName: 'pkg', version: '1.0.0', description: 'd', title: 't', kind: 'bundle', ok: false, reason: 'r',
+      cordisSameCopy: false, enginesDsh: '>=1', rows: [{ id: 'a', name: 'm', gated: false }, { name: 'n', gated: true }],
+      overrides: ['x'], addable: [{ name: 'p', title: 'u', ok: false, error: 'e' }, { name: 'q', ok: true }],
+      configSchema: { type: 'object' }, checkedAt: '2026-09-05T00:00:00.000Z',
+    }
+    expect(parseProbeRecord(record)).toBe(record)
+    const broken: Record<string, unknown>[] = [
+      { packageName: 1 }, { checkedAt: 1 }, { version: 1 }, { kind: 1 }, { kind: 'weird' }, { ok: 'yes' },
+      { cordisSameCopy: 'no' }, { rows: {} }, { rows: [1] }, { rows: [{ id: 1, name: 'm', gated: false }] },
+      { rows: [{ name: 1, gated: false }] }, { rows: [{ name: 'm', gated: 'no' }] }, { overrides: 'x' }, { overrides: [1] },
+      { addable: {} }, { addable: [1] }, { addable: [{ name: 1, ok: true }] }, { addable: [{ name: 'p', title: 1, ok: true }] },
+      { addable: [{ name: 'p', ok: 'yes' }] }, { addable: [{ name: 'p', ok: true, error: 1 }] },
+    ]
+    for (const fields of broken) expect(parseProbeRecord({ ...record, ...fields }), JSON.stringify(fields)).toBeUndefined()
+    expect(parseProbeRecord('record')).toBeUndefined()
+  })
+})
+
+describe('parseChildReport', () => {
+  it('accepts the child\'s message only with every field in place', () => {
+    const inspection = { ok: true, isPlugin: true, configSchema: null }
+    const report: ChildReport = { cordis: null, main: inspection, addable: { 'pkg/x': { ...inspection, ok: false, error: 'boom' } } }
+    expect(parseChildReport(report)).toBe(report)
+    expect(parseChildReport({ ...report, cordis: 'file:///cordis/index.js' })).toBeDefined()
+    const broken: Record<string, unknown>[] = [
+      { cordis: 1 }, { main: undefined }, { main: { ...inspection, ok: 'yes' } }, { main: { ...inspection, isPlugin: 'no' } },
+      { main: { ok: true, isPlugin: true } }, { main: { ...inspection, error: 1 } }, { addable: [] }, { addable: { 'pkg/x': 1 } },
+    ]
+    for (const fields of broken) expect(parseChildReport({ ...report, ...fields }), JSON.stringify(fields)).toBeUndefined()
+    expect(parseChildReport('report')).toBeUndefined()
   })
   })
 })
 })

+ 22 - 14
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 { Context } from '@deepseek-ai/cordis'
 import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import { ensurePluginFailures, ProfileRuntime, type ComposedStack, type Profile, type ProfileLayer } from '../src/index.ts'
+import { claimLayerIds, ProfileRuntime, type ComposedStack, type Profile, type ProfileLayer } from '../src/index.ts'
 
 
 const contexts: Context[] = []
 const contexts: Context[] = []
 afterEach(async () => {
 afterEach(async () => {
@@ -28,17 +28,21 @@ async function harness(
 ): Promise<{ ctx: Context; runtime: ProfileRuntime; compose: ReturnType<typeof vi.fn> }> {
 ): Promise<{ ctx: Context; runtime: ProfileRuntime; compose: ReturnType<typeof vi.fn> }> {
   const ctx = new Context()
   const ctx = new Context()
   contexts.push(ctx)
   contexts.push(ctx)
+  // The conflicts, when given, belong to the reloaded profile only.
   const compose = vi.fn((current: Profile): ComposedStack => {
   const compose = vi.fn((current: Profile): ComposedStack => {
     const patches = [{ id: `composed-for-${current.layers.length}` }] as PatchOptions[]
     const patches = [{ id: `composed-for-${current.layers.length}` }] as PatchOptions[]
     return {
     return {
       patches,
       patches,
       layers: [{ label: 'stack', patches }],
       layers: [{ label: 'stack', patches }],
-      conflicts: options.conflicts ?? [],
+      owners: claimLayerIds(current.layers).owners,
+      conflicts: current === options.reloaded ? options.conflicts ?? [] : [],
       skippedBundles: [],
       skippedBundles: [],
     }
     }
   })
   })
+  const booted = profile(layers)
   await ctx.plugin(ProfileRuntime, {
   await ctx.plugin(ProfileRuntime, {
-    profile: profile(layers),
+    profile: booted,
+    stack: compose(booted),
     installAnchor: '/install/package.json',
     installAnchor: '/install/package.json',
     loadProfile: () => options.reloaded ?? profile(layers),
     loadProfile: () => options.reloaded ?? profile(layers),
     compose,
     compose,
@@ -101,32 +105,36 @@ describe('ProfileRuntime', () => {
     expect([...runtime.userDisabledRowIds()]).toEqual(['a'])
     expect([...runtime.userDisabledRowIds()]).toEqual(['a'])
   })
   })
 
 
-  it('recomposes through the root include, optionally re-reading the profile first', async () => {
+  it('recomposes through the root include, optionally re-reading the profile first, and commits on acceptance', async () => {
     const update = vi.fn(async () => {})
     const update = vi.fn(async () => {})
     const entry = { options: { config: { path: 'file:///root/cordis.yml', patches: [{ id: 'old' }] } }, update } as unknown as Entry
     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 reloaded = profile([layer('a', 'builtin', []), layer('b', 'external', [])])
-    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 })
+    const conflicts = [{ rowId: 'x', moduleName: 'm', layer: 'late', packageName: 'late', declaredBy: 'a', message: 'row "x" is already declared by a' }]
+    const { runtime, compose } = await harness([layer('a', 'builtin', [])], { rootEntry: () => entry, reloaded, conflicts })
 
 
     await runtime.recompose()
     await runtime.recompose()
     expect(compose).toHaveBeenLastCalledWith(expect.objectContaining({ layers: expect.any(Array) as ProfileLayer[] }))
     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' }] } })
     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' })])
+    expect(runtime.conflicts).toEqual([])
 
 
     await runtime.recompose({ reloadBundles: true })
     await runtime.recompose({ reloadBundles: true })
     expect(runtime.layers).toHaveLength(2)
     expect(runtime.layers).toHaveLength(2)
     expect(update).toHaveBeenLastCalledWith({ config: { path: 'file:///root/cordis.yml', patches: [{ id: 'composed-for-2' }] } })
     expect(update).toHaveBeenLastCalledWith({ config: { path: 'file:///root/cordis.yml', patches: [{ id: 'composed-for-2' }] } })
-    // Provenance follows the reloaded profile.
+    // Provenance and conflicts follow the reloaded profile once the update holds.
     expect(runtime.originOf('bundle/b')).toEqual({ trust: 'external', packageName: 'b', version: '2.0.0' })
     expect(runtime.originOf('bundle/b')).toEqual({ trust: 'external', packageName: 'b', version: '2.0.0' })
+    expect(runtime.conflicts).toEqual(conflicts)
   })
   })
 
 
-  it('leaves the registry untouched when the root include rejects the update', async () => {
+  it('keeps the committed profile, provenance, and conflicts 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 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()
+    const reloaded = profile([layer('a', 'builtin', []), layer('b', 'external', [])])
+    const conflicts = [{ rowId: 'x', moduleName: 'm', layer: 'late', packageName: 'late', declaredBy: 'a', message: 'row "x" is already declared by a' }]
+    const { runtime } = await harness([layer('a', 'builtin', [])], { rootEntry: () => entry, reloaded, conflicts })
+    await expect(runtime.recompose({ reloadBundles: true })).rejects.toThrow('rejected')
+    expect(runtime.current.layers).toHaveLength(1)
+    expect(runtime.layers.map(current => current.packageName)).toEqual(['a'])
+    expect(runtime.originOf('bundle/b')).toBeUndefined()
+    expect(runtime.conflicts).toEqual([])
   })
   })
 
 
   it('refuses to recompose before the root include is mounted', async () => {
   it('refuses to recompose before the root include is mounted', async () => {

+ 49 - 3
packages/boot/app-boot/tests/user-patches.spec.ts

@@ -17,8 +17,9 @@ import Timer from '@deepseek-ai/cordis-plugin-timer'
 import {
 import {
   boot,
   boot,
   loadOptionalPatches,
   loadOptionalPatches,
+  loadOverlayPatches,
   PROFILE_PATCH_FILENAME,
   PROFILE_PATCH_FILENAME,
-  watchUserPatches,
+  watchUserPatches, rootIncludeEntry,
 } from '../src/index.ts'
 } from '../src/index.ts'
 
 
 const NAME = 'dsh-test-bin'
 const NAME = 'dsh-test-bin'
@@ -74,6 +75,43 @@ describe('loadOptionalPatches', () => {
     expect(patches?.[1]?.insert).toHaveLength(1)
     expect(patches?.[1]?.insert).toHaveLength(1)
   })
   })
 
 
+  it.each([
+    { label: 'optional', load: loadOptionalPatches },
+    { label: 'overlay', load: loadOverlayPatches },
+  ])('loads absolute plugin paths from patch files as file URLs ($label)', async ({ load }) => {
+    const dir = tmp()
+    const pluginPath = join(dir, 'absolute #100%.mjs')
+    const pluginUrl = pathToFileURL(pluginPath).href
+    writeFileSync(pluginPath, 'export function apply(ctx) { ctx.provide("absolutePatchLoaded", true) }\n')
+    const patchPath = join(dir, PROFILE_PATCH_FILENAME)
+    writeFileSync(patchPath, JSON.stringify([
+      { id: 'existing', name: pluginPath },
+      { insert: [
+        { id: 'absolute', name: pluginPath },
+        { id: 'url', name: pluginUrl },
+        { id: 'bare', name: '@deepseek-ai/dsh-system-prompt' },
+        { id: 'nested', name: 'cordis:group', group: true, config: [
+          { id: 'child', name: pluginPath },
+        ] },
+      ] },
+    ]))
+    const patches = load(NAME, patchPath)!
+    expect(patches[0]?.name).toBe(pluginPath)
+    expect(patches[1]?.insert?.map(entry => entry.name)).toEqual([
+      pluginUrl, pluginUrl, '@deepseek-ai/dsh-system-prompt', 'cordis:group',
+    ])
+    expect((patches[1]?.insert?.[3]?.config as { name: string }[])[0]?.name).toBe(pluginUrl)
+
+    const configPath = join(dir, 'cordis.yml')
+    writeFileSync(configPath, '[]\n')
+    const ctx = await boot(NAME, configPath, [{ insert: [patches[1]!.insert![0]!] }])
+    try {
+      expect(ctx.get('absolutePatchLoaded')).toBe(true)
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
   it('anchors inserted relative plugins to the patch file and keeps assertion names literal', () => {
   it('anchors inserted relative plugins to the patch file and keeps assertion names literal', () => {
     const dir = tmp()
     const dir = tmp()
     const patchPath = join(dir, PROFILE_PATCH_FILENAME)
     const patchPath = join(dir, PROFILE_PATCH_FILENAME)
@@ -367,7 +405,12 @@ describe('boot with user patches', () => {
     const dispose = await watchUserPatches(ctx, {
     const dispose = await watchUserPatches(ctx, {
       binName: NAME,
       binName: NAME,
       filename,
       filename,
-      compose: userPatches => [...basePatches, ...userPatches],
+      reapply: async () => {
+        const entry = rootIncludeEntry(ctx)
+        if (entry === undefined) throw new Error('no root include')
+        const { patches: _previous, ...config } = entry.options.config as Include.Config
+        await entry.update({ config: { ...config, patches: [...basePatches, ...loadOptionalPatches(NAME, filename) ?? []] } })
+      },
     })
     })
     try {
     try {
       writeFileSync(filename, '- id: noop\n  config:\n    value: live\n')
       writeFileSync(filename, '- id: noop\n  config:\n    value: live\n')
@@ -395,13 +438,16 @@ describe('boot with user patches', () => {
       expect(failures).toHaveLength(2)
       expect(failures).toHaveLength(2)
       await settleChokidarChangeThrottle()
       await settleChokidarChangeThrottle()
 
 
-      // Default compose: the user layer IS the whole patch list, so a
+      // Default re-application: the user layer IS the whole patch list, so a
       // fresh generation replaces the app-owned layer instead of stacking on it.
       // fresh generation replaces the app-owned layer instead of stacking on it.
       await dispose()
       await dispose()
       const disposeDefault = await watchUserPatches(ctx, { binName: NAME, filename })
       const disposeDefault = await watchUserPatches(ctx, { binName: NAME, filename })
       try {
       try {
         writeFileSync(filename, '- id: noop\n  config:\n    value: identity\n')
         writeFileSync(filename, '- id: noop\n  config:\n    value: identity\n')
         await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose user patch was not applied')
         await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose user patch was not applied')
+        await settleChokidarChangeThrottle()
+        unlinkSync(filename)
+        await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'base', 'default-compose removal did not empty the patch list')
       } finally {
       } finally {
         await disposeDefault()
         await disposeDefault()
       }
       }

+ 3 - 0
packages/boot/app-boot/tsconfig.json

@@ -37,6 +37,9 @@
     },
     },
     {
     {
       "path": "../../util/home-paths"
       "path": "../../util/home-paths"
+    },
+    {
+      "path": "../../util/package-manifest"
     }
     }
   ]
   ]
 }
 }

+ 26 - 12
packages/boot/app-boot/tsdown.config.ts

@@ -4,16 +4,30 @@ import { defineConfig } from 'tsdown'
  * Embed Include while keeping Loader external so the built include tree and
  * Embed Include while keeping Loader external so the built include tree and
  * app host bind to one Loader peer.
  * app host bind to one Loader peer.
  */
  */
-export default defineConfig({
-  entry: ['lib/types/index.js'],
-  outDir: 'lib',
-  format: ['esm'],
-  platform: 'node',
-  target: 'es2024',
-  fixedExtension: false,
-  dts: false,
-  clean: false,
-  deps: {
-    alwaysBundle: ['@deepseek-ai/cordis-plugin-include'],
+export default defineConfig([
+  {
+    entry: ['lib/types/index.js'],
+    outDir: 'lib',
+    format: ['esm'],
+    platform: 'node',
+    target: 'es2024',
+    fixedExtension: false,
+    dts: false,
+    clean: false,
+    deps: {
+      alwaysBundle: ['@deepseek-ai/cordis-plugin-include'],
+    },
   },
   },
-})
+  {
+    // The package probe's child entry ships beside the lib as its own
+    // bundle; the probe path-loads it relative to its own module.
+    entry: { 'probe-child': 'lib/types/probe-child.js' },
+    outDir: 'lib',
+    format: ['esm'],
+    platform: 'node',
+    target: 'es2024',
+    fixedExtension: false,
+    dts: false,
+    clean: false,
+  },
+])

+ 2 - 2
packages/client/modules/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/modules/README.md
 #   pnpm run verify-translation-pairing --write packages/client/modules/README.md
-README.md: 414d34af202ee4ab24e1b7570697ad63bd8cdbc8
-README.zh.md: e6adc7b7d0265e52d5108e241a8f8a551976536d
+README.md: 7bfa39d183bb29f3f9481bb39280c5521786031a
+README.zh.md: 64dc5644f535722b464c867616b18ff557e921be

+ 2 - 0
packages/client/modules/README.md

@@ -25,6 +25,8 @@ English | [中文](README.zh.md)
 <a id="use-this-package"></a>
 <a id="use-this-package"></a>
 ## Use this package
 ## Use this package
 
 
+Use [`DshClientManifest`](../../util/package-manifest/README.md) for the declaration type. Client-modules validates the JSON and owns the normalized boot graph.
+
 Use it when you compose or build a browser client plugin: the package turns a package's `dsh.client` declaration into a loadable browser bundle with no per-plugin wiring. It activates with the web composition; the shell boots it before any plugin runs.
 Use it when you compose or build a browser client plugin: the package turns a package's `dsh.client` declaration into a loadable browser bundle with no per-plugin wiring. It activates with the web composition; the shell boots it before any plugin runs.
 
 
 ### Declaring a client plugin
 ### Declaring a client plugin

+ 2 - 0
packages/client/modules/README.zh.md

@@ -25,6 +25,8 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 <a id="use-this-package"></a>
 ## 使用本包
 ## 使用本包
 
 
+声明类型使用 [`DshClientManifest`](../../util/package-manifest/README.zh.md)。Client-modules 负责 JSON 校验和归一化的启动图。
+
 组合或构建浏览器客户端插件时使用它:本包把包的 `dsh.client` 声明变成可加载的浏览器 bundle,无需任何逐插件接线。它随 web 组合激活;外壳在任何插件运行前启动它。
 组合或构建浏览器客户端插件时使用它:本包把包的 `dsh.client` 声明变成可加载的浏览器 bundle,无需任何逐插件接线。它随 web 组合激活;外壳在任何插件运行前启动它。
 
 
 ### 声明客户端插件
 ### 声明客户端插件

+ 2 - 1
packages/client/modules/package.json

@@ -45,7 +45,8 @@
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-package-manifest": "workspace:^"
   },
   },
   "files": [
   "files": [
     "lib/index.js",
     "lib/index.js",

+ 2 - 17
packages/client/modules/src/index.ts

@@ -33,6 +33,7 @@ import { Service } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
 import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
 import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
 import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
 import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
+import type { DshClientManifest } from '@deepseek-ai/dsh-package-manifest'
 import { optionalStringArray, stripClientSuffix } from './client/manifest.ts'
 import { optionalStringArray, stripClientSuffix } from './client/manifest.ts'
 import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph } from './client/manifest.ts'
 import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph } from './client/manifest.ts'
 
 
@@ -48,22 +49,6 @@ declare module '@deepseek-ai/cordis' {
   }
   }
 }
 }
 
 
-/** package.json `dsh.client` declaration fields, validated one by one after reading the file. */
-interface DshClientDeclaration {
-  inject?: string[]
-  platform: string
-  /** Boot phase-one registration barrier; absent rows still ride the shared application batch. */
-  immediately?: boolean
-  /**
-   * Exact module-table requests beyond the implicit client baseline. Any
-   * specifier is valid, including subpaths such as `<pkg>/client`; each
-   * importing package declares its own exceptional requests. A type-only
-   * import is not a request because the transform erases it before resolution.
-   * Absent means the package uses only the baseline externals.
-   */
-  external?: string[]
-}
-
 /** The declared fields a graph row carries, normalized (absent array declarations become empty). */
 /** The declared fields a graph row carries, normalized (absent array declarations become empty). */
 interface WebBootRowFields {
 interface WebBootRowFields {
   inject?: string[]
   inject?: string[]
@@ -198,7 +183,7 @@ function exactPackageSpecifier(specifier: string): string | undefined {
 }
 }
 
 
 /** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
 /** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
-function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined {
+function parseDshClient(pkgName: string, value: unknown): DshClientManifest | undefined {
   if (value === undefined) return undefined
   if (value === undefined) return undefined
   if (typeof value !== 'object' || value === null) {
   if (typeof value !== 'object' || value === null) {
     throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`)
     throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`)

+ 1 - 0
packages/client/modules/tsconfig.json

@@ -11,6 +11,7 @@
     { "path": "../../../vendor/cordis" },
     { "path": "../../../vendor/cordis" },
     { "path": "../../../vendor/loader" },
     { "path": "../../../vendor/loader" },
     { "path": "../../host/webserver" },
     { "path": "../../host/webserver" },
+    { "path": "../../util/package-manifest" },
     { "path": "../../runtime-diagnostics/invariants" }
     { "path": "../../runtime-diagnostics/invariants" }
   ]
   ]
 }
 }

+ 2 - 2
packages/experimental/webworker-packer/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/experimental/webworker-packer/README.md
 #   pnpm run verify-translation-pairing --write packages/experimental/webworker-packer/README.md
-README.md: 701f4a41db78fff6c6af532d1e4f8aa43d585577
-README.zh.md: 30682e314c36dbc08bcd895fb7a2277a38f7ea52
+README.md: 598a175637a6111001b07b0186c0f1a465d54a9c
+README.zh.md: 8f05e39d34bd33613065cf86fd9c6e51ff90d88e

+ 2 - 0
packages/experimental/webworker-packer/README.md

@@ -23,6 +23,8 @@ The VFS image packer: turns one composed profile into the gzip-compressed base t
 <a id="use-this-package"></a>
 <a id="use-this-package"></a>
 ## Use this package
 ## Use this package
 
 
+The [`DshConfigTreeDeclaration`](../../util/package-manifest/README.md) type describes each `dsh.configTrees` entry; this packer validates it and resolves its source directory.
+
 The pack is a three-layer standard stack:
 The pack is a three-layer standard stack:
 
 
 1. **Roster** — the composed profile's plugin rows (standard YAML parse under Include's dialect, `!!js` intact), plus the rows of every config tree the CLI declares in its `package.json` `dsh.configTrees` (agent presets), materialized as a Node-style dependency closure. External peer edges never bind the worker; workspace peers stay on the chain.
 1. **Roster** — the composed profile's plugin rows (standard YAML parse under Include's dialect, `!!js` intact), plus the rows of every config tree the CLI declares in its `package.json` `dsh.configTrees` (agent presets), materialized as a Node-style dependency closure. External peer edges never bind the worker; workspace peers stay on the chain.

+ 2 - 0
packages/experimental/webworker-packer/README.zh.md

@@ -23,6 +23,8 @@ VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 挂载为
 <a id="use-this-package"></a>
 <a id="use-this-package"></a>
 ## 使用本包
 ## 使用本包
 
 
+[`DshConfigTreeDeclaration`](../../util/package-manifest/README.zh.md) 描述每个 `dsh.configTrees` 条目;本打包器负责校验并解析其源目录。
+
 打包是三层标准栈:
 打包是三层标准栈:
 
 
 1. **Roster**——合成 profile 的插件行(标准 YAML 解析、Include 方言、`!!js` 原样保留),加上 CLI 在 `package.json` `dsh.configTrees` 里声明的每棵配置树(agent presets)的行,按 Node 式依赖闭包物化。外部包的 peer 边不追,workspace peer 保留在链上。
 1. **Roster**——合成 profile 的插件行(标准 YAML 解析、Include 方言、`!!js` 原样保留),加上 CLI 在 `package.json` `dsh.configTrees` 里声明的每棵配置树(agent presets)的行,按 Node 式依赖闭包物化。外部包的 peer 边不追,workspace peer 保留在链上。

+ 2 - 1
packages/experimental/webworker-packer/package.json

@@ -40,7 +40,8 @@
   "devDependencies": {
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
     "@types/js-yaml": "^4.0.9",
     "@types/js-yaml": "^4.0.9",
-    "@types/picomatch": "^3.0.2"
+    "@types/picomatch": "^3.0.2",
+    "@deepseek-ai/dsh-package-manifest": "workspace:^"
   },
   },
   "peerDependencies": {
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
     "@deepseek-ai/cordis": "workspace:^"

+ 2 - 8
packages/experimental/webworker-packer/src/repository.ts

@@ -12,6 +12,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node
 import { tmpdir } from 'node:os'
 import { tmpdir } from 'node:os'
 import { join, relative } from 'node:path'
 import { join, relative } from 'node:path'
 import { DSH_HOME_ENV } from '@deepseek-ai/dsh-home-paths'
 import { DSH_HOME_ENV } from '@deepseek-ai/dsh-home-paths'
+import type { DshConfigTreeDeclaration } from '@deepseek-ai/dsh-package-manifest'
 import type { ConfigTree, ImageTree, PackResult } from './pack.ts'
 import type { ConfigTree, ImageTree, PackResult } from './pack.ts'
 
 
 /**
 /**
@@ -96,13 +97,6 @@ export function composeProfile(repoRoot: string, profile: string): string {
   }
   }
 }
 }
 
 
-/** One `dsh.configTrees` declaration entry, validated field by field. */
-interface ConfigTreeDeclaration {
-  mount: string
-  path: string
-  scanRoster?: boolean
-}
-
 /**
 /**
  * Config trees the CLI package declares for deployment images
  * Config trees the CLI package declares for deployment images
  * (`dsh.configTrees` in its package.json): `path` is relative to the CLI
  * (`dsh.configTrees` in its package.json): `path` is relative to the CLI
@@ -125,7 +119,7 @@ export function configTrees(repoRoot: string): ConfigTree[] {
   }
   }
   const mounts = new Set<string>()
   const mounts = new Set<string>()
   return declared.map((entry, index) => {
   return declared.map((entry, index) => {
-    const tree = entry as Partial<ConfigTreeDeclaration> | null
+    const tree = entry as Partial<DshConfigTreeDeclaration> | null
     const at = `${CLI_PACKAGE} dsh.configTrees[${String(index)}]`
     const at = `${CLI_PACKAGE} dsh.configTrees[${String(index)}]`
     if (tree === null || typeof tree !== 'object'
     if (tree === null || typeof tree !== 'object'
       || typeof tree.mount !== 'string' || tree.mount === ''
       || typeof tree.mount !== 'string' || tree.mount === ''

+ 3 - 0
packages/experimental/webworker-packer/tsconfig.json

@@ -19,6 +19,9 @@
     },
     },
     {
     {
       "path": "../../util/home-paths"
       "path": "../../util/home-paths"
+    },
+    {
+      "path": "../../util/package-manifest"
     }
     }
   ]
   ]
 }
 }

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

@@ -1484,13 +1484,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       },
       {
       {
         signature: 'userDisabledRowIds(): Set<string>',
         signature: 'userDisabledRowIds(): Set<string>',
-        description: 'Row ids the user patch layers disable with a literal `disabled: true`. A `!!js` gate in a user file is a condition, not a user decision, and is left to the composition.',
+        description: 'Row ids the user patch layers disable with a literal `disabled: true`. A `!!js` gate in a user file stays an expression node when read from disk, so it is a condition, not a user decision, and is left to the composition.',
         parameters: [],
         parameters: [],
         returns: 'the ids, re-read from disk on every call.',
         returns: 'the ids, re-read from disk on every call.',
       },
       },
       {
       {
         signature: 'async recompose(options: { reloadBundles?: boolean } = {}): Promise<void>',
         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. The rows the stack left out replace the failure registry\'s conflict records once the update holds.',
+        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 candidate profile, its ownership, and its conflicts become the committed composition only once the update holds; until then, and after a rejection, `current`, `layers`, `originOf`, and `conflicts` keep describing the running tree.',
         parameters: [{ name: 'options', description: '`reloadBundles` re-reads the profile manifest first, so a bundle enabled or installed since boot joins the stack.' }],
         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.'],
         throws: ['when the root include is not mounted, or the Loader rejected the update.'],
       },
       },

+ 19 - 5
packages/host/plugin-inventory/src/index.ts

@@ -104,11 +104,7 @@ export class PluginInventoryGateway extends TypertRemoteService {
     // registry is its only record, and the list must still show it.
     // registry is its only record, and the list must still show it.
     for (const failure of failures?.list() ?? []) {
     for (const failure of failures?.list() ?? []) {
       if (listed.has(failure.entryId)) continue
       if (listed.has(failure.entryId)) continue
-      // 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)
+      const origin = runtime?.originOf(failure.rowId)
       entries.push({
       entries.push({
         entryId: pluginEntryId(failure.entryId),
         entryId: pluginEntryId(failure.entryId),
         moduleName: failure.moduleName,
         moduleName: failure.moduleName,
@@ -119,6 +115,24 @@ export class PluginInventoryGateway extends TypertRemoteService {
         failure: { stage: failure.stage, message: failure.message },
         failure: { stage: failure.stage, message: failure.message },
       })
       })
     }
     }
+    // A row the composition left out never reached the tree; the conflict
+    // names the layer that lost, so no lookup of the id's owner is needed.
+    if (runtime !== undefined) {
+      for (const conflict of runtime.conflicts) {
+        const version = runtime.layers.find(candidate => candidate.packageName === conflict.packageName)?.version
+        entries.push({
+          entryId: pluginEntryId(`conflict:${conflict.layer}:${conflict.rowId}`),
+          moduleName: conflict.moduleName,
+          enabled: true,
+          fiberPhase: 'failed',
+          trust: conflict.packageName === undefined ? 'builtin' : 'external',
+          ...conflict.packageName === undefined
+            ? {}
+            : { package: packageRef({ packageName: conflict.packageName, ...version === undefined ? {} : { version } }) },
+          failure: { stage: 'conflict', message: conflict.message },
+        })
+      }
+    }
     const presets = this.ctx.get('agentPresets')
     const presets = this.ctx.get('agentPresets')
     if (presets === undefined) return { entries }
     if (presets === undefined) return { entries }
     const agentPresets: AgentPresetPluginGroup[] = (await presets.compositionInventory()).map(
     const agentPresets: AgentPresetPluginGroup[] = (await presets.compositionInventory()).map(

+ 1 - 1
packages/host/plugin-inventory/src/types.ts

@@ -48,7 +48,7 @@ export interface PluginInventoryEntry {
   readonly package?: PluginPackageRef
   readonly package?: PluginPackageRef
   /** Present exactly when `enabled` is false. */
   /** Present exactly when `enabled` is false. */
   readonly disabledBy?: PluginDisabledBy
   readonly disabledBy?: PluginDisabledBy
-  /** Present for a row an isolated bundle failed to start. */
+  /** Present for a row an isolated bundle failed to start, or a row the composition left out. */
   readonly failure?: PluginFailure
   readonly failure?: PluginFailure
 }
 }
 
 

+ 22 - 14
packages/host/plugin-inventory/tests/inventory.spec.ts

@@ -97,7 +97,7 @@ describe('PluginInventoryGateway', () => {
     expect((await inventory.list()).entries.some(entry => entry.entryId === pendingId)).toBe(false)
     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 () => {
+  it('attributes rows to their bundle through the profile runtime and lists recorded failures and conflicts', async () => {
     const { ctx, inventory } = await harness()
     const { ctx, inventory } = await harness()
     // A bare Loader assigns ids; without a root include they are also the tree-wide ids.
     // A bare Loader assigns ids; without a root include they are also the tree-wide ids.
     const versioned = await ctx.loader.create({ name: 'cordis:active' })
     const versioned = await ctx.loader.create({ name: 'cordis:active' })
@@ -112,21 +112,19 @@ describe('PluginInventoryGateway', () => {
     ctx.provide('profileRuntime', {
     ctx.provide('profileRuntime', {
       originOf: (rowId: string) => origins.get(rowId),
       originOf: (rowId: string) => origins.get(rowId),
       userDisabledRowIds: () => new Set([off]),
       userDisabledRowIds: () => new Set([off]),
-    } as Partial<ProfileRuntime> as never)
+      layers: [{ packageName: 'late', version: '9.9.9' }],
+      conflicts: [
+        { rowId: 'tool', moduleName: 'late', layer: 'late', packageName: 'late', declaredBy: 'ext', message: 'row "tool" is already declared by ext' },
+        { rowId: 'mine', moduleName: 'twice', layer: '/p/cordis.patch.yml', declaredBy: 'ext', message: 'row "mine" is already declared by ext' },
+        { rowId: 'x', moduleName: 'gone/x', layer: 'gone', packageName: 'gone', declaredBy: 'gone', message: 'row "x" is declared twice by gone' },
+      ],
+    } as unknown as ProfileRuntime)
     const registry = ensurePluginFailures(ctx)
     const registry = ensurePluginFailures(ctx)
     registry.record({ entryId: 'gone', rowId: 'gone', moduleName: 'cordis:throws', groupId: 'bundle/ext', stage: 'apply', message: 'boom' })
     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.
     // 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: bare, rowId: bare, moduleName: 'cordis:active', groupId: 'bundle/ext', stage: 'import', message: 'stale' })
     // A failure the runtime cannot attribute belongs to no package.
     // A failure the runtime cannot attribute belongs to no package.
     registry.record({ entryId: 'orphan', rowId: 'orphan', moduleName: 'cordis:throws', groupId: 'bundle/gone', stage: 'apply', message: 'lost' })
     registry.record({ entryId: 'orphan', rowId: 'orphan', moduleName: 'cordis:throws', groupId: 'bundle/gone', stage: 'apply', message: 'lost' })
-    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()
     const { entries } = await inventory.list()
     expect(entries).toEqual([
     expect(entries).toEqual([
@@ -136,10 +134,20 @@ describe('PluginInventoryGateway', () => {
       // A failed row the tree no longer holds is attributed through the runtime.
       // 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' } },
       { entryId: 'gone', moduleName: 'cordis:throws', enabled: true, fiberPhase: 'failed', trust: 'external', package: { name: 'ext' }, failure: { stage: 'apply', message: 'boom' } },
       { entryId: 'orphan', moduleName: 'cordis:throws', enabled: true, fiberPhase: 'failed', trust: 'external', failure: { stage: 'apply', message: 'lost' } },
       { entryId: 'orphan', moduleName: 'cordis:throws', enabled: true, fiberPhase: 'failed', trust: 'external', failure: { stage: 'apply', message: 'lost' } },
-      // 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' } },
+      // A row the composition left out is listed from the runtime's conflicts, under the layer that lost.
+      {
+        entryId: 'conflict:late:tool', moduleName: 'late', enabled: true, fiberPhase: 'failed', trust: 'external',
+        package: { name: 'late', version: '9.9.9' }, failure: { stage: 'conflict', message: 'row "tool" is already declared by ext' },
+      },
+      {
+        entryId: 'conflict:/p/cordis.patch.yml:mine', moduleName: 'twice', enabled: true, fiberPhase: 'failed', trust: 'builtin',
+        failure: { stage: 'conflict', message: 'row "mine" is already declared by ext' },
+      },
+      // A bundle the layer list no longer names keeps its package, without a version.
+      {
+        entryId: 'conflict:gone:x', moduleName: 'gone/x', enabled: true, fiberPhase: 'failed', trust: 'external',
+        package: { name: 'gone' }, failure: { stage: 'conflict', message: 'row "x" is declared twice by gone' },
+      },
     ])
     ])
   })
   })
 
 

+ 2 - 0
packages/host/plugin-manager/package.json

@@ -53,6 +53,7 @@
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-agent-presets": "workspace:^",
     "@deepseek-ai/dsh-agent-presets": "workspace:^",
     "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@deepseek-ai/dsh-app-boot": "workspace:^",
+    "@deepseek-ai/dsh-package-manifest": "workspace:^",
     "@deepseek-ai/dsh-patch-file": "workspace:^",
     "@deepseek-ai/dsh-patch-file": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-util-values": "workspace:^"
     "@deepseek-ai/dsh-util-values": "workspace:^"
@@ -70,6 +71,7 @@
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-agent-presets": "workspace:^",
     "@deepseek-ai/dsh-agent-presets": "workspace:^",
     "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@deepseek-ai/dsh-app-boot": "workspace:^",
+    "@deepseek-ai/dsh-package-manifest": "workspace:^",
     "@deepseek-ai/dsh-patch-file": "workspace:^",
     "@deepseek-ai/dsh-patch-file": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-util-values": "workspace:^"
     "@deepseek-ai/dsh-util-values": "workspace:^"

+ 15 - 4
packages/host/plugin-manager/src/index.ts

@@ -41,11 +41,11 @@ import {
   resolveBundleDir,
   resolveBundleDir,
   resolveProfileLayer,
   resolveProfileLayer,
   writeProbeCache,
   writeProbeCache,
-  type BundleStage,
   type PluginProbe,
   type PluginProbe,
   type ProfileManifest,
   type ProfileManifest,
   type ProfileRuntime,
   type ProfileRuntime,
 } from '@deepseek-ai/dsh-app-boot'
 } from '@deepseek-ai/dsh-app-boot'
+import type { BundleStage } from '@deepseek-ai/dsh-package-manifest'
 import type {} from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-agent'
 import { mutatePatchFile, readPatchListFile, type PatchRow } from '@deepseek-ai/dsh-patch-file'
 import { mutatePatchFile, readPatchListFile, type PatchRow } from '@deepseek-ai/dsh-patch-file'
 import type { JsonValue } from '@deepseek-ai/dsh-util-values'
 import type { JsonValue } from '@deepseek-ai/dsh-util-values'
@@ -336,9 +336,7 @@ export class PluginManager extends TypertRemoteService {
     /* v8 ignore next -- the root include provides the registry on every boot; the guard answers its optional type */
     /* v8 ignore next -- the root include provides the registry on every boot; the guard answers its optional type */
     for (const failure of failures?.list() ?? []) {
     for (const failure of failures?.list() ?? []) {
       if (listed.has(failure.entryId)) continue
       if (listed.has(failure.entryId)) 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
+      if (runtime.originOf(failure.rowId)?.packageName !== name) continue
       rows.push({
       rows.push({
         entryId: failure.entryId,
         entryId: failure.entryId,
         rowId: failure.rowId,
         rowId: failure.rowId,
@@ -348,6 +346,19 @@ export class PluginManager extends TypertRemoteService {
         failure: { stage: failure.stage, message: failure.message },
         failure: { stage: failure.stage, message: failure.message },
       })
       })
     }
     }
+    // A row the composition left out never reached the tree; the runtime's
+    // conflicts name the bundle that lost, so nothing is looked up by id.
+    for (const conflict of runtime.conflicts) {
+      if (conflict.packageName !== name) continue
+      rows.push({
+        entryId: `conflict:${conflict.layer}:${conflict.rowId}`,
+        rowId: conflict.rowId,
+        moduleName: conflict.moduleName,
+        enabled: true,
+        phase: 'failed',
+        failure: { stage: 'conflict', message: conflict.message },
+      })
+    }
     return rows
     return rows
   }
   }
 
 

+ 3 - 4
packages/host/plugin-manager/tests/plugin-manager.spec.ts

@@ -16,7 +16,7 @@ import { afterEach, describe, expect, it } from 'vitest'
 import { Context, type Plugin } from '@deepseek-ai/cordis'
 import { Context, type Plugin } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import {
 import {
-  boot, composeProfileStack, recordRowConflicts, loadOptionalPatches, loadProfile, ProfileRuntime, readProbeCache, rootIncludeEntry,
+  boot, composeProfileStack, loadOptionalPatches, loadProfile, ProfileRuntime, readProbeCache, rootIncludeEntry,
   type ComposedStack, type Profile,
   type ComposedStack, type Profile,
 } from '@deepseek-ai/dsh-app-boot'
 } from '@deepseek-ai/dsh-app-boot'
 import { remoteMethods, RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { remoteMethods, RemoteError } from '@deepseek-ai/dsh-typert-protocol'
@@ -205,14 +205,13 @@ async function bootProfile(staged: StagedHome, internals: PluginManagerInternals
   contexts.push(ctx)
   contexts.push(ctx)
   await ctx.plugin(ProfileRuntime, {
   await ctx.plugin(ProfileRuntime, {
     profile,
     profile,
+    stack: composeFor(profile),
     installAnchor: staged.anchor,
     installAnchor: staged.anchor,
     loadProfile: load,
     loadProfile: load,
     compose: composeFor,
     compose: composeFor,
     rootEntry: () => rootIncludeEntry(ctx),
     rootEntry: () => rootIncludeEntry(ctx),
     readUserPatches: () => loadOptionalPatches(NAME, profile.patchPath) ?? [],
     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 {
   class TestManager extends PluginManager {
     constructor(context: Context, managerConfig: PluginManager['config']) {
     constructor(context: Context, managerConfig: PluginManager['config']) {
       super(context, managerConfig, internals)
       super(context, managerConfig, internals)
@@ -318,7 +317,7 @@ describe('PluginManager', () => {
       expect(two).toMatchObject({ status: 'failed', enabled: true })
       expect(two).toMatchObject({ status: 'failed', enabled: true })
       expect(two?.reason).toContain('already declared by ext-one')
       expect(two?.reason).toContain('already declared by ext-one')
       expect(two?.rows).toEqual([{
       expect(two?.rows).toEqual([{
-        entryId: 'conflict:bundle/ext-two:hello', rowId: 'hello', moduleName: 'cordis:good', enabled: true, phase: 'failed',
+        entryId: 'conflict:ext-two:hello', rowId: 'hello', moduleName: 'cordis:good', enabled: true, phase: 'failed',
         failure: { stage: 'conflict', message: 'row "hello" is already declared by ext-one' },
         failure: { stage: 'conflict', message: 'row "hello" is already declared by ext-one' },
       }])
       }])
     })
     })

+ 3 - 0
packages/host/plugin-manager/tsconfig.json

@@ -37,6 +37,9 @@
     },
     },
     {
     {
       "path": "../../core/agent"
       "path": "../../core/agent"
+    },
+    {
+      "path": "../../util/package-manifest"
     }
     }
   ]
   ]
 }
 }

+ 3 - 2
packages/preset/agent-presets/tests/overlay.spec.ts

@@ -235,7 +235,8 @@ describe('inventory', () => {
     expect(fromFile?.rows).toEqual([
     expect(fromFile?.rows).toEqual([
       { entryId: 'alpha', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'user' },
       { entryId: 'alpha', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'user' },
       { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'composition' },
       { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'composition' },
-      { entryId: 'user-gamma', moduleName: CONTRIBUTE, enabled: true, source: 'user' },
+      // An absolute path in a user row mounts as a file URL, like a `./` path anchored beside the file.
+      { entryId: 'user-gamma', moduleName: pathToFileURL(CONTRIBUTE).href, enabled: true, source: 'user' },
     ])
     ])
 
 
     await agentOn(ctx, 'sess-inventory', 'standard')
     await agentOn(ctx, 'sess-inventory', 'standard')
@@ -243,7 +244,7 @@ describe('inventory', () => {
     expect(mounted?.rows).toEqual([
     expect(mounted?.rows).toEqual([
       { entryId: 'alpha', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'user' },
       { entryId: 'alpha', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'user' },
       { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'composition' },
       { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'composition' },
-      { entryId: 'user-gamma', moduleName: CONTRIBUTE, enabled: true, source: 'user', fiberState: FiberState.ACTIVE },
+      { entryId: 'user-gamma', moduleName: pathToFileURL(CONTRIBUTE).href, enabled: true, source: 'user', fiberState: FiberState.ACTIVE },
     ])
     ])
   })
   })
 
 

+ 2 - 2
packages/util/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/util/README.md
 #   pnpm run verify-translation-pairing --write packages/util/README.md
-README.md: ab201cfbc33a51e1713804532f9c6d0a5003c19c
-README.zh.md: 751370c822a05da1b050d96bdcf9afbee1d0ea39
+README.md: 2e46d9d4573b31afa14d7f1380af106c391d19d6
+README.zh.md: c7d8b63dc3ee7e748e8a07a3d65315ff81d5476f

+ 1 - 0
packages/util/README.md

@@ -27,6 +27,7 @@ Each package provides one primitive; open a package page for how to use it.
 | Package | Role |
 | Package | Role |
 |---|---|
 |---|---|
 | [`brand/`](brand/README.md) | Nominal string types and their stateless constructor |
 | [`brand/`](brand/README.md) | Nominal string types and their stateless constructor |
+| [`package-manifest/`](package-manifest/README.md) | Shared TypeScript declarations for package manifests |
 | [`crypto/`](crypto/README.md) | Mints RFC 9562 v4 UUIDs from the cross-runtime `crypto.getRandomValues` primitive |
 | [`crypto/`](crypto/README.md) | Mints RFC 9562 v4 UUIDs from the cross-runtime `crypto.getRandomValues` primitive |
 | [`deque/`](deque/README.md) | Provides amortized constant-time queue operations with bounded vacant storage |
 | [`deque/`](deque/README.md) | Provides amortized constant-time queue operations with bounded vacant storage |
 | [`values/`](values/README.md) | Validates, snapshots, compares, and freezes lossless JSON-compatible values |
 | [`values/`](values/README.md) | Validates, snapshots, compares, and freezes lossless JSON-compatible values |

+ 1 - 0
packages/util/README.zh.md

@@ -27,6 +27,7 @@ kind: "package-group"
 | 包 | 职责 |
 | 包 | 职责 |
 |---|---|
 |---|---|
 | [`brand/`](brand/README.zh.md) | 提供名义字符串类型及其无状态构造函数 |
 | [`brand/`](brand/README.zh.md) | 提供名义字符串类型及其无状态构造函数 |
+| [`package-manifest/`](package-manifest/README.zh.md) | Package manifest 的共享 TypeScript 声明 |
 | [`crypto/`](crypto/README.zh.md) | 基于跨运行时 `crypto.getRandomValues` 原语生成 RFC 9562 v4 UUID |
 | [`crypto/`](crypto/README.zh.md) | 基于跨运行时 `crypto.getRandomValues` 原语生成 RFC 9562 v4 UUID |
 | [`deque/`](deque/README.zh.md) | 提供摊销常数时间的队列操作和有界空闲存储 |
 | [`deque/`](deque/README.zh.md) | 提供摊销常数时间的队列操作和有界空闲存储 |
 | [`values/`](values/README.zh.md) | 校验、创建快照、比较和冻结无损 JSON 兼容值 |
 | [`values/`](values/README.zh.md) | 校验、创建快照、比较和冻结无损 JSON 兼容值 |

+ 6 - 0
packages/util/package-manifest/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/util/package-manifest/README.md
+README.md: 87ecc78fbedd912b3b1136e05c680ca0a8bef293
+README.zh.md: 17f20bdf2e70656842046e2a3b8fa83f7151061b

+ 85 - 0
packages/util/package-manifest/README.md

@@ -0,0 +1,85 @@
+---
+description: "Shared TypeScript declarations for package.json.dsh metadata, usable by boot, client, build, and external packages."
+kind: "package-library"
+---
+
+# @deepseek-ai/dsh-package-manifest
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Use `DshManifest` to type a package's Harness metadata, or a member type such as `DshClientManifest` for one declaration. Boot, client, build, and external packages import the same types; each reader owns JSON validation and default resolution.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+Import from the package root. Use a development dependency when only checking your own source; use a production dependency if your published declarations reference these types.
+
+```ts
+import type { DshClientManifest, DshManifest } from '@deepseek-ai/dsh-package-manifest'
+
+const client: DshClientManifest = { platform: 'web' }
+const dsh: DshManifest = {
+  bundle: { patch: './cordis.patch.yml' },
+  client,
+}
+```
+
+`DshManifest` describes `bundle`, `profile`, `client`, `title`, `plugins`, `configTrees`, `sessionFormatMigration`, and `moduleFallback`, not the surrounding npm manifest. `moduleFallback` is launcher-generated metadata and is not an author configuration entry. TypeScript checks this object and erases `import type` during compilation; JSON files cannot import types, and this example does not write a `package.json`. See [`src/types.ts`](src/types.ts) for the declarations.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+The package root only re-exports declarations from [`src/types.ts`](src/types.ts). No runtime invariant companion is published because the package has no runtime state or independently observable relationships.
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+- [Profile launcher](../../boot/app-boot/README.md#profiles) — manifest loading and composition.
+- [Declaration ownership](../../../.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md) — scope and dependency rationale.
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as this package only exports types.
+
+#### KV Cache effect
+
+Type declarations add no model input, so provider cache reuse is unaffected.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- **Static typing only.** These declarations do not validate JSON, check file existence, or supply defaults. `configTrees` serves the experimental image packer, and `sessionFormatMigration` is discovered only for workspace migration packages; declaring them does not register external plugin behavior.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>

+ 85 - 0
packages/util/package-manifest/README.zh.md

@@ -0,0 +1,85 @@
+---
+description: "供启动器、客户端、构建工具和外部包共同使用的 package.json.dsh 元数据 TypeScript 声明。"
+kind: "package-library"
+---
+
+# @deepseek-ai/dsh-package-manifest
+
+[English](README.md) | 中文
+
+## 概述
+
+使用 `DshManifest` 为包的 Harness 元数据添加类型,也可用 `DshClientManifest` 等成员类型描述单项声明。启动器、客户端、构建工具和外部包导入同一组类型;各读取方负责 JSON 校验和默认值解析。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与后续工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+从包根导入类型。仅检查自己的源码时使用开发依赖;若发布的声明文件引用这些类型,则使用生产依赖。
+
+```ts
+import type { DshClientManifest, DshManifest } from '@deepseek-ai/dsh-package-manifest'
+
+const client: DshClientManifest = { platform: 'web' }
+const dsh: DshManifest = {
+  bundle: { patch: './cordis.patch.yml' },
+  client,
+}
+```
+
+`DshManifest` 描述 `bundle`、`profile`、`client`、`title`、`plugins`、`configTrees`、`sessionFormatMigration` 和 `moduleFallback`,不包含外层 npm manifest。`moduleFallback` 是启动器生成的元数据,不是作者配置项。TypeScript 检查该对象,并在编译时删除 `import type`;JSON 文件不能导入类型,此示例也不会写入 `package.json`。声明见 [`src/types.ts`](src/types.ts)。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<details>
+<summary>实现细节——点击展开</summary>
+
+包根仅重新导出 [`src/types.ts`](src/types.ts) 中的声明。本包不发布运行时不变量伴随模块,因为它没有运行时状态或可独立观察的关系。
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 进一步探索
+
+- [Profile 启动器](../../boot/app-boot/README.zh.md#profiles)——manifest 加载与组合。
+- [声明归属](../../../.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md)——范围与依赖依据。
+
+<a id="model-experience"></a>
+## 模型体验
+
+无,因为本包仅导出类型。
+
+#### KV Cache 影响
+
+类型声明不增加模型输入,因此不影响提供方的缓存复用。
+
+## 已知限制与后续工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- **仅提供静态类型。** 这些声明不校验 JSON、不检查文件存在性,也不提供默认值。`configTrees` 服务于实验性镜像打包器,`sessionFormatMigration` 仅从工作区迁移包中发现;声明它们不会注册外部插件行为。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者的工作上下文——点击展开</summary>
+
+无。
+
+</details>

+ 35 - 0
packages/util/package-manifest/package.json

@@ -0,0 +1,35 @@
+{
+  "name": "@deepseek-ai/dsh-package-manifest",
+  "description": "Shared type declarations for package.json.dsh configuration fields",
+  "version": "0.1.3-alpha.1",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/util/package-manifest"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts"
+  ],
+  "license": "MIT",
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^"
+  }
+}

+ 17 - 0
packages/util/package-manifest/src/index.ts

@@ -0,0 +1,17 @@
+/**
+ * Public package manifest types, with no runtime exports.
+ * @module @deepseek-ai/dsh-package-manifest
+ */
+
+export type {
+  BundleStage,
+  DshBundleManifest,
+  DshClientManifest,
+  DshConfigTreeDeclaration,
+  DshManifest,
+  DshModuleFallbackManifest,
+  DshPluginDeclaration,
+  DshProfileManifest,
+  DshSessionFormatMigrationManifest,
+  ProfilePatchReload,
+} from './types.ts'

+ 131 - 0
packages/util/package-manifest/src/types.ts

@@ -0,0 +1,131 @@
+/**
+ * Shared declarations for `package.json.dsh`.
+ * Each reader owns JSON validation and resolved defaults.
+ * @module @deepseek-ai/dsh-package-manifest/types
+ */
+
+/** The `dsh` property of an npm manifest; a package may declare several roles. */
+export interface DshManifest {
+  /** Bundle metadata consumed by the profile launcher. */
+  bundle?: DshBundleManifest
+  /** Profile metadata consumed by the profile launcher. */
+  profile?: DshProfileManifest
+  /** Client module loading and build metadata. */
+  client?: DshClientManifest
+  /** Display title of the package, read by the package probe. */
+  title?: string
+  /** Agent-plane modules the package declares addable to a composition, read by the package probe. */
+  plugins?: DshPluginDeclaration[]
+  /** Config directories consumed by the experimental deployment-image packer. */
+  configTrees?: DshConfigTreeDeclaration[]
+  /** Adjacent Session migration metadata consumed by the workspace catalog generator. */
+  sessionFormatMigration?: DshSessionFormatMigrationManifest
+  /**
+   * Launcher-generated module proxy metadata, not an author configuration entry.
+   * @internal
+   */
+  moduleFallback?: DshModuleFallbackManifest
+}
+
+/**
+ * How an external bundle joins the tree. `runtime` (the default) mounts its
+ * rows in a contained group: a failing row is isolated and recorded, and a
+ * row id another layer owns leaves the bundle out. `boot` mounts them like
+ * built-in rows: they claim ids first, and a failure stops the process.
+ */
+export type BundleStage = 'boot' | 'runtime'
+
+/** One agent-plane module a package declares addable to a composition. */
+export interface DshPluginDeclaration {
+  /** The module's subpath or bare specifier, importable from the package. */
+  name: string
+  /** Display title. */
+  title?: string
+  /** Default row config. */
+  config?: unknown
+}
+
+/** The configuration layer exported by a bundle package. */
+export interface DshBundleManifest {
+  /** Patch file path relative to the declaring package root. */
+  patch: string
+  /** Mount stage the bundle author asks for; the profile's `stages` overrides it. */
+  stage?: BundleStage
+}
+
+/** The bundle composition declared by a profile directory. */
+export interface DshProfileManifest {
+  /** Ordered bundle layer list, using installed package names. */
+  bundles?: string[]
+  /** User patch lifecycle; omitted means `live` for custom profiles. */
+  patchReload?: ProfilePatchReload
+  /** Deployer overrides of each external bundle's mount stage, by package name. */
+  stages?: Record<string, BundleStage>
+  /**
+   * Installed packages treated as built-in: not wrapped and fatal on failure.
+   * For first-party packages linked into a profile during development, where
+   * provenance alone would classify them external.
+   */
+  firstParty?: string[]
+}
+
+/** Whether user patch files reload while a profile remains active or apply only at startup. */
+export type ProfilePatchReload = 'live' | 'startup'
+
+/** Client module declaration read by client-modules and the client build. */
+export interface DshClientManifest {
+  /** Client platform identifier; the Web consumer selects `web`. */
+  platform: string
+  /** Informational package-name dependencies, not Cordis service injection. */
+  inject?: string[]
+  /** Boot phase-one registration barrier; absent means the shared application batch. */
+  immediately?: boolean
+  /**
+   * Exact module-table requests beyond the implicit client baseline, including
+   * subpaths such as `<pkg>/client`; absent means baseline externals only.
+   * Type-only imports are erased and create no module request.
+   */
+  external?: string[]
+}
+
+/** One config directory read from the CLI package by the experimental image packer. */
+export interface DshConfigTreeDeclaration {
+  /** Non-empty destination path in the image; mount values must be unique. */
+  mount: string
+  /** Non-empty source directory path relative to the declaring package root. */
+  path: string
+  /** Include the directory's YAML plugin rows in the package roster; absent means false. */
+  scanRoster?: boolean
+}
+
+/**
+ * Adjacent Session migration metadata declared on disk. The catalog generator
+ * discovers only packages/session/session-format-vN-to-vN+1, not external plugins.
+ */
+export interface DshSessionFormatMigrationManifest {
+  /** Non-negative safe integer source version; negative zero is rejected. */
+  from: number
+  /** Non-negative safe integer target version, exactly from + 1. */
+  to: number
+  /** Non-empty package export path, such as `.` or `./migration`. */
+  export: string
+  /** Non-empty named export of the migration implementation. */
+  migration: string
+  /** Non-empty named export of the source version codec. */
+  sourceCodec: string
+  /** Non-empty named export of the target version codec. */
+  targetCodec: string
+  /** Non-empty named export of the target header validator. */
+  targetHeaderValidator: string
+  /** Non-empty named export of the target version restorer. */
+  targetRestorer: string
+}
+
+/**
+ * Metadata generated and read by the launcher's module fallback proxies.
+ * @internal
+ */
+export interface DshModuleFallbackManifest {
+  /** Package export subpaths mapped to resolved target file URLs. */
+  targets: Record<string, string>
+}

+ 11 - 0
packages/util/package-manifest/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types",
+    "types": []
+  },
+  "include": [
+    "src"
+  ]
+}

+ 7 - 7
packages/util/patch-file/src/index.ts

@@ -16,7 +16,7 @@
  */
  */
 
 
 import { mkdir, readFile } from 'node:fs/promises'
 import { mkdir, readFile } from 'node:fs/promises'
-import { dirname, resolve } from 'node:path'
+import { dirname, isAbsolute, resolve } from 'node:path'
 import { pathToFileURL } from 'node:url'
 import { pathToFileURL } from 'node:url'
 import * as yaml from 'js-yaml'
 import * as yaml from 'js-yaml'
 import { Document, isMap, isSeq, parseDocument, YAMLMap, YAMLSeq } from 'yaml'
 import { Document, isMap, isSeq, parseDocument, YAMLMap, YAMLSeq } from 'yaml'
@@ -25,18 +25,18 @@ import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
 import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
 
 
 /**
 /**
- * Resolve relative plugin paths in one patch list's `insert` rows against the
- * file's own directory, without changing assertion names. A row naming
- * `./plugin.js` means the file beside the patch file, wherever the Loader's
- * root happens to be.
+ * Convert inserted filesystem paths in one patch list's `insert` rows to file
+ * URLs: an absolute path as it is, a `./` or `../` path anchored beside the
+ * patch file, wherever the Loader's root happens to be. Assertion names on
+ * id-targeted patches stay literal.
  * @param patches - the parsed patch list, mutated in place.
  * @param patches - the parsed patch list, mutated in place.
- * @param file - the patch file's path, whose directory anchors the names.
+ * @param file - the patch file's path, whose directory anchors relative names.
  * @returns the same list.
  * @returns the same list.
  */
  */
 export function anchorInsertedPluginNames(patches: PatchOptions[], file: string): PatchOptions[] {
 export function anchorInsertedPluginNames(patches: PatchOptions[], file: string): PatchOptions[] {
   const base = dirname(resolve(file))
   const base = dirname(resolve(file))
   const visit = (entry: EntryOptions): void => {
   const visit = (entry: EntryOptions): void => {
-    if (typeof entry.name === 'string' && (entry.name.startsWith('./') || entry.name.startsWith('../'))) {
+    if (typeof entry.name === 'string' && (isAbsolute(entry.name) || entry.name.startsWith('./') || entry.name.startsWith('../'))) {
       entry.name = pathToFileURL(resolve(base, entry.name)).href
       entry.name = pathToFileURL(resolve(base, entry.name)).href
     }
     }
     if (entry.group && Array.isArray(entry.config)) entry.config.forEach(visit)
     if (entry.group && Array.isArray(entry.config)) entry.config.forEach(visit)

+ 21 - 0
pnpm-lock.yaml

@@ -16,6 +16,9 @@ importers:
 
 
   .:
   .:
     devDependencies:
     devDependencies:
+      '@deepseek-ai/dsh-package-manifest':
+        specifier: workspace:^
+        version: link:packages/util/package-manifest
       '@deepseek-ai/dsh-tool-session-query':
       '@deepseek-ai/dsh-tool-session-query':
         specifier: workspace:^
         specifier: workspace:^
         version: link:packages/session-query/tool-session-query
         version: link:packages/session-query/tool-session-query
@@ -982,6 +985,9 @@ importers:
       '@deepseek-ai/dsh-atomic-write':
       '@deepseek-ai/dsh-atomic-write':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../util/atomic-write
         version: link:../../util/atomic-write
+      '@deepseek-ai/dsh-package-manifest':
+        specifier: workspace:^
+        version: link:../../util/package-manifest
       '@deepseek-ai/dsh-patch-file':
       '@deepseek-ai/dsh-patch-file':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../util/patch-file
         version: link:../../util/patch-file
@@ -1878,6 +1884,9 @@ importers:
       '@deepseek-ai/dsh-invariants':
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../runtime-diagnostics/invariants
         version: link:../../runtime-diagnostics/invariants
+      '@deepseek-ai/dsh-package-manifest':
+        specifier: workspace:^
+        version: link:../../util/package-manifest
 
 
   packages/client/store:
   packages/client/store:
     dependencies:
     dependencies:
@@ -4937,6 +4946,9 @@ importers:
       '@deepseek-ai/cordis':
       '@deepseek-ai/cordis':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../../vendor/cordis
         version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-package-manifest':
+        specifier: workspace:^
+        version: link:../../util/package-manifest
       '@types/js-yaml':
       '@types/js-yaml':
         specifier: ^4.0.9
         specifier: ^4.0.9
         version: 4.0.9
         version: 4.0.9
@@ -5923,6 +5935,9 @@ importers:
       '@deepseek-ai/dsh-app-boot':
       '@deepseek-ai/dsh-app-boot':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../boot/app-boot
         version: link:../../boot/app-boot
+      '@deepseek-ai/dsh-package-manifest':
+        specifier: workspace:^
+        version: link:../../util/package-manifest
       '@deepseek-ai/dsh-patch-file':
       '@deepseek-ai/dsh-patch-file':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../util/patch-file
         version: link:../../util/patch-file
@@ -9514,6 +9529,12 @@ importers:
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../../vendor/cordis
         version: link:../../../vendor/cordis
 
 
+  packages/util/package-manifest:
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+
   packages/util/patch-file:
   packages/util/patch-file:
     dependencies:
     dependencies:
       '@deepseek-ai/dsh-atomic-write':
       '@deepseek-ai/dsh-atomic-write':

+ 2 - 0
scripts/check-workspace-constraints.ts

@@ -166,6 +166,8 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
   // resolve at install time, before the build produces lib/bin.js.
   // resolve at install time, before the build produces lib/bin.js.
   '@deepseek-ai/dsh-experimental-webworker-packer': ['bin.js', 'lib/repository-*.js'],
   '@deepseek-ai/dsh-experimental-webworker-packer': ['bin.js', 'lib/repository-*.js'],
   '@deepseek-ai/dsh-subprocess-local': ['scripts/ensure-spawn-helper.mjs'],
   '@deepseek-ai/dsh-subprocess-local': ['scripts/ensure-spawn-helper.mjs'],
+  // The package probe's child process entry, path-loaded beside the lib.
+  '@deepseek-ai/dsh-app-boot': ['lib/probe-child.js'],
 }
 }
 
 
 function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean {
 function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean {

+ 1 - 0
scripts/doc-standard.spec.ts

@@ -85,6 +85,7 @@ const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
   'packages/util/launch-environment': 'Zero-dependency environment resolver.',
   'packages/util/launch-environment': 'Zero-dependency environment resolver.',
   'packages/util/native-command': 'Host-side subprocess runner utility.',
   'packages/util/native-command': 'Host-side subprocess runner utility.',
   'packages/util/output-retention': 'Zero-dependency retention utility.',
   'packages/util/output-retention': 'Zero-dependency retention utility.',
+  'packages/util/package-manifest': 'Shared package manifest declarations with type-only exports.',
   'packages/util/time': 'Zero-dependency time-zone canonicalization utility.',
   'packages/util/time': 'Zero-dependency time-zone canonicalization utility.',
   'packages/util/timeout': 'Zero-dependency timeout utility.',
   'packages/util/timeout': 'Zero-dependency timeout utility.',
   'packages/util/values': 'Stateless lossless-JSON and immutable-value helpers.',
   'packages/util/values': 'Stateless lossless-JSON and immutable-value helpers.',

+ 4 - 10
scripts/gen-session-format-catalog.ts

@@ -3,21 +3,15 @@
 import { globSync, readFileSync, writeFileSync } from 'node:fs'
 import { globSync, readFileSync, writeFileSync } from 'node:fs'
 import { resolve } from 'node:path'
 import { resolve } from 'node:path'
 import { pathToFileURL } from 'node:url'
 import { pathToFileURL } from 'node:url'
+import type { DshSessionFormatMigrationManifest } from '@deepseek-ai/dsh-package-manifest'
 
 
 const root = resolve(import.meta.dirname, '..')
 const root = resolve(import.meta.dirname, '..')
 const OUT = 'packages/session/session-format-catalog/src/generated.ts'
 const OUT = 'packages/session/session-format-catalog/src/generated.ts'
 
 
-/** One adjacent migration declaration read from a workspace manifest. */
-export interface SessionFormatMigrationManifest {
+/** Validated adjacent migration metadata with its resolved package import path. */
+export interface SessionFormatMigrationManifest extends Readonly<Omit<DshSessionFormatMigrationManifest, 'export'>> {
   readonly packageName: string
   readonly packageName: string
   readonly importPath: string
   readonly importPath: string
-  readonly from: number
-  readonly to: number
-  readonly migration: string
-  readonly sourceCodec: string
-  readonly targetCodec: string
-  readonly targetHeaderValidator: string
-  readonly targetRestorer: string
 }
 }
 
 
 interface RawManifest {
 interface RawManifest {
@@ -78,7 +72,7 @@ export function collectSessionFormatMigrations(
     if (metadata === undefined) {
     if (metadata === undefined) {
       throw new Error(`gen-session-format-catalog: ${rel} lacks dsh.sessionFormatMigration`)
       throw new Error(`gen-session-format-catalog: ${rel} lacks dsh.sessionFormatMigration`)
     }
     }
-    const allowed = new Set([
+    const allowed: ReadonlySet<string> = new Set<keyof DshSessionFormatMigrationManifest>([
       'from', 'to', 'export', 'migration', 'sourceCodec', 'targetCodec',
       'from', 'to', 'export', 'migration', 'sourceCodec', 'targetCodec',
       'targetHeaderValidator', 'targetRestorer',
       'targetHeaderValidator', 'targetRestorer',
     ])
     ])

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -59,6 +59,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/util/crypto': { kind: 'indirect', reason: 'Pure identifier minting; the ids consumers mint with it never enter prompts as semantic content.' },
   'packages/util/crypto': { kind: 'indirect', reason: 'Pure identifier minting; the ids consumers mint with it never enter prompts as semantic content.' },
   'packages/util/deque': { kind: 'none', reason: 'In-process collection primitive; registers nothing model-facing.' },
   'packages/util/deque': { kind: 'none', reason: 'In-process collection primitive; registers nothing model-facing.' },
   'packages/util/patch-file': { kind: 'none', reason: 'Composition patch-file parser and writer; the rows those files name own every model-facing registration.' },
   'packages/util/patch-file': { kind: 'none', reason: 'Composition patch-file parser and writer; the rows those files name own every model-facing registration.' },
+  'packages/util/package-manifest': { kind: 'none', reason: 'Type declarations only; registers nothing model-facing.' },
   'packages/util/time': { kind: 'indirect', reason: 'Pure zone validation; the consumer that records a canonical zone owns the model-visible line derived from it.' },
   'packages/util/time': { kind: 'indirect', reason: 'Pure zone validation; the consumer that records a canonical zone owns the model-visible line derived from it.' },
   'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' },
   'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' },
   'packages/llm/deepseek-llm-api-extensions': { kind: 'indirect', reason: 'The registry contributes model-hidden provider fields; dsh-llm-deepseek owns their wire placement.' },
   'packages/llm/deepseek-llm-api-extensions': { kind: 'indirect', reason: 'The registry contributes model-hidden provider fields; dsh-llm-deepseek owns their wire placement.' },

+ 1 - 0
tsconfig.base.json

@@ -311,6 +311,7 @@
       "@deepseek-ai/dsh-message-feedback": ["./packages/feedback/message-feedback/src"],
       "@deepseek-ai/dsh-message-feedback": ["./packages/feedback/message-feedback/src"],
       "@deepseek-ai/dsh-native-command": ["./packages/util/native-command/src"],
       "@deepseek-ai/dsh-native-command": ["./packages/util/native-command/src"],
       "@deepseek-ai/dsh-output-retention": ["./packages/util/output-retention/src"],
       "@deepseek-ai/dsh-output-retention": ["./packages/util/output-retention/src"],
+      "@deepseek-ai/dsh-package-manifest": ["./packages/util/package-manifest/src"],
       "@deepseek-ai/dsh-patch-file": ["./packages/util/patch-file/src"],
       "@deepseek-ai/dsh-patch-file": ["./packages/util/patch-file/src"],
       "@deepseek-ai/dsh-permission-presets": ["./packages/interaction/permission-presets/src"],
       "@deepseek-ai/dsh-permission-presets": ["./packages/interaction/permission-presets/src"],
       "@deepseek-ai/dsh-permission-presets/invariant": ["./packages/interaction/permission-presets/src/invariant.ts"],
       "@deepseek-ai/dsh-permission-presets/invariant": ["./packages/interaction/permission-presets/src/invariant.ts"],

+ 1 - 0
tsconfig.host.json

@@ -132,6 +132,7 @@
     { "path": "./vendor/hmr" },
     { "path": "./vendor/hmr" },
     { "path": "./vendor/logger-console" },
     { "path": "./vendor/logger-console" },
     { "path": "./packages/util/brand" },
     { "path": "./packages/util/brand" },
+    { "path": "./packages/util/package-manifest" },
     { "path": "./packages/util/http-proxy" },
     { "path": "./packages/util/http-proxy" },
     { "path": "./packages/util/launch-environment" },
     { "path": "./packages/util/launch-environment" },
     { "path": "./packages/util/native-command" },
     { "path": "./packages/util/native-command" },

+ 2 - 0
vendor/README.md

@@ -49,6 +49,8 @@ Keep this log exhaustive — every divergence from upstream must be listed.
 17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).
 17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).
 18. **Entry `disabled` interpolation in `loader/src/config/entry.ts`**: a `disabled: !!js` expression evaluates against the loader context at every mount decision; the raw node stays in the options, so write-back keeps the `!!js` form. `disabled` is the only interpolated metadata field. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts` and `apps/cli/tests/windows-shell.spec.ts`.
 18. **Entry `disabled` interpolation in `loader/src/config/entry.ts`**: a `disabled: !!js` expression evaluates against the loader context at every mount decision; the raw node stays in the options, so write-back keeps the `!!js` form. `disabled` is the only interpolated metadata field. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts` and `apps/cli/tests/windows-shell.spec.ts`.
 19. **`loader/src/internal.ts` runtime shape detection**: `ModuleLoader.fromInternal()` classifies the internal loader by which module-job API it owns — `getOrCreateModuleJob` for v2, `getModuleJobForImport` for v1 — instead of by Node major version. Upstream tags every major `>= 24` as v2, but the v2 interface arrived in Node 24.12.0, so 24.0–24.11.1 report major 24 while still carrying the v1 loader; consumers then called `resolveSync` with reversed parameters and every call threw. `dsh web` served an empty client graph (`__DSH_BOOT__.entries: []`) and HMR partial reload resolved no entry URL, both behind swallowed or warn-level errors. Arity cannot discriminate the two shapes, because each reports `resolveSync.length === 2`. A loader owning neither API is left unclassified rather than guessed, so consumers take their documented no-internals path. Covered on the `node-compat` Node version matrix, which pins 24.9 for the mistagged range.
 19. **`loader/src/internal.ts` runtime shape detection**: `ModuleLoader.fromInternal()` classifies the internal loader by which module-job API it owns — `getOrCreateModuleJob` for v2, `getModuleJobForImport` for v1 — instead of by Node major version. Upstream tags every major `>= 24` as v2, but the v2 interface arrived in Node 24.12.0, so 24.0–24.11.1 report major 24 while still carrying the v1 loader; consumers then called `resolveSync` with reversed parameters and every call threw. `dsh web` served an empty client graph (`__DSH_BOOT__.entries: []`) and HMR partial reload resolved no entry URL, both behind swallowed or warn-level errors. Arity cannot discriminate the two shapes, because each reports `resolveSync.length === 2`. A loader owning neither API is left unclassified rather than guessed, so consumers take their documented no-internals path. Covered on the `node-compat` Node version matrix, which pins 24.9 for the mistagged range.
+20. **`loader/src/config/entry.ts` typed update errors**: the per-row `failed to <stage> loader entry` wrapper is an exported `EntryUpdateError` carrying its `stage`, so a consumer that classifies a row failure reads the field instead of parsing the message. The message text is unchanged. Covered by `packages/boot/app-boot/tests/contained-group.spec.ts`.
+21. **`include/src/index.ts` detached inserts**: `applyEntryPatches` clones inserted rows before adding them to the list, so a later patch that inserts into or overrides a row an earlier patch inserted mutates the copy and applying one patch list twice yields the same tree — the Loader re-applies the previous include config when it rolls a rejected update back, and an aliased insert row accumulated its children on every application. Covered by `packages/boot/app-boot/tests/external-bundles.spec.ts`.
 
 
 ## Sync procedure
 ## Sync procedure
 
 

+ 12 - 6
vendor/include/src/index.ts

@@ -47,9 +47,14 @@ function retryableWriteError(error: unknown): boolean {
  * is never mutated and the result is always detached from it (even with no
  * is never mutated and the result is always detached from it (even with no
  * patches): patching or mounting shared entry objects would bake earlier
  * patches): patching or mounting shared entry objects would bake earlier
  * values into the cached parse, so repeated application (config hot-reloads)
  * values into the cached parse, so repeated application (config hot-reloads)
- * could never revert a removed or changed patch. Inserted entries are indexed
- * as they are added, so a later patch in the same list can target a row an
- * earlier patch inserted. A patch that matches nothing warns and is skipped.
+ * could never revert a removed or changed patch. Inserted rows are cloned
+ * before they join the list for the same reason: a later patch that inserts
+ * into or overrides a row an earlier patch inserted mutates the copy, so
+ * applying one patch list twice (the Loader rolling a rejected update back to
+ * the previous one) yields the same tree each time. Inserted entries are
+ * indexed as they are added, so a later patch in the same list can target a
+ * row an earlier patch inserted. A patch that matches nothing warns and is
+ * skipped.
  * @param data - the parsed entry list (JSON-safe plain data).
  * @param data - the parsed entry list (JSON-safe plain data).
  * @param patches - the patch list to apply, in order.
  * @param patches - the patch list to apply, in order.
  * @param warn - sink for skipped-patch diagnostics (printf-style, `%C` = code).
  * @param warn - sink for skipped-patch diagnostics (printf-style, `%C` = code).
@@ -78,6 +83,7 @@ export function applyEntryPatches(
     const { id, insert, name, ...overrides } = patch
     const { id, insert, name, ...overrides } = patch
 
 
     if (insert) {
     if (insert) {
+      const inserted = structuredClone(insert)
       if (id) {
       if (id) {
         const target = entryMap.get(id)
         const target = entryMap.get(id)
         if (!target) {
         if (!target) {
@@ -89,16 +95,16 @@ export function applyEntryPatches(
           continue
           continue
         }
         }
         if (!Array.isArray(target.config)) target.config = []
         if (!Array.isArray(target.config)) target.config = []
-        target.config.push(...insert)
+        target.config.push(...inserted)
       } else {
       } else {
-        data.push(...insert)
+        data.push(...inserted)
       }
       }
       // Index what this patch added so a LATER patch in the same list can
       // Index what this patch added so a LATER patch in the same list can
       // target it. Patch lists compose one layer per source (each bundle
       // target it. Patch lists compose one layer per source (each bundle
       // layer, then the user's, then `--patch` overlays), and a layer must be
       // layer, then the user's, then `--patch` overlays), and a layer must be
       // able to configure or disable a row an earlier layer inserted; without
       // able to configure or disable a row an earlier layer inserted; without
       // this, inserted rows were silently unpatchable.
       // this, inserted rows were silently unpatchable.
-      buildMap(insert)
+      buildMap(inserted)
       continue
       continue
     }
     }
 
 

+ 13 - 3
vendor/loader/src/config/entry.ts

@@ -21,9 +21,19 @@ export interface EntryOptions {
   inject?: Inject | null
   inject?: Inject | null
 }
 }
 
 
-function updateError(stage: 'import' | 'dispose' | 'apply' | 'rollback', options: EntryOptions, cause: unknown) {
-  const detail = cause instanceof Error ? cause.message : String(cause)
-  return new Error(`failed to ${stage} loader entry ${options.id} (${options.name}): ${detail}`, { cause })
+/** The step of an entry update that failed. */
+export type EntryUpdateStage = 'import' | 'dispose' | 'apply' | 'rollback'
+
+/** The failure of one entry update, carrying the step that failed so callers need not parse the message. */
+export class EntryUpdateError extends Error {
+  constructor(readonly stage: EntryUpdateStage, options: EntryOptions, cause: unknown) {
+    const detail = cause instanceof Error ? cause.message : String(cause)
+    super(`failed to ${stage} loader entry ${options.id} (${options.name}): ${detail}`, { cause })
+  }
+}
+
+function updateError(stage: EntryUpdateStage, options: EntryOptions, cause: unknown) {
+  return new EntryUpdateError(stage, options, cause)
 }
 }
 
 
 function takeEntries(object: {}, keys: string[]) {
 function takeEntries(object: {}, keys: string[]) {

+ 2 - 0
vitest.config.ts

@@ -210,6 +210,8 @@ export default defineConfig({
         'packages/*/*/src/types.ts',
         'packages/*/*/src/types.ts',
         'packages/*/*/src/bin.ts',
         'packages/*/*/src/bin.ts',
         'packages/*/*/src/worker.ts',
         'packages/*/*/src/worker.ts',
+        // The package probe's child entry runs only as a spawned process; probe.spec drives it.
+        'packages/boot/app-boot/src/probe-child.ts',
         // Dynamic Host/Client composition is covered by its focused lifecycle
         // Dynamic Host/Client composition is covered by its focused lifecycle
         // tests and assembled application checks rather than per-file coverage.
         // tests and assembled application checks rather than per-file coverage.
         'packages/self-modification/*/src/**/*.{ts,tsx}',
         'packages/self-modification/*/src/**/*.{ts,tsx}',