فهرست منبع

refactor(boot): use native entry diagnostics and static plugin declarations

Yichen Jiang 1 ماه پیش
والد
کامیت
1144cf1e8e
54فایلهای تغییر یافته به همراه1031 افزوده شده و 2270 حذف شده
  1. 2 2
      .agents/notes/archived/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml
  2. 1 0
      .agents/notes/archived/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md
  3. 1 0
      .agents/notes/archived/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md
  4. 2 2
      .agents/notes/archived/architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml
  5. 1 0
      .agents/notes/archived/architecture/2026-09-04-external-bundles-as-contained-groups.md
  6. 1 0
      .agents/notes/archived/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md
  7. 6 0
      .agents/notes/archived/manifest.json
  8. 6 0
      .agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.i18n.yaml
  9. 41 0
      .agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md
  10. 41 0
      .agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md
  11. 1 1
      apps/cli/src/dump-config.ts
  12. 10 18
      apps/cli/src/profile-boot.ts
  13. 2 2
      docs/subsystems/core.i18n.yaml
  14. 15 15
      docs/subsystems/core.md
  15. 15 15
      docs/subsystems/core.zh.md
  16. 2 2
      packages/boot/app-boot/README.i18n.yaml
  17. 12 12
      packages/boot/app-boot/README.md
  18. 12 12
      packages/boot/app-boot/README.zh.md
  19. 0 1
      packages/boot/app-boot/package.json
  20. 20 12
      packages/boot/app-boot/src/compose-stack.ts
  21. 0 223
      packages/boot/app-boot/src/contained-group.ts
  22. 114 0
      packages/boot/app-boot/src/entry-issues.ts
  23. 24 135
      packages/boot/app-boot/src/external-bundles.ts
  24. 27 166
      packages/boot/app-boot/src/index.ts
  25. 127 0
      packages/boot/app-boot/src/package-metadata.ts
  26. 1 1
      packages/boot/app-boot/src/patch-rows.ts
  27. 0 50
      packages/boot/app-boot/src/probe-child.ts
  28. 0 63
      packages/boot/app-boot/src/probe-report.ts
  29. 0 475
      packages/boot/app-boot/src/probe.ts
  30. 38 34
      packages/boot/app-boot/src/profile-runtime.ts
  31. 1 1
      packages/boot/app-boot/src/profile.ts
  32. 27 16
      packages/boot/app-boot/tests/app-boot.spec.ts
  33. 6 7
      packages/boot/app-boot/tests/compose-stack.spec.ts
  34. 0 315
      packages/boot/app-boot/tests/contained-group.spec.ts
  35. 211 0
      packages/boot/app-boot/tests/entry-issues.spec.ts
  36. 43 223
      packages/boot/app-boot/tests/external-bundles.spec.ts
  37. 116 0
      packages/boot/app-boot/tests/package-metadata.spec.ts
  38. 0 361
      packages/boot/app-boot/tests/probe.spec.ts
  39. 10 9
      packages/boot/app-boot/tests/profile-runtime.spec.ts
  40. 0 12
      packages/boot/app-boot/tsdown.config.ts
  41. 1 0
      packages/bundle/web-app/tests/web-app.spec.ts
  42. 20 5
      packages/extensions/tool-cordis/src/api-catalog.ts
  43. 2 2
      packages/host/plugin-inventory/README.i18n.yaml
  44. 4 4
      packages/host/plugin-inventory/README.md
  45. 4 4
      packages/host/plugin-inventory/README.zh.md
  46. 7 25
      packages/host/plugin-inventory/src/index.ts
  47. 3 3
      packages/host/plugin-inventory/src/types.ts
  48. 15 15
      packages/host/plugin-inventory/tests/inventory.spec.ts
  49. 7 7
      packages/util/package-manifest/src/types.ts
  50. 0 2
      scripts/check-workspace-constraints.ts
  51. 1 1
      scripts/gen-cordis-catalog.ts
  52. 2 2
      vendor/README.md
  53. 1 2
      vendor/include/src/index.ts
  54. 28 13
      vendor/loader/src/config/entry.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml → .agents/notes/archived/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;
 # 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
-2026-09-04-boot-scoped-fail-loud-and-package-probe.md: 015d822cfaf9e933654f19cf68fb16b95a7625d8
-2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: d94d718735358ad2959ddb2a1bca9c9920d353b2
+2026-09-04-boot-scoped-fail-loud-and-package-probe.md: c56067dfa3209cae83d40aaa07f46b13d1de94ff
+2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: b49f667e2d915367938cec2a1a0e8386f737e4d2

+ 1 - 0
.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md → .agents/notes/archived/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md

@@ -1,6 +1,7 @@
 # Agent Note: Fail-loud is boot-scoped, and packages are probed in a child process
 
 Status: implemented
+Archived: 2026-09-11
 
 English | [中文](2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md)
 

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

@@ -1,6 +1,7 @@
 # Agent Note: fail-loud 只在启动期,包在子进程里探测
 
 Status: implemented
+Archived: 2026-09-11
 
 [English](2026-09-04-boot-scoped-fail-loud-and-package-probe.md) | 中文
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md
-2026-09-04-external-bundles-as-contained-groups.md: 023fc1efb803348b054e0c0118ba50c4564fc9c1
-2026-09-04-external-bundles-as-contained-groups.zh.md: d3c86b25767aa7235b91308f561adcdddfa052a6
+2026-09-04-external-bundles-as-contained-groups.md: 28e6f6b2bcf07455fdc8f297b6820569ff856ee4
+2026-09-04-external-bundles-as-contained-groups.zh.md: daa00851d264a90b8b8fd6719b797842bdc35074

+ 1 - 0
.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md → .agents/notes/archived/architecture/2026-09-04-external-bundles-as-contained-groups.md

@@ -1,6 +1,7 @@
 # Agent Note: External bundles mount as contained groups with owned row ids
 
 Status: implemented
+Archived: 2026-09-11
 
 English | [中文](2026-09-04-external-bundles-as-contained-groups.zh.md)
 

+ 1 - 0
.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md → .agents/notes/archived/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md

@@ -1,6 +1,7 @@
 # Agent Note: 外部组合包挂载为受控组,行 id 有归属
 
 Status: implemented
+Archived: 2026-09-11
 
 [English](2026-09-04-external-bundles-as-contained-groups.md) | 中文
 

+ 6 - 0
.agents/notes/archived/manifest.json

@@ -361,6 +361,12 @@
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml": "sha256:ba8a62a9fa3263de4162709cb1aaf81f6ad4feee5011d7a5d8b24eb92ad81d30",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.md": "sha256:9db9c47559c17f0e2931b9e646f1d2d17b590df4d5699d4e779ed6d7f65fc371",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md": "sha256:9a266590d49093a097bcdb5d09865757cdd07625cfaaddce1303046d580551f1",
+    "architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml": "sha256:4836b212b8448187a8a5f5ed58c31fe408ff6f0fb5d0939029f9f7a378cab291",
+    "architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md": "sha256:f8aba8765ded42963ede65390e26a0b2ac410831f516440cd1069b26680078d1",
+    "architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md": "sha256:1d9ac5ac4afec95abeb121ef6e29049097d90de6a25f6395edb7cb31c24e3dbc",
+    "architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml": "sha256:e47c077cbca4f981ce472506723271be1c12a097c8ebadac8f371162d085bc8d",
+    "architecture/2026-09-04-external-bundles-as-contained-groups.md": "sha256:72e175d5927c8a80d2fb84f34f995349a4da79b6f9772d2ac2fcf54840ee2967",
+    "architecture/2026-09-04-external-bundles-as-contained-groups.zh.md": "sha256:332a5eaa768cd3dd3a8f040253a7126e7c0eaf3045c1eeaeaf910500120c1183",
     "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml": "sha256:8be5b0afd8820c593e2ec254d35fb8c384267814104f6234bb9af824c5f974ba",
     "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md": "sha256:861e6130e893489271478def5f54f212a106a1580b744e6e40f56356f73c7e10",
     "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md": "sha256:3549257e06f074591de580ebfaa71284c7c384fb0ffb1963c30258d7a7c26db7",

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.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-11-native-entry-diagnostics-and-static-plugin-declarations.md
+2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md: 58e41379ff3a0c8907f664d055c323986bc3ba0c
+2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md: b8ea8ef0266f0977f5db84e40c76eef0a0db4d4f

+ 41 - 0
.agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md

@@ -0,0 +1,41 @@
+# Agent Note: Native entry diagnostics and static plugin declarations
+
+Status: implemented
+
+English | [中文](2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md)
+
+## Problem
+
+Third-party plugin failures must leave the application’s management endpoints available without declaring a broken required composition ready. Native Loader groups retain successful siblings after a row fails. A separate group implementation would duplicate that behavior, while checking package exports during discovery would execute third-party code before the user mounted it. Requested options can also differ from a still-active fiber’s validated config after an update fails.
+
+## Decision
+
+**Startup strictness belongs to row provenance.** Only rows introduced by an external runtime bundle are optional. Built-in, boot-staged, unowned rows and the bootstrap Include are required. Profile stage overrides author stage, which defaults to runtime; first-party declarations and template provenance determine trust. Stage changes failure policy, not execution timing. A config override retains the target row’s owner. Nested Includes inherit their owning entry’s provenance; their local ids are never resolved against unrelated root rows.
+
+**Bundle enablement selects a whole patch layer.** Native rows and author-written groups retain their explicit ids and parents. Anonymous bundle rows receive deterministic ids on detached execution copies. Disabling a bundle removes its inserts and overrides across every target group; persistent user row overrides survive re-enablement. Ownership is checked before loading: strict layers claim ids first, optional collisions omit the whole losing bundle, and conflicting user inserts are omitted per row. Prefixing explicit ids would break author expressions and user patches; relying on authors to avoid collisions permits silent Loader reparenting.
+
+**Diagnostics describe attempts and running instances separately.** `Entry.lastFailure` passively retains import, activation or update errors where the Loader already catches or propagates them. It does not restore transactions or change native continuation. Asynchronous activation failures come from `fiber.await()`, pending dependencies from injection state, and disabled-expression failures from evaluating the effective gate. An active old fiber can coexist with an update failure. Removed entries need no separate failure registry. Nested continuation failures remain advisory; readiness does not promise every nested plugin callback succeeded.
+
+**Recomposition is serialized and nontransactional.** The launcher provides `ProfileRuntime` before startup auditing. Preparation rejects malformed files before mutation. Accepted updates settle current Loader work and await removed fibers before publishing composition facts and returning issues. Old fiber references are necessary because removed entries are absent from Loader task enumeration. Successful sibling changes remain applied after another entry fails. Fresh patch copies prevent a later override from mutating data reused by another composition.
+
+**Discovery reads declarations without executing modules.** `readPackageMetadata` reads package identity, bundle patches and explicit `dsh.plugins` entries. `.` names the main export; subpaths and default configs remain available for composition editors. Undeclared packages remain installed and unknown. A declaration does not certify importability, Config validity or activation. Physical Cordis peer resolution is advisory and cannot detect an inlined copy. Runtime validation belongs to each actual mounted row, including separate mounts of one module in different presets.
+
+**Unhandled process failures stay fatal.** `installFailLoud` remains until shutdown. Only duplicate rejections already observed by an entry audit are coalesced through its process checkpoint. Unrelated detached rejections retain master’s teardown-and-exit policy. No in-process group protects against `process.exit`, a blocked event loop, native crashes or OOM. Preset generation owners retain their own strict mount-and-cleanup behavior.
+
+## Alternatives considered
+
+**Keep contained groups and child probes.** The [group design](../../archived/architecture/2026-09-04-external-bundles-as-contained-groups.md) protected against transactional Loader rollback and supplied failure records for removed rows. Native entries retain both siblings and failed rows, so wrapping adds identities without supplying bundle-wide enablement that layer composition lacks. The [probe design](../../archived/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md) bounded discovery-time execution and discovered undeclared exports and schemas. Static declarations avoid that execution; automatic main-export detection and pre-mount schemas are given up. A future execution sandbox needs its own process ownership and teardown design, not a discovery probe presented as runtime isolation.
+
+**Import inside the Host with try/catch.** Rejected because discovery could execute irreversible side effects, exit the Host or block its event loop. Catching exceptions cannot restore that isolation. Parsing source to infer plugin exports would add another JavaScript interpreter with incomplete results.
+
+**Use a global list of required plugin ids.** Rejected because ids do not describe provenance, external boot providers, custom profiles or nested Include trees. Existing profile trust and stage declarations already own those distinctions.
+
+**Recover by rolling back every live update or ignoring all post-boot rejections.** Neither can establish a safe restored state after plugin effects run. Per-row outcomes and retained user choices are observable; process-level detached failures cannot be attributed reliably to a row and remain fatal.
+
+## Consequences
+
+Installed metadata, enabled layer selection and current runtime health are independent. An enabled bundle may be partially running, pending or failed; updating a row may leave old behavior active with a visible diagnostic. Whole-bundle disable removes its patch effects without requiring a synthetic parent group. Startup can still fail when an optional provider leaves a required consumer pending. Old probe records are not activation evidence, and plugins need explicit `dsh.plugins` declarations for discovery as addable modules.
+
+## Verification
+
+`packages/boot/app-boot/tests/entry-issues.spec.ts` covers trust and stage, import/config/apply/disabled/pending failures, nested and anonymous provenance, active old config after failed updates, recovery, and awaited removed-fiber teardown. Composition tests cover duplicate ownership and unchanged parents. Static metadata tests use an import-time file-writing fixture and assert no execution, retain unknown packages, and reject malformed declarations. Inventory tests verify actual Loader failures and composition conflicts without a historical failure registry. Process-guard and user-patch tests retain fatal detached failures and best-effort live reload diagnostics.

+ 41 - 0
.agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md

@@ -0,0 +1,41 @@
+# Agent Note: 原生条目诊断与静态插件声明
+
+Status: implemented
+
+[English](2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md) | 中文
+
+## Problem
+
+第三方插件失败后,应用管理入口需要保持可用,同时不能把损坏的必需组合宣布为已就绪。原生 Loader 组会在某行失败后保留成功的其他行。独立的组实现会重复这一行为,而在发现包时检查导出会在用户挂载前执行第三方代码。更新失败后,请求的选项还可能与仍然活跃的 fiber 所使用的已校验配置不同。
+
+## Decision
+
+**启动严格程度由行来源决定。** 只有外部 runtime 组合包引入的行是可选行。内置、boot 阶段、无归属行以及启动 Include 均为必需。profile 的 stage 覆盖作者声明,默认值为 runtime;firstParty 声明与模板来源决定 trust。stage 改变失败策略,不改变执行时间。配置覆盖保留目标行的所有者。嵌套 Include 继承所属条目的来源,不能用其局部 id 查询无关的根级行。
+
+**组合包启停选择整份 patch 层。** 原生行和作者声明的组保留显式 id 与父组。匿名组合包行在独立执行副本中获得确定性的 id。禁用组合包会从所有目标组中移除其插入与覆盖;持久化的用户行覆盖在重新启用后保留。加载前检查所有权:严格层优先占有 id,可选层冲突时整包排除,用户插入冲突时逐行排除。为显式 id 加前缀会破坏作者表达式与用户 patch;依赖作者自行避免冲突则允许 Loader 静默改变父组。
+
+**诊断分别描述操作尝试与运行实例。** `Entry.lastFailure` 在 Loader 已有的捕获或传播位置被动保留导入、激活或更新错误。它不恢复事务,也不改变原生继续加载行为。异步激活失败取自 `fiber.await()`,等待依赖取自注入状态,disabled 表达式失败取自有效条件的求值。活跃的旧 fiber 可以与更新失败同时存在。移除的条目无需独立失败注册表。嵌套延续任务失败仍为提示信息;就绪不保证每个嵌套插件回调均成功。
+
+**重组串行执行且不具备事务性。** launcher 在启动审计前提供 `ProfileRuntime`。准备阶段在修改运行树前拒绝无效文件。已接受的更新等待当前 Loader 工作与已移除 fiber 完成后,发布组合信息并返回问题。必须保留旧 fiber 引用,因为已移除的条目不在 Loader 任务枚举中。某行失败后,其他行已成功的变化保留。新的 patch 副本避免后续覆盖修改其他重组会复用的数据。
+
+**发现阶段只读声明,不执行模块。** `readPackageMetadata` 读取包身份、组合包 patch 和显式 `dsh.plugins` 条目。`.` 表示主入口;子路径与默认配置继续供组合编辑器使用。未声明的包保持已安装和未知状态。声明不证明可导入、Config 有效或激活成功。Cordis 物理 peer 解析仅供提示,无法识别内联副本。运行校验属于每个实际挂载行,包括同一模块在不同预设中的独立挂载。
+
+**未处理的进程级失败仍然致命。** `installFailLoud` 保持到应用关闭。只有条目审计已观测到的重复 rejection 会在其进程检查点内合并。无关的脱离管理 rejection 保留 master 的清理退出策略。进程内的组无法防护 `process.exit`、事件循环阻塞、原生崩溃或 OOM。预设代际的拥有者保留独立的严格挂载与清理行为。
+
+## Alternatives considered
+
+**保留 contained group 和子进程 probe。** [原组设计](../../archived/architecture/2026-09-04-external-bundles-as-contained-groups.md) 防止事务式 Loader 回滚,并为已移除行提供失败记录。原生条目会同时保留成功行与失败行,包装只增加身份,并未提供整层组合所缺少的整包启停能力。[原 probe 设计](../../archived/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md) 为发现阶段的执行设定边界,并发现未声明导出与 schema。静态声明避免这次执行,同时放弃自动主入口识别和挂载前 schema。未来的执行沙箱需要独立设计进程归属与清理,不能把发现 probe 视为运行隔离。
+
+**在 Host 内 import 并 try/catch。** 拒绝,因为发现阶段可能产生不可逆副作用、退出 Host 或阻塞事件循环。捕获异常无法恢复隔离。通过解析源码推断插件导出会增加一个结果不完整的 JavaScript 解释器。
+
+**使用全局必需插件 id 列表。** 拒绝,因为 id 无法描述来源、外部 boot 提供方、自定义 profile 或嵌套 Include 树。已有的 profile trust 与 stage 声明负责这些区别。
+
+**通过回滚全部实时更新或忽略所有启动后 rejection 恢复。** 插件副作用发生后,两者都不能保证恢复安全状态。逐行结果与保留的用户选择可以被观测;进程级脱离管理失败无法可靠归属到某行,因此仍然致命。
+
+## Consequences
+
+已安装元信息、启用层选择与当前运行健康相互独立。启用的组合包可能部分运行、等待依赖或失败;行更新可能保留旧行为并显示诊断。整包禁用移除其 patch 效果,无需合成父组。可选提供方失败导致必需消费方等待时,启动仍可能失败。旧 probe 记录不是激活证据,插件需要显式 `dsh.plugins` 声明才能被发现为可添加模块。
+
+## Verification
+
+`packages/boot/app-boot/tests/entry-issues.spec.ts` 覆盖 trust 与 stage、导入/配置/apply/disabled/pending 失败、嵌套与匿名来源、失败更新后的活跃旧配置、恢复及已移除 fiber 的清理等待。组合测试覆盖重复归属与父组保持。静态元信息测试使用导入时写文件的 fixture,确认未执行、保留未知包并拒绝无效声明。inventory 测试直接验证 Loader 失败与组合冲突,不依赖历史失败注册表。进程处理与用户 patch 测试保留脱离管理失败的致命策略,以及尽力执行的实时重载诊断。

+ 1 - 1
apps/cli/src/dump-config.ts

@@ -53,7 +53,7 @@ export function runDumpConfig(
       userLayers.push({ label: absolute, patches: loadOverlayPatches(NAME, absolute) })
     }
   }
-  // The same composition boot mounts: external bundles as contained groups,
+  // The same composition boot mounts: bundle layers in their declared order,
   // a bundle or row left out by an id conflict reported, not shown.
   const stack = composeProfileStack(NAME, loaded.layers, userLayers)
   for (const conflict of stack.conflicts) process.stderr.write(`${NAME}: ${formatRowConflict(conflict)}\n`)

+ 10 - 18
apps/cli/src/profile-boot.ts

@@ -24,7 +24,6 @@ import {
   healProfilesModuleFallback,
   initProfile,
   installFailLoud,
-  installRuntimeGuards,
   loadOptionalPatches,
   loadOverlayPatches,
   loadProfile,
@@ -307,9 +306,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   const composed = await composeProfile(options.profile, options.patchFiles, options.fromDefaultProfile)
   const app: { current?: Context; runtime?: ProfileRuntime } = {}
   const appReady = createAppReady()
-  let uninstallRuntimeGuards = (): void => {}
   const shutdown = createProcessShutdown(async () => {
-    uninstallRuntimeGuards()
     await app.current?.fiber.dispose()
     await disposeProxy()
   })
@@ -325,7 +322,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   // complete; SIGINT is a user interrupt and reports 130.
   process.on('SIGTERM', () => { interrupt(0) })
   process.on('SIGINT', () => { interrupt(130) })
-  const uninstallFailLoud = installFailLoud(NAME, process, async () => {
+  installFailLoud(NAME, process, async () => {
     await app.current?.fiber.dispose()
   })
 
@@ -358,8 +355,16 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   for (const conflict of composed.stack.conflicts) process.stderr.write(`${NAME}: ${formatRowConflict(conflict)}\n`)
   // Cloned for the same insert-aliasing reason as composeFor: the boot
   // 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), async (hostCtx) => {
     app.current = hostCtx
+    await hostCtx.plugin(ProfileRuntime, {
+      profile: composed.profile,
+      stack: composed.stack,
+      loadProfile: () => prepareProfile(options.profile),
+      compose: composeFor,
+      rootEntry: () => rootIncludeEntry(hostCtx),
+    })
+    app.runtime = hostCtx.profileRuntime
     // Before any config-tree entry mounts, so plugins resolve all launch-time
     // environment values from the same immutable provenance snapshot.
     hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
@@ -372,21 +377,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
     })
   })
   app.current = ctx
-  // The tree is up: a later unhandled rejection is a plugin's stray
-  // continuation, not a load failure, and must not take every session down.
-  uninstallFailLoud()
-  uninstallRuntimeGuards = installRuntimeGuards(NAME, (line) => { process.stderr.write(`${line}\n`) })
   if (!signalShutdown.signal.aborted && ctx.fiber.state === FiberState.ACTIVE && ctx.get('loader') !== undefined) {
     warnNestedFiberFailures(ctx, NAME, (line) => { process.stderr.write(`${line}\n`) })
-    await ctx.plugin(ProfileRuntime, {
-      profile: composed.profile,
-      stack: composed.stack,
-      loadProfile: () => prepareProfile(options.profile),
-      compose: composeFor,
-      rootEntry: () => rootIncludeEntry(ctx),
-    })
-    const runtime = ctx.get('profileRuntime')
-    if (runtime !== undefined) app.runtime = runtime
   }
   // A live-reload profile can dispose the whole tree while post-boot watcher
   // setup is in flight — a signal or appExit. Loader presence and fiber state

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/core.md
-core.md: 2b9ce0a2cd67647db3f27f508a89fd9acbcfe3ed
-core.zh.md: 34d0f5a2d60a93e484ca2ff95a218a4b5ef0a3ee
+core.md: 5b1a249d4d0b2df05750e62044ee539a24b32b0e
+core.zh.md: 2c38ffe6ec9956fc436d4a4f7e2fcc3d1f7420f8

+ 15 - 15
docs/subsystems/core.md

@@ -912,6 +912,13 @@ Facts and recomposition of the booted profile.
  */
 originOf(rowId: string): RowOrigin | undefined
 
+/**
+ * Resolve provenance within its Loader tree; nested includes inherit their owning entry.
+ * @param entry - the live entry, including an entry inside another Include.
+ * @returns its supplying bundle, or undefined for a user-owned entry.
+ */
+originOfEntry(entry: Entry): RowOrigin | undefined
+
 /**
  * Row ids the user patch layers disable with a literal `disabled: true`,
  * as the committed composition read them. The set describes the running
@@ -932,22 +939,15 @@ userDisabledRowIds(): ReadonlySet<string>
 userDisables(entry: Entry): boolean
 
 /**
- * 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.
- * Calls queue: one that arrives while another is in flight starts after it
- * settled and reads what it committed. A rejection is that call's outcome
- * alone and does not stop the ones behind it.
- * @param options - `reloadBundles` re-reads the profile manifest first, so a
- * bundle enabled or installed since boot joins the stack.
- * @throws when the root include is not mounted, or the Loader rejected the update.
+ * Apply a fresh profile stack and wait for live entries and removed fibers to settle.
+ * Parse/composition failures leave the applied stack unchanged. Accepted options
+ * can coexist with failed entries or fibers running their previous valid config.
+ * Calls serialize; a failed call does not block later changes.
+ * @param options - whether to reread installed bundle layers from disk.
+ * @returns current entry issues after application, without rolling back successful siblings.
+ * @throws when preparation fails or the root Include cannot accept the update.
  */
-async recompose(options: { reloadBundles?: boolean } = {}): Promise<void>
+async recompose(options: { reloadBundles?: boolean } = {}): Promise<readonly EntryIssue[]>
 ```
 
 Source: [`packages/boot/app-boot/src/profile-runtime.ts`](../../packages/boot/app-boot/src/profile-runtime.ts)

+ 15 - 15
docs/subsystems/core.zh.md

@@ -922,6 +922,13 @@ Facts and recomposition of the booted profile.
  */
 originOf(rowId: string): RowOrigin | undefined
 
+/**
+ * Resolve provenance within its Loader tree; nested includes inherit their owning entry.
+ * @param entry - the live entry, including an entry inside another Include.
+ * @returns its supplying bundle, or undefined for a user-owned entry.
+ */
+originOfEntry(entry: Entry): RowOrigin | undefined
+
 /**
  * Row ids the user patch layers disable with a literal `disabled: true`,
  * as the committed composition read them. The set describes the running
@@ -942,22 +949,15 @@ userDisabledRowIds(): ReadonlySet<string>
 userDisables(entry: Entry): boolean
 
 /**
- * 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.
- * Calls queue: one that arrives while another is in flight starts after it
- * settled and reads what it committed. A rejection is that call's outcome
- * alone and does not stop the ones behind it.
- * @param options - `reloadBundles` re-reads the profile manifest first, so a
- * bundle enabled or installed since boot joins the stack.
- * @throws when the root include is not mounted, or the Loader rejected the update.
+ * Apply a fresh profile stack and wait for live entries and removed fibers to settle.
+ * Parse/composition failures leave the applied stack unchanged. Accepted options
+ * can coexist with failed entries or fibers running their previous valid config.
+ * Calls serialize; a failed call does not block later changes.
+ * @param options - whether to reread installed bundle layers from disk.
+ * @returns current entry issues after application, without rolling back successful siblings.
+ * @throws when preparation fails or the root Include cannot accept the update.
  */
-async recompose(options: { reloadBundles?: boolean } = {}): Promise<void>
+async recompose(options: { reloadBundles?: boolean } = {}): Promise<readonly EntryIssue[]>
 ```
 
 Source: [`packages/boot/app-boot/src/profile-runtime.ts`](../../packages/boot/app-boot/src/profile-runtime.ts)

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
-README.md: ddfbecae8b3e8fd07db7ec037cedb4d7df8c02d7
-README.zh.md: 7def7098ce32e2fbbe3086d3e96c46d0d39a4e17
+README.md: 2bcdeff9ca96f8ee54e0a8ef2474496e8cf0776e
+README.zh.md: 2cbb7e7e022abe6cebc6b4f5ae468598040476ae

+ 12 - 12
packages/boot/app-boot/README.md

@@ -40,7 +40,7 @@ installFailLoud('dsh')
 const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
 ```
 
-With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`.
+Startup policy follows the bundle that introduced each row. Only `external` / `runtime` rows may fail with a warning while their successful siblings remain active. Built-in, boot-staged, and unowned rows are required: an enabled failure disposes the app and rejects startup. Disabled rows are ignored; the bootstrap Include is always required. Direct calls without profile provenance are strict.
 
 <a id="profiles"></a>
 ### Profiles
@@ -58,9 +58,9 @@ Profiles with `patchReload: live` watch both user patch files and apply the [rel
 
 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, counting both the rows a layer inserts and the rows its patches set as a group's config: 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.
+Installed dependencies default to `external`; template bundles and packages in `dsh.profile.firstParty` are `builtin`. Effective stage is `dsh.profile.stages[package]`, then the package’s `dsh.bundle.stage`, then `runtime`. Stage selects startup failure policy, not a later execution phase. Bundle patches retain their declared ids, parents, and ordering. Built-in and boot-staged layers claim ids first; an optional bundle with a duplicate id is omitted whole and reported, while a conflicting user insert is omitted per row. `dependencies` records installation; `dsh.profile.bundles` selects enabled layers, including all their inserts and overrides.
 
-After the tree is up the launcher provides `ctx.profileRuntime`, which holds the composition the tree runs — the profile, 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 a runtime bundle enable or install all call it, recompositions run one at a time, and a rejected update leaves its facts describing the tree still running. 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.
+The launcher provides `ctx.profileRuntime` before any configuration entry mounts. It owns row provenance, accepted composition, conflicts, and user-disabled rows. Watchers and management operations share its serial recomposition queue. Recomposition waits for current entries and removed fibers, then publishes accepted options and reports per-entry issues; a failed update can leave a fiber running its previous valid config. `installFailLoud` remains installed until shutdown for process-level unhandled rejections.
 
 ### Previewing the effective configuration
 
@@ -85,7 +85,7 @@ After the Loader settles, app-boot reports optional failures as warnings and rej
 | Detached asynchronous work outside the `apply()` return Promise produces an unhandled rejection | Fatal: dispose the app and exit nonzero | Fatal: dispose the app and exit nonzero | Fatal: dispose the app and exit nonzero, regardless of entry id |
 | Entry is absent or explicitly disabled | Ignore it | Ignore it | Do not activate it; no required-startup audit |
 
-The required list above includes `modules` and `connection`; Web startup cannot succeed when either enabled entry fails. Failure of an optional provider can also prevent a required consumer from activating. Schema rejection before an existing entry updates is not a transactional rollback of sibling changes.
+An optional provider failure can leave a required consumer pending and prevent readiness. Overriding an existing row does not change its owner or startup policy. Schema rejection before an existing entry updates does not roll back successful sibling changes.
 
 The [Web process matrix](../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) and [startup acceptance](../../../apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) verify these outcomes through the shipped Web profile; [app-boot tests](tests/app-boot.spec.ts) also exercise root Include failures.
 
@@ -109,9 +109,9 @@ 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.
 - **Two Loader builtins.** `mountRootInclude` registers `cordis:include` and `cordis:group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution.
-- **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot applies the global required-entry policy after initial settlement; agent presets and dynamic multi-entry compositions own and dispose their separate generation when they require all-or-nothing setup. App-boot reads failed fibers to report their recorded errors and coalesces duplicate Loader rejection notifications through one process checkpoint.
+- **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot audits provenance after startup settlement; agent presets own and clean up generations that require every configured entry to activate. Entry failures are read from the Loader and Fiber, with duplicate rejection notifications coalesced through one process checkpoint.
 - **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.
-- **Update completion.** App boot observes restart failures through the `internal/update` waterfall. Live patch reloads wait for the tree's fibers before auditing activation; `Fiber.update()` and `Entry.update()` alone do not establish restart success.
+- **Update completion.** Live reload waits for Loader work before inspecting entry issues. Profile recomposition also waits for removed fibers, whose cleanup is absent from the current Loader tree. `Fiber.update()` and `Entry.update()` alone do not establish restart success.
 - **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`. Plugin diagnostics include original stacks, nested causes, and aggregate member failures. Cyclic causes terminate diagnostic traversal without replacing the original cause.
 
 ### Helper behavior
@@ -122,13 +122,13 @@ The exports each own one stage of the boot: config resolution and snapshot repla
 
 | File | Role |
 |---|---|
-| [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard and runtime guards, activation audit, patch parsing, config dump, harness-source section |
+| [`src/index.ts`](src/index.ts) | Boot, environment layers, fail-loud guard, startup audit, patch parsing and watching, config dump |
 | [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution with trust and stage, module fallback |
-| [`src/external-bundles.ts`](src/external-bundles.ts) | External layer composition (contained group, overrides) and the manifest operations behind install, enable, and disable |
+| [`src/external-bundles.ts`](src/external-bundles.ts) | Bundle ownership analysis and installed/enabled manifest lists |
 | [`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/entry-issues.ts`](src/entry-issues.ts) | Current entry failures, unresolved services, and diagnostic formatting |
 | [`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 |
+| [`src/package-metadata.ts`](src/package-metadata.ts) | Static manifest and patch declarations; no module execution or probe cache |
 | — | 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>
@@ -171,10 +171,10 @@ These limits describe when this boot library is a poor fit or needs special care
 - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection.
 - **Environment discovery is launch-scoped** — `loadLayeredEnv` reads only the invocation directory and Harness home once; it does not search parents or follow a workspace selected later. `loadEnv` remains the one-directory helper for non-product bins.
 - **A user patch replaces the whole matched config** — an id-targeted patch does not deep-merge, so a profile override restates the bundle fields it keeps.
-- **An external bundle's overrides are not isolated** — a patch it applies to a built-in row edits that row in place, so its effect stays when the bundle's own rows fail and it is the one thing a bundle can break outside its group.
+- **Overrides retain the target’s owner** — changing a built-in row can make that required row fail; successful updates to other rows remain applied. Disabling the bundle removes its whole patch layer, including overrides.
 - **A conflict is decided by order, not merit** — among external bundles the earlier layer in `dsh.profile.bundles` keeps a contested id, so uninstalling that bundle lets the later one mount on the next boot; the plugin list shows which bundle lost and to whom.
 - **The nested-fiber audit is advisory** — a failed `ctx.inject()` continuation under a built-in entry is reported, not fatal, until shipped compositions are known clean.
-- **The probe cannot run from a packaged executable** — `probe-child.js` sits inside the executable's `/snapshot` tree, which the plain Node child cannot read, so `probePackage` rejects there until the child entry is materialized outside the executable the way module proxies are.
+- **Declarations are not activation results** — `readPackageMetadata` reads `dsh.plugins` without importing modules. Declare `plugins: [{ name: "." }]` for a main export, or subpaths such as `./tools`; undeclared packages remain `unknown`. Config validation and execution diagnostics appear only after an actual mount. Physical Cordis resolution cannot detect an inlined copy.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 12 - 12
packages/boot/app-boot/README.zh.md

@@ -40,7 +40,7 @@ installFailLoud('dsh')
 const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
 ```
 
-有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`webserver`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。
+启动策略由引入该行的组合包决定。只有 `external` / `runtime` 行可以在失败后输出警告,同时保留成功的其他行。内置、boot 阶段和无归属行均为必需行:启用但失败时会清理应用并拒绝启动。已禁用的行被忽略;启动 Include 始终为必需。未提供 profile 来源信息的直接调用采用严格策略。
 
 <a id="profiles"></a>
 ### Profile
@@ -58,9 +58,9 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 插入条目的插件名可以是绝对文件系统路径、文件 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 在整叠层里共用一个命名空间,既算一层插入的行也算它的 patch 设为某个组 config 的行:内置层先占有自己的 id,外部组合包若声明了别的层已占有的 id,或把自己的某个 id 声明了两次,就整层被排除,并在 stderr 与插件列表里报告;用户层插入已被占用的 id 时该行被丢弃,同样报告。
+已安装依赖默认为 `external`;模板组合包和 `dsh.profile.firstParty` 中的包为 `builtin`。有效 stage 依次取 `dsh.profile.stages[package]`、包的 `dsh.bundle.stage`,最后默认为 `runtime`。stage 决定启动失败策略,不表示稍后执行。组合包 patch 保留声明的 id、父组和顺序。内置与 boot 阶段层优先占有 id;可选组合包有重复 id 时整层被排除并报告,冲突的用户插入则逐行排除。`dependencies` 记录安装;`dsh.profile.bundles` 选择启用的层,包括它们的全部插入和覆盖。
 
-树起来之后 launcher 提供 `ctx.profileRuntime`:它持有树正在运行的组合——profile、每一行的归属层、被组合排除的行——读取用户 patch 文件停用了哪些行,并且是重组整棵树的唯一入口:patch 监视器、运行时启用或安装组合包都调用它,重组一次只跑一个,被拒的更新留下的事实仍然描述正在运行的树。启动期的 fail-loud rejection 守卫在树起来后卸载:启动后未处理的 rejection 会被报告,进程继续运行,不停止任何任务也不归属到任何插件;未捕获的异常会被报告并退出。
+launcher 在任何配置行挂载前提供 `ctx.profileRuntime`。它拥有行来源、已接受的组合、冲突和用户禁用行信息。文件监听与管理操作共用它的串行重组队列。重组等待当前条目和已移除 fiber 完成后,发布已接受的选项并报告逐行问题;更新失败时,fiber 可能仍使用先前的有效配置运行。`installFailLoud` 保持到应用关闭,处理进程级未处理 rejection。
 
 ### 预览生效配置
 
@@ -85,7 +85,7 @@ Loader 结算后,app-boot 将 optional 失败报告为警告;若已启用的
 | 脱离 `apply()` 返回 Promise 的异步任务产生未处理 rejection | 致命错误:释放应用并以非零码退出 | 致命错误:释放应用并以非零码退出 | 致命错误:释放应用并以非零码退出,与条目 id 无关 |
 | 条目缺失或被显式禁用 | 忽略 | 忽略 | 不激活该条目;不执行 required 启动审计 |
 
-上面的 required 列表包含 `modules` 与 `connection`;只要其中一个已启用条目失败,Web 就无法成功启动。Optional 提供方失败也可能使 required 消费方无法激活。现有条目的新配置在更新前被 schema 校验拒绝,并不等于对兄弟插件的变更做事务回滚。
+可选提供方失败可能使必需消费方等待依赖,从而阻止应用就绪。覆盖已有行不会改变其所有者或启动策略。已有行在更新前校验失败,不会回滚其他行已成功的变化。
 
 [Web 进程矩阵](../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)和[启动验收测试](../../../apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)通过随附 Web profile 验证这些结果;[app-boot 测试](tests/app-boot.spec.ts)还覆盖根 Include 失败。
 
@@ -109,9 +109,9 @@ Loader 结算后,app-boot 将 optional 失败报告为警告;若已启用的
 
 - **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。
 - **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
-- **由 consumer 持有严格语义。** 普通 Loader group 保留成功 sibling。App-boot 在首次结算后应用全局 required-entry policy;agent preset 与动态多 entry 组合在需要 all-or-nothing setup 时,持有并拆卸各自的独立 generation。App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检查点内合并 Loader 重复的 rejection 通知。
+- **消费方决定严格程度。** 普通 Loader 组保留成功的其他行。app-boot 在启动完成后按来源审计;agent preset 拥有并清理要求全部配置行激活的代际。行失败从 Loader 与 Fiber 读取,重复的 rejection 通知在一个进程检查点内合并。
 - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部组合包若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。
-- **更新完成。** App boot 通过 `internal/update` waterfall 观察重启失败。实时 patch 重载在检查激活状态前等待配置树中的 fiber;单独调用 `Fiber.update()` 或 `Entry.update()` 不能确定重启成功。
+- **更新完成。** 实时重载等待 Loader 工作后检查逐行问题。profile 重组还等待已移除 fiber 的清理,这些工作已不在当前 Loader 树中。单独完成 `Fiber.update()` 和 `Entry.update()` 不代表重启成功。
 - **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`。插件诊断包含原始堆栈、嵌套原因和聚合错误中的各项失败。原因链出现循环时,诊断遍历会终止,不会替换原始原因。
 
 ### Helper 行为
@@ -122,13 +122,13 @@ Loader 结算后,app-boot 将 optional 失败报告为警告;若已启用的
 
 | 文件 | 职责 |
 |---|---|
-| [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、fail-loud 守卫与运行时守卫、激活审计、patch 解析、配置 dump、harness 源码段落 |
+| [`src/index.ts`](src/index.ts) | 启动、环境层、fail-loud 处理、启动审计、patch 解析与监听、配置导出 |
 | [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、带 trust 与 stage 的组合包解析、模块后备机制 |
-| [`src/external-bundles.ts`](src/external-bundles.ts) | 外部层组合(受控组、覆盖报告)与安装、启用、停用背后的 manifest 操作 |
+| [`src/external-bundles.ts`](src/external-bundles.ts) | 组合包归属分析及安装、启用 manifest 列表 |
 | [`src/compose-stack.ts`](src/compose-stack.ts) | 整叠层的行 id 归属:`claimLayerIds`、`composeProfileStack`、冲突记录 |
-| [`src/contained-group.ts`](src/contained-group.ts) | `cordis:contained-group` builtin 与 `pluginFailures` 注册表 |
+| [`src/entry-issues.ts`](src/entry-issues.ts) | 当前条目失败、未满足服务和诊断格式化 |
 | [`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) 是它校验的报告 |
+| [`src/package-metadata.ts`](src/package-metadata.ts) | 静态 manifest 与 patch 声明;不执行模块,不使用 probe 缓存 |
 | — | 不发布运行时不变式伴生入口;边界与回放测试覆盖其协议映射。 |
 
 </details>
@@ -171,10 +171,10 @@ Loader 结算后,app-boot 将 optional 失败报告为警告;若已启用的
 - **快照回放替换仅识别特定 basename**——只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。
 - **环境发现以启动为界**——`loadLayeredEnv` 只读取一次调用目录与 harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper。
 - **用户 patch 会替换匹配到的整个配置**——按 id 定位的 patch 不做深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。
-- **外部组合包的覆盖不被隔离**——它对内置行施加的 patch 原地修改那一行,所以即使该组合包自己的行失败,效果仍然保留;这是组合包唯一能在自己的组之外造成影响的地方。
+- **覆盖保留目标的所有者**——修改内置行可能使该必需行失败;其他行成功的更新仍然生效。禁用组合包移除其整份 patch 层,包括覆盖。
 - **冲突按顺序判定,不看是非**——外部组合包之间,`dsh.profile.bundles` 里靠前的那层保住争议 id,卸掉它之后靠后的那层在下次启动时挂上;插件列表显示谁输给了谁。
 - **嵌套 fiber 审计只是提示**——内置条目下失败的 `ctx.inject()` 延续会被报告而非致命,直到确认随附组合都没有这类失败。
-- **探针无法从打包可执行文件运行**——`probe-child.js` 位于可执行文件的 `/snapshot` 树内,普通 Node 子进程读不到它,因此 `probePackage` 在那里会 reject,直到子进程入口像模块代理那样被物化到可执行文件之外。
+- **声明不代表激活结果**——`readPackageMetadata` 读取 `dsh.plugins`,不导入模块。主入口声明为 `plugins: [{ name: "." }]`,子路径可写为 `./tools`;未声明的包保持 `unknown`。Config 校验和执行诊断只在实际挂载后产生。Cordis 物理路径解析无法识别被内联打包的副本。
 
 <a id="dev-note"></a>
 ### 开发备注

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

@@ -23,7 +23,6 @@
   },
   "files": [
     "lib/index.js",
-    "lib/probe-child.js",
     "lib/types/**/*.d.ts"
   ],
   "license": "MIT",

+ 20 - 12
packages/boot/app-boot/src/compose-stack.ts

@@ -7,16 +7,16 @@
  * 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
+ * patches, the owner of every id, and the conflicts, and each optional
+ * layer is analyzed once. Boot, live recomposition, and the config dump all
  * compose through here, so they agree.
  * @module @deepseek-ai/dsh-app-boot/compose-stack
  */
 
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import { composeExternalLayer, isContainedLayer, type ComposedExternalLayer } from './external-bundles.ts'
-import { visitIdentifiedRows, visitRowTree } from './patch-rows.ts'
+import { analyzeBundleLayer, isOptionalRuntimeLayer, type AnalyzedBundleLayer } from './external-bundles.ts'
+import { visitIdentifiedRows, visitPatchRows, visitRowTree } from './patch-rows.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. */
@@ -72,8 +72,8 @@ export interface LayerOwnership {
   readonly owners: Map<string, ProfileLayer>
   /** The conflicts of each external bundle left out, by package name. */
   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>
+  /** The analysis of each optional layer that owns its ids, by package name; rendered once and mounted as is. */
+  readonly composed: Map<string, AnalyzedBundleLayer>
 }
 
 /** One conflict with its message: the id's other declarer, or the losing layer itself declaring it twice. */
@@ -113,7 +113,7 @@ export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
   const owners = new Map<string, ProfileLayer>()
   const declaredUnder = new Map<string, string | undefined>()
   for (const layer of layers) {
-    if (isContainedLayer(layer)) continue
+    if (isOptionalRuntimeLayer(layer)) continue
     visitIdentifiedRows(layer.patches, ({ id, source, place, listed }) => {
       const owner = owners.get(id)
       if (owner === layer) {
@@ -132,11 +132,11 @@ export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
     })
   }
   const skipped = new Map<string, RowConflict[]>()
-  const composed = new Map<string, ComposedExternalLayer>()
+  const composed = new Map<string, AnalyzedBundleLayer>()
   for (const layer of layers) {
-    if (!isContainedLayer(layer)) continue
+    if (!isOptionalRuntimeLayer(layer)) continue
     const { packageName } = layer
-    const composition = composeExternalLayer(layer)
+    const composition = analyzeBundleLayer(layer)
     const conflicts: RowConflict[] = composition.duplicates.map(({ rowId, moduleName }) => (
       rowConflict({ rowId, moduleName, layer: packageName, packageName, declaredBy: packageName })
     ))
@@ -159,8 +159,8 @@ export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
 /**
  * Compose the stack the root include mounts: every bundle layer that owns its
  * ids, in manifest order, then the user layers with any insert of an already
- * owned id dropped. Patches are passed by reference; callers that mount them
- * clone, because the include pushes inserted rows into the tree as they are.
+ * owned id dropped. Bundle patches are copied before assigning anonymous ids;
+ * user patches remain shared input, so callers mount a detached execution copy.
  * @param binName - the diagnostic prefix on a thrown built-in duplicate.
  * @param layers - the profile's bundle layers, in manifest order.
  * @param userLayers - the user-owned layers, in application order.
@@ -170,6 +170,14 @@ export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
 export function composeProfileStack(
   binName: string, layers: readonly ProfileLayer[], userLayers: readonly StackUserLayer[],
 ): ComposedStack {
+  // Only anonymous bundle rows need an assigned id. The execution copy keeps
+  // provenance available after loading without modifying author-owned patches.
+  layers = layers.map((layer) => {
+    const patches = structuredClone(layer.patches)
+    let anonymous = 0
+    visitPatchRows(patches, (row) => { row.id ||= `anonymous/${layer.packageName}/${String(anonymous++)}` })
+    return { ...layer, patches }
+  })
   let ownership: LayerOwnership
   try {
     ownership = claimLayerIds(layers)

+ 0 - 223
packages/boot/app-boot/src/contained-group.ts

@@ -1,223 +0,0 @@
-/**
- * The contained group: a Loader group whose rows fail individually instead of
- * failing the group. The vendored `EntryGroup.update` is all-or-nothing — one
- * rejected row rolls the whole group back and rethrows, which at boot unwinds
- * the whole tree. External bundles mount under this group so their startup
- * failures are recorded and reported rather than fatal, while built-in rows
- * keep the fatal path.
- * @module @deepseek-ai/dsh-app-boot/contained-group
- */
-
-import type { Context } from '@deepseek-ai/cordis'
-import { EntryUpdateError, Group, type Entry, type EntryGroup, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
-import { visitRowTree } from './patch-rows.ts'
-
-/** 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. */
-export interface ContainedFailure {
-  /** The row's tree-wide id, as `Entry.id` spells it (`include:<row id>` under the root include). */
-  readonly entryId: string
-  /** The row id as the composition declares it — the key patches and provenance use. */
-  readonly rowId: string
-  /** The module specifier the row named. */
-  readonly moduleName: string
-  /** The contained group the row belongs to, as the tree names it. */
-  readonly groupId: string
-  /** Which lifecycle step failed. */
-  readonly stage: ContainedFailureStage
-  /** The failure text, with the Loader's per-row wrapper folded in. */
-  readonly message: string
-}
-
-/**
- * Failures recorded by contained groups of one runtime. Rows are keyed by
- * their tree-wide id; recording a row again replaces its earlier record, a
- * row that later mounts clears it, a group that updates drops the records of
- * rows it no longer configures, and a group that unmounts clears its rows'.
- * A record's `groupId` names the contained group that isolates the row, at
- * any nesting depth, so those two cleanups reach every row of a bundle.
- * Rows the composition left out never reach a group and are not recorded
- * here; `ProfileRuntime.conflicts` holds them.
- */
-export class ContainedFailureRegistry {
-  private readonly failures = new Map<string, ContainedFailure>()
-
-  /**
-   * Record one failure, replacing an earlier record of the same row.
-   * @param failure - the failure to record.
-   */
-  record(failure: ContainedFailure): void {
-    this.failures.set(failure.entryId, failure)
-  }
-
-  /**
-   * Forget one row's failure, for a row that mounted on a later attempt.
-   * @param entryId - the row's tree-wide id.
-   */
-  clear(entryId: string): void {
-    this.failures.delete(entryId)
-  }
-
-  /**
-   * Forget every record of one contained group, when the group unmounts.
-   * @param groupId - the group's tree-wide id.
-   */
-  clearGroup(groupId: string): void {
-    for (const [entryId, failure] of this.failures) {
-      if (failure.groupId === groupId) this.failures.delete(entryId)
-    }
-  }
-
-  /**
-   * Forget the records of one contained group's rows that its configuration
-   * no longer names, after the group updated.
-   * @param groupId - the group's tree-wide id.
-   * @param rowIds - the row ids the group still configures, at any depth.
-   */
-  retain(groupId: string, rowIds: ReadonlySet<string>): void {
-    for (const [entryId, failure] of this.failures) {
-      if (failure.groupId === groupId && !rowIds.has(failure.rowId)) this.failures.delete(entryId)
-    }
-  }
-
-  /**
-   * Every recorded failure in record order.
-   * @returns the failures.
-   */
-  list(): ContainedFailure[] {
-    return [...this.failures.values()]
-  }
-
-  /**
-   * One row's failure.
-   * @param entryId - the row's tree-wide id.
-   * @returns the record, or undefined when the row is not failed.
-   */
-  get(entryId: string): ContainedFailure | undefined {
-    return this.failures.get(entryId)
-  }
-}
-
-declare module '@deepseek-ai/cordis' {
-  interface Context {
-    /** Failures recorded by contained groups; provided on the root by the boot glue. */
-    pluginFailures?: ContainedFailureRegistry
-  }
-}
-
-/**
- * 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(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
- * 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
- * failed row is absent from the tree, and the record names it. When the group
- * updates, the records of rows its configuration dropped go; when it unmounts
- * — its bundle disabled or uninstalled — all its rows' records go with it, so
- * no failure outlives the composition that produced it.
- */
-export class ContainedGroup extends Group {
-  override async update(config: EntryOptions[]): Promise<void> {
-    await super.update(config)
-    const configured = new Set<string>()
-    // `ensureId` named every row, nested ones included, while the update mounted it.
-    for (const row of config) visitRowTree(row, (entry) => { configured.add(entry.id) })
-    this.registry()?.retain(this.groupId(), configured)
-  }
-
-  override async create(options: Omit<EntryOptions, 'id'>): Promise<string> {
-    try {
-      const id = await super.create(options)
-      this.registry()?.clear(id)
-      return id
-    } catch (error) {
-      const registry = this.registry()
-      // `ensureId` assigned the id before the row started, so a failed row
-      // still carries the id the tree would have used.
-      const rowId = (options as EntryOptions).id
-      // The same spelling `Entry.id` gives a live row: prefixed with the
-      // owning include's id when this tree is an included one.
-      const treeOwner = this.tree.ctx.fiber.entry
-      const entryId = treeOwner === undefined ? rowId : `${treeOwner.id}:${rowId}`
-      /* v8 ignore next -- the Loader wraps a row's thrown value in an Error before it reaches here */
-      const message = error instanceof Error ? error.message : String(error)
-      registry?.record({
-        entryId,
-        rowId,
-        moduleName: options.name,
-        groupId: this.groupId(),
-        stage: stageOf(error),
-        message,
-      })
-      this.ctx.logger.warn(`contained group ${this.groupId()}: row ${rowId} failed and was isolated: ${message}`)
-      return rowId
-    }
-  }
-
-  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. */
-  private registry(): ContainedFailureRegistry | undefined {
-    return this.ctx.get('pluginFailures')
-  }
-
-  /** This group's own entry id, as the tree names it. */
-  private groupId(): string {
-    /* v8 ignore next -- a group always runs as a Loader entry; the fallback keeps a direct `ctx.plugin(ContainedGroup)` readable */
-    return this.ctx.fiber.entry?.id ?? '(unknown group)'
-  }
-}
-
-/**
- * The contained group that isolates an entry: the nearest owning group, at
- * any depth, that is a {@link ContainedGroup}.
- * @param entry - the Loader entry to classify.
- * @returns the group, or undefined for an entry outside every contained group.
- */
-export function containingGroup(entry: Entry): ContainedGroup | undefined {
-  let group: EntryGroup | undefined = entry.parent
-  while (group !== undefined) {
-    if (group instanceof ContainedGroup) return group
-    group = group.ctx.fiber.entry?.parent
-  }
-  return undefined
-}
-
-/**
- * Whether an entry sits inside a contained group, at any depth.
- * @param entry - the Loader entry to classify.
- * @returns true when some owning group is a {@link ContainedGroup}.
- */
-export function isContainedEntry(entry: Entry): boolean {
-  return containingGroup(entry) !== undefined
-}
-
-/**
- * The contained failure registry of a runtime, creating and providing one on
- * the root the first time it is asked for.
- * @param ctx - any context of the runtime.
- * @returns the registry.
- */
-export function ensurePluginFailures(ctx: Context): ContainedFailureRegistry {
-  const existing = ctx.get('pluginFailures')
-  if (existing !== undefined) return existing
-  const registry = new ContainedFailureRegistry()
-  ctx.root.provide('pluginFailures', registry)
-  return registry
-}

+ 114 - 0
packages/boot/app-boot/src/entry-issues.ts

@@ -0,0 +1,114 @@
+/** Entry-owned load/update failures and missing services, independent of bundle grouping. */
+import type { Context, FiberState } from '@deepseek-ai/cordis'
+import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
+
+const PENDING = 0 as FiberState.PENDING
+const ACTIVE = 2 as FiberState.ACTIVE
+const FAILED = 3 as FiberState.FAILED
+
+/** A failed attempt or unresolved dependency of one configured entry. */
+export interface EntryIssue {
+  /** Entry whose options and fiber describe the requested and running configuration. */
+  readonly entry: Entry
+  readonly stage: 'import' | 'activation' | 'update' | 'disabled-expression' | 'inject-pending'
+  readonly message: string
+  /** Original rejection, retained only in process for duplicate rejection handling. */
+  readonly error?: unknown
+}
+
+const observedRejections = new Map<unknown, number>()
+
+/**
+ * Whether a rejection is already being reported by an entry audit.
+ * @param reason - the process rejection reason.
+ * @returns true during the audit's rejection checkpoint.
+ */
+export function isObservedEntryRejection(reason: unknown): boolean {
+  return observedRejections.has(reason)
+}
+
+/**
+ * Render nested causes and aggregate members without cycling or dropping plugin stacks.
+ * @param error - the thrown value.
+ * @returns the diagnostic text.
+ */
+export function formatEntryError(error: unknown): string {
+  const details: string[] = []
+  const seen = new Set<Error>()
+  const visit = (value: unknown): void => {
+    if (!(value instanceof Error)) {
+      details.push(String(value))
+      return
+    }
+    if (seen.has(value)) return
+    seen.add(value)
+    details.push(value.stack ?? value.message)
+    if (value.cause !== undefined) visit(value.cause)
+    if (value instanceof AggregateError) value.errors.forEach(visit)
+  }
+  visit(error)
+  return details.join('\n')
+}
+
+/**
+ * Read one entry's latest failed attempt or unresolved services without activating it.
+ * @param entry - the current Loader entry.
+ * @returns its issue, or undefined when disabled or active without a failed update.
+ */
+export async function entryIssue(entry: Entry): Promise<EntryIssue | undefined> {
+  try {
+    if (entry.disabled) return undefined
+  } catch (error) {
+    return { entry, stage: 'disabled-expression', message: formatEntryError(error), error }
+  }
+  if (entry.lastFailure !== undefined) {
+    const { stage, error } = entry.lastFailure
+    return { entry, stage, message: `${stage === 'import' ? 'failed to import: ' : ''}${formatEntryError(error)}`, error }
+  }
+  const fiber = entry.fiber
+  if (fiber === undefined) return { entry, stage: 'import', message: 'failed to import' }
+  if (fiber.state === ACTIVE) return undefined
+  if (fiber.state === FAILED) {
+    try {
+      await fiber.await()
+    } catch (error) {
+      return { entry, stage: 'activation', message: formatEntryError(error), error }
+    }
+    return undefined
+  }
+  if (fiber.state === PENDING) {
+    const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
+    return {
+      entry, stage: 'inject-pending',
+      message: `pending (waiting for ${missing.length === 1 ? 'service' : 'services'}: ${missing.join(', ') || 'unknown'})`,
+    }
+  }
+  return { entry, stage: 'activation', message: `fiber state ${String(fiber.state)}` }
+}
+
+/**
+ * Collect current entry issues and coalesce Loader's duplicate process rejections.
+ * @param ctx - the context owning the Loader.
+ * @returns current issues; no historical failure registry is retained.
+ */
+export async function inspectEntryIssues(ctx: Context): Promise<EntryIssue[]> {
+  const issues: EntryIssue[] = []
+  for (const entry of ctx.loader.entries()) {
+    const issue = await entryIssue(entry)
+    if (issue !== undefined) issues.push(issue)
+  }
+  const reasons = issues.filter(issue => 'error' in issue).map(issue => issue.error)
+  if (reasons.length > 0) {
+    for (const reason of reasons) observedRejections.set(reason, (observedRejections.get(reason) ?? 0) + 1)
+    try {
+      await new Promise<void>(resolve => setImmediate(resolve))
+    } finally {
+      for (const reason of reasons) {
+        const count = observedRejections.get(reason)
+        if (count !== undefined && count > 1) observedRejections.set(reason, count - 1)
+        else observedRejections.delete(reason)
+      }
+    }
+  }
+  return issues
+}

+ 24 - 135
packages/boot/app-boot/src/external-bundles.ts

@@ -1,18 +1,4 @@
-/**
- * External bundle composition and the profile-manifest operations behind
- * installing, enabling, and disabling bundles.
- *
- * An external (`runtime` stage) bundle never mounts its rows directly into the
- * built-in tree: its inserted rows are wrapped in one contained group per
- * bundle, under the ids its patch declares. A group is the unit the Loader
- * 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
- */
+/** Bundle patch ownership and the profile manifest's installed/enabled layer lists. */
 
 import { join } from 'node:path'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
@@ -22,149 +8,52 @@ import {
   readProfileManifest, resolveBundleDir, writeProfileManifest, type ProfileLayer, type ProfileManifest,
 } from './profile.ts'
 
-/** Loader builtin name of the contained group every `runtime` bundle mounts under. */
-export const CONTAINED_GROUP_MODULE = 'cordis:contained-group'
-
-/** Prefix of the group id one external bundle's rows mount under. */
-export const BUNDLE_GROUP_PREFIX = 'bundle/'
-
-/**
- * The group id one external bundle's rows mount under. `/` rather than `:`
- * because `:` is the Loader's nested-id separator (`EntryTree.sep`), and an
- * id containing it would not resolve through `loader.update(id, …)`.
- * @param packageName - the bundle's package name.
- * @returns the group entry id.
- */
-export function bundleGroupId(packageName: string): string {
-  return `${BUNDLE_GROUP_PREFIX}${packageName}`
-}
-
-/** One id an external bundle's patch introduces twice: a row inserted again, or a row spelling the group's or a wrapper's id. */
+/** One row declared twice within a bundle layer. */
 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. */
-export interface ComposedExternalLayer {
-  /**
-   * 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[]
-  /**
-   * Every id the layer introduces — its inserted rows, the rows its config
-   * overrides set, its group, and each wrapper group — with the module each
-   * names; a repeated id keeps its first module.
-   */
-  rows: Map<string, string>
-  /**
-   * Ids the layer introduces more than once, in order of repetition. A config
-   * override restating a row under the group that already holds it is not
-   * one; the same id twice in one config list, or set under another group, is.
-   */
-  duplicates: DuplicateRow[]
-  /** Ids outside the bundle that its patch overrides; not containable, reported for visibility. */
-  overrides: string[]
+/** Static row ownership and overrides of one unmodified bundle patch list. */
+export interface AnalyzedBundleLayer {
+  readonly patches: PatchOptions[]
+  readonly rows: Map<string, string>
+  readonly duplicates: DuplicateRow[]
+  readonly overrides: string[]
 }
 
 /**
- * Whether a layer mounts isolated: an external bundle the profile does not
- * stage at boot.
- * @param layer - the resolved layer.
- * @returns true when the layer mounts as a contained group.
+ * Whether an enabled bundle may fail without rejecting application startup.
+ * @param layer - the resolved provenance and stage.
+ * @returns true only for external runtime layers.
  */
-export function isContainedLayer(layer: ProfileLayer): boolean {
+export function isOptionalRuntimeLayer(layer: ProfileLayer): boolean {
   return layer.trust === 'external' && layer.stage === 'runtime'
 }
 
 /**
- * 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 introduces
- * 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, and a patch that replaces the target's config
- * has removed the wrapper, so the next insert creates a new one under the
- * same id. An id-targeted patch passes through unchanged and is reported as
- * an override when it addresses a row the bundle did not introduce; the rows
- * it sets as a group's config count as the bundle's own, since they mount
- * as children like inserted ones. Every id, declared or generated, goes
- * through one registration, so a row spelling a wrapper's id is a duplicate,
- * and so is a config row the bundle already declared under another group,
- * or listed twice: the Loader would move the first and reject the second.
- * Ids are indexed before any patch is emitted, so an insert into a group the
- * bundle introduces later in its list still counts as its own.
- * @param layer - the resolved external layer.
- * @returns the patches to mount, the ids the layer introduces, and the ids it repeats.
+ * Inspect the rows a layer introduces without changing their ids or parents.
+ * @param layer - the bundle patch list.
+ * @returns its declared rows, duplicates, and external override targets.
  */
-export function composeExternalLayer(layer: ProfileLayer): ComposedExternalLayer {
-  const groupId = bundleGroupId(layer.packageName)
+export function analyzeBundleLayer(layer: ProfileLayer): AnalyzedBundleLayer {
   const rows = new Map<string, string>()
   const declaredUnder = new Map<string, string | undefined>()
   const duplicates: DuplicateRow[] = []
-  const claim = (rowId: string, moduleName: string, target?: string): void => {
-    if (rows.has(rowId)) {
-      duplicates.push({ rowId, moduleName })
-      return
-    }
-    rows.set(rowId, moduleName)
-    declaredUnder.set(rowId, target)
-  }
   visitIdentifiedRows(layer.patches, ({ id, row, source, place, listed }) => {
-    // A root insert lands in the bundle's group: that is the group it declares under.
-    const target = place.target ?? groupId
-    if (source === 'config') {
-      // A config override restates the children it keeps: the same id declared
-      // under the same group before is that row. Twice in one list, or under
-      // another group, it would mount as a rejected duplicate or move the row.
-      if (listed.has(id) || (rows.has(id) && declaredUnder.get(id) !== target)) {
+    if (rows.has(id)) {
+      if (source === 'insert' || listed.has(id) || declaredUnder.get(id) !== place.target) {
         duplicates.push({ rowId: id, moduleName: row.name })
-      } else if (!rows.has(id)) {
-        rows.set(id, row.name)
-        declaredUnder.set(id, target)
       }
     } else {
-      claim(id, row.name, target)
+      rows.set(id, row.name)
+      declaredUnder.set(id, place.target)
     }
   })
-  claim(groupId, CONTAINED_GROUP_MODULE)
-  const wrappers = new Map<string, string>()
-  const generated = new Set<string>()
-  const overrides: string[] = []
-  const patches: PatchOptions[] = [{ insert: [{ id: groupId, name: CONTAINED_GROUP_MODULE, group: true, config: [] }] }]
-  for (const patch of layer.patches) {
-    if (patch.insert === undefined) {
-      if (patch.id !== undefined) {
-        if (!rows.has(patch.id)) overrides.push(patch.id)
-        // The target's children are replaced, wrapper included.
-        if (patch.config !== undefined) wrappers.delete(patch.id)
-      }
-      patches.push(structuredClone(patch))
-      continue
-    }
-    const inserted = structuredClone(patch.insert)
-    const target = patch.id ?? groupId
-    if (rows.has(target)) {
-      patches.push({ id: target, insert: inserted })
-      continue
-    }
-    const wrapper = wrappers.get(target)
-    if (wrapper !== undefined) {
-      patches.push({ id: wrapper, insert: inserted })
-      continue
-    }
-    const wrapperId = `${groupId}/in/${target}`
-    if (!generated.has(wrapperId)) {
-      generated.add(wrapperId)
-      claim(wrapperId, CONTAINED_GROUP_MODULE)
-    }
-    wrappers.set(target, wrapperId)
-    patches.push({ id: target, insert: [{ id: wrapperId, name: CONTAINED_GROUP_MODULE, group: true, config: inserted }] })
-  }
-  return { patches, rows, duplicates, overrides }
+  const overrides = layer.patches.flatMap(patch => (
+    patch.insert === undefined && patch.id !== undefined && !rows.has(patch.id) ? [patch.id] : []
+  ))
+  return { patches: layer.patches, rows, duplicates, overrides }
 }
 
 /**

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

@@ -20,7 +20,7 @@ import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from
 import type {} from '@deepseek-ai/cordis-plugin-hmr'
 import { watchConfig } from './watch-config.ts'
 import type {} from '@deepseek-ai/dsh-system-prompt'
-import { ContainedGroup, ensurePluginFailures, isContainedEntry } from './contained-group.ts'
+import { formatEntryError, inspectEntryIssues, isObservedEntryRejection } from './entry-issues.ts'
 import { visitPatchRows } from './patch-rows.ts'
 
 declare module '@deepseek-ai/cordis' {
@@ -52,20 +52,17 @@ export {
   type ProfileModuleFallbackOptions,
   type ProfileTemplate,
 } from './profile.ts'
-export { ensurePluginFailures } from './contained-group.ts'
+export { entryIssue, inspectEntryIssues, type EntryIssue } from './entry-issues.ts'
 export {
-  bundleGroupId, disableBundle, enableBundle, reconcileInstalledBundles,
-  type BundleReconciliation, type ComposedExternalLayer, type DuplicateRow,
+  disableBundle, enableBundle, reconcileInstalledBundles,
+  type BundleReconciliation, type AnalyzedBundleLayer, type DuplicateRow,
 } from './external-bundles.ts'
 export {
   claimLayerIds, composeProfileStack, formatRowConflict,
   type ComposedStack, type LayerOwnership, type RowConflict, type StackUserLayer,
 } from './compose-stack.ts'
 export { ProfileRuntime, type RowOrigin } from './profile-runtime.ts'
-export {
-  PLUGIN_PROBE_DIR, probePackage, readProbeCache, writeProbeCache,
-  type PluginProbe, type PluginProbeRow, type ProbeOptions,
-} from './probe.ts'
+export { readPackageMetadata, type PackageMetadata, type PackageMetadataOptions, type PackageMetadataRow } from './package-metadata.ts'
 
 /**
  * Resolve the config to boot. Replay swaps a `cordis.yml` basename for
@@ -274,7 +271,7 @@ export async function watchUserPatches(
   if (hmr === undefined) throw new Error(`${binName}: user patch-layer watching requires the Cordis HMR service`)
   const entry = bootstrapIncludes.get(ctx)
   if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`)
-  const reapply = options.reapply ?? (async (): Promise<void> => {
+  const apply = options.reapply ?? (async (): Promise<void> => {
     // Re-read the include's non-patch options per refresh so a writer that
     // updates another option between refreshes is not silently reverted.
     const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
@@ -284,11 +281,14 @@ export async function watchUserPatches(
         patches: loadOptionalPatches(binName, filename) ?? [],
       },
     })
+  })
+  const reapply = async (): Promise<void> => {
+    await apply()
     await ctx.loader.await()
     await Promise.allSettled([...ctx.loader.entries()].map(entry => Promise.resolve(entry.fiber?.await())))
     const failures = await inactiveEntries(ctx)
     if (failures.length > 0) throw new Error(activationDiagnostic(binName, 'warning', failures).trimEnd())
-  })
+  }
   const register = watchConfig(ctx, filename, hmr.config, reapply)
   try {
     return await register
@@ -566,10 +566,6 @@ export async function mountRootInclude(
   // by name. Both builtins load through the ambient module pipeline, so neither
   // depends on the included tree's own specifier resolution.
   ctx.loader.builtins.group = Group
-  // `cordis:contained-group`: the group external bundles mount under, whose
-  // rows fail individually and are recorded on the root's failure registry.
-  ctx.loader.builtins['contained-group'] = ContainedGroup
-  ensurePluginFailures(ctx)
   // Pinned id: the bootstrap include is app glue, not a config row, and its
   // id appears in Loader failure chains — a random id would make startup
   // diagnostics unstable across runs (and snapshot fixtures).
@@ -606,33 +602,6 @@ export interface FailLoudProcess {
   exit(code: number): void
 }
 
-// Loader rc.5 derives and drops a rejected promise after a fiber fails. Keep
-// exact reasons already folded into the boot diagnostic visible through the
-// next process rejection checkpoint so the process guard can coalesce them.
-const assembledActivationRejections = new Map<unknown, number>()
-
-function retainAssembledRejection(reason: unknown): void {
-  assembledActivationRejections.set(reason, (assembledActivationRejections.get(reason) ?? 0) + 1)
-}
-
-function releaseAssembledRejection(reason: unknown): void {
-  const count = assembledActivationRejections.get(reason)
-  if (count === undefined || count === 1) {
-    assembledActivationRejections.delete(reason)
-  } else {
-    assembledActivationRejections.set(reason, count - 1)
-  }
-}
-
-async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Promise<void> {
-  for (const reason of reasons) retainAssembledRejection(reason)
-  try {
-    await new Promise<void>(resolve => setImmediate(resolve))
-  } finally {
-    for (const reason of reasons) releaseAssembledRejection(reason)
-  }
-}
-
 /**
  * How long {@link installFailLoud} waits for its `release` hook before exiting
  * anyway. A wedged disposer must delay the fatal exit, never cancel it.
@@ -675,7 +644,7 @@ export function installFailLoud(
 ): () => void {
   let exiting = false
   const handler = (err: unknown): void => {
-    if (assembledActivationRejections.has(err)) return
+    if (isObservedEntryRejection(err)) return
     // A release in flight already owns the exit. Swallow later rejections
     // (teardown's own included) rather than reporting a second failure over the
     // real one or letting Node kill the process before the terminal is back.
@@ -710,93 +679,13 @@ export function installFailLoud(
   return uninstall
 }
 
-/** The slice of `process` {@link installRuntimeGuards} needs. */
-export interface RuntimeGuardProcess {
-  on(event: 'unhandledRejection' | 'uncaughtException', handler: (err: unknown) => void): unknown
-  off(event: 'unhandledRejection' | 'uncaughtException', handler: (err: unknown) => void): unknown
-  exit(code: number): unknown
-}
-
-/**
- * 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
- * plugin's stray continuation, and exiting would take every session down for
- * 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 report - sink for the report lines.
- * @param proc - the process slice to register on; defaults to `process`.
- * @returns a disposer removing both handlers.
- */
-export function installRuntimeGuards(
-  binName: string,
-  report: (line: string) => void,
-  proc: RuntimeGuardProcess = process,
-): () => void {
-  const onRejection = (err: unknown): void => {
-    report(`${binName}: unhandled rejection after boot (not attributed to a plugin; the process keeps running): ${formatActivationError(err)}`)
-  }
-  const onException = (err: unknown): void => {
-    report(`${binName}: uncaught exception after boot; exiting: ${formatActivationError(err)}`)
-    proc.exit(1)
-  }
-  proc.on('unhandledRejection', onRejection)
-  proc.on('uncaughtException', onException)
-  return () => {
-    proc.off('unhandledRejection', onRejection)
-    proc.off('uncaughtException', onException)
-  }
-}
-
 /**
  * Value mirrors used because Cordis's const enum has no runtime object to import.
  * Keep aligned with `packages/extensions/tool-cordis/src/fiber-state.ts` and
  * `packages/client/web/src/loader-status.ts`.
  */
-const FIBER_PENDING = 0 as FiberState.PENDING
-const FIBER_ACTIVE = 2 as FiberState.ACTIVE
 const FIBER_FAILED = 3 as FiberState.FAILED
 
-/**
- * Entry ids whose presence defines a usable DSH application.
- *
- * The list is global rather than profile metadata. Missing or disabled ids do
- * not affect startup; an enabled listed entry must activate. The list covers
- * shared Agent execution, application endpoints, and Web bootstrap/transport.
- */
-const requiredStartupEntryIds = new Set<string>([
-  'agent-loop',
-  'webserver',
-  'modules',
-  'connection',
-  'headless-runner',
-  'acp',
-  'sdk-jsonrpc-server',
-])
-
-/** Render plugin stacks, nested causes, and aggregate member failures once per error. */
-function formatActivationError(error: unknown): string {
-  const details: string[] = []
-  const seen = new Set<Error>()
-  function visit(value: unknown): void {
-    if (!(value instanceof Error)) {
-      details.push(String(value))
-      return
-    }
-    if (seen.has(value)) return
-    seen.add(value)
-    details.push(value.stack ?? value.message)
-    if (value.cause !== undefined) visit(value.cause)
-    if (value instanceof AggregateError) value.errors.forEach(visit)
-  }
-  visit(error)
-  return details.join('\n')
-}
-
 interface InactiveEntry {
   /** Loader entry used to identify the bootstrap Include and required ids. */
   entry: Entry
@@ -810,44 +699,16 @@ interface InactiveEntry {
  * duplicate Loader notifications through the next process rejection checkpoint.
  */
 async function inactiveEntries(ctx: Context): Promise<InactiveEntry[]> {
-  const failures: InactiveEntry[] = []
-  const rejectionReasons: unknown[] = []
-  for (const entry of ctx.loader.entries()) {
-    const subject = `${entry.options.id} (${entry.options.name})`
-    try {
-      if (entry.disabled) continue
-    } catch (error) {
-      failures.push({ entry, diagnostic: `${subject}: disabled expression failed: ${formatActivationError(error)}` })
-      continue
-    }
-    const fiber = entry.fiber
-    if (fiber === undefined) {
-      failures.push({ entry, diagnostic: `${subject}: failed to import` })
-      continue
-    }
-    const state = fiber.state
-    if (state === FIBER_ACTIVE) continue
-    if (state === FIBER_FAILED) {
-      try {
-        await fiber.await()
-      } catch (error) {
-        rejectionReasons.push(error)
-        failures.push({ entry, diagnostic: `${subject}: ${formatActivationError(error)}` })
-      }
-      continue
-    }
-    if (state === FIBER_PENDING) {
-      const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
-      failures.push({
-        entry,
-        diagnostic: `${subject}: pending (waiting for ${missing.length === 1 ? 'service' : 'services'}: ${missing.join(', ') || 'unknown'})`,
-      })
-    } else {
-      failures.push({ entry, diagnostic: `${subject}: fiber state ${String(state)}` })
-    }
-  }
-  if (rejectionReasons.length > 0) await observeLoaderRejectionCheckpoint(rejectionReasons)
-  return failures
+  return (await inspectEntryIssues(ctx)).map(issue => ({
+    entry: issue.entry,
+    diagnostic: `${issue.entry.options.id} (${issue.entry.options.name}): ${issue.stage === 'disabled-expression' ? 'disabled expression failed: ' : ''}${issue.message}`,
+  }))
+}
+
+/** The policy follows row provenance, including the owner of nested Includes. */
+function isOptionalEntry(ctx: Context, entry: Entry): boolean {
+  const origin = ctx.get('profileRuntime')?.originOfEntry(entry)
+  return origin?.trust === 'external' && origin.stage === 'runtime'
 }
 
 /** Render an inactive-entry diagnostic with a count and severity label. */
@@ -864,9 +725,9 @@ function activationDiagnostic(
 /**
  * Apply DSH startup policy to a settled Loader tree.
  *
- * Inactive entries from the global required list reject startup. Other
+ * Inactive built-in, boot-staged, and unowned entries reject startup. External runtime
  * inactive entries produce one warning and leave successful siblings running.
- * Required ids absent from the tree, and disabled required entries, are ignored.
+ * Disabled entries are ignored. Provenance is read from the prepared profile runtime.
  * A throwing disabled expression is an entry failure, not a disabled entry.
  * The bootstrap Include must activate so unreadable or invalid root config is fatal.
  * @param ctx - the settled context whose Loader entries to audit.
@@ -885,7 +746,7 @@ export async function auditStartupEntries(
   const optional: InactiveEntry[] = []
   for (const failure of failures) {
     const target = failure.entry === bootstrapIncludes.get(ctx)
-      || requiredStartupEntryIds.has(failure.entry.options.id) ? required : optional
+      || !isOptionalEntry(ctx, failure.entry) ? required : optional
     target.push(failure)
   }
   if (optional.length > 0) warn(activationDiagnostic(binName, 'warning', optional))
@@ -917,7 +778,7 @@ export function warnNestedFiberFailures(
       // that entry; the entry's own root fiber is the one the entry holds.
       const owner = fiber.entry
       if (fiber.state !== FIBER_FAILED || owner === undefined || owner.fiber === fiber) continue
-      if (isContainedEntry(owner)) continue
+      if (isOptionalEntry(ctx, owner)) continue
       count += 1
       warn(`${binName}: nested fiber under ${owner.options.name} failed (${runtime.name}); the entry itself stays active`)
     }
@@ -935,7 +796,7 @@ export function warnNestedFiberFailures(
  * embeds Include while leaving Loader external, so the built include tree and
  * host share one Loader peer. Loader settlement drains entry work without
  * rejecting the whole tree. The final {@link auditStartupEntries} call rejects
- * failures in the global required list and warns about other failed, missing,
+ * failures in required profile rows and warns about other failed, missing,
  * and pending entries while successful siblings remain active. Later unhandled
  * rejections remain covered by {@link installFailLoud}. Built bins need the Loader's native
  * helper for bare plugin specifiers; relative specifiers do not.
@@ -1001,7 +862,7 @@ export async function boot(
       deepest = deepest.cause
     }
     const stack = deepest instanceof AggregateError
-      ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatActivationError).join('\n')}`
+      ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatEntryError).join('\n')}`
       : deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : ''
     throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause })
   }

+ 127 - 0
packages/boot/app-boot/src/package-metadata.ts

@@ -0,0 +1,127 @@
+/** Installed package declarations read without importing or executing their modules. */
+import { createRequire } from 'node:module'
+import { existsSync, readFileSync, realpathSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { loadOverlayPatches } from './index.ts'
+import { readProfileManifest, resolveBundleDir } from './profile.ts'
+import { visitPatchRows } from './patch-rows.ts'
+import type { DshPluginDeclaration } from '@deepseek-ai/dsh-package-manifest'
+
+/** One configured bundle row; conditions remain unevaluated until it mounts. */
+export interface PackageMetadataRow {
+  readonly id?: string
+  readonly name: string
+  readonly gated: boolean
+}
+
+/** Static facts; a declaration is not an activation verdict. */
+export interface PackageMetadata {
+  readonly packageName: string
+  readonly version?: string
+  readonly description?: string
+  readonly title?: string
+  readonly kind: 'bundle' | 'plugin' | 'unknown'
+  readonly enginesDsh?: string
+  /** Physical resolution only; null when unavailable, not an execution guarantee. */
+  readonly cordisSameCopy: boolean | null
+  readonly rows: readonly PackageMetadataRow[]
+  readonly overrides: readonly string[]
+  readonly addable: readonly DshPluginDeclaration[]
+}
+
+/** Locations needed to read one installed package. */
+export interface PackageMetadataOptions {
+  readonly binName: string
+  readonly packageName: string
+  readonly profileDir: string
+  readonly installAnchor: string
+}
+
+/**
+ * Read package identity, bundle rows and explicitly addable modules without loading code.
+ * @param options - installation and profile resolution anchors.
+ * @returns declared metadata; an undeclared main export remains unknown.
+ * @throws when the package or its declaration cannot be read or parsed.
+ */
+export function readPackageMetadata(options: PackageMetadataOptions): PackageMetadata {
+  const { binName, packageName, installAnchor, profileDir } = options
+  const dir = resolveBundleDir(binName, packageName, installAnchor, profileDir)
+  const manifest = readProfileManifest(binName, dir)
+  const declarations: unknown = manifest.dsh?.plugins
+  const addable: DshPluginDeclaration[] = []
+  if (declarations !== undefined) {
+    if (!Array.isArray(declarations)) throw new TypeError(`${packageName}: dsh.plugins must be an array`)
+    const names = new Set<string>()
+    for (const value of declarations as unknown[]) {
+      if (!isRecord(value) || typeof value.name !== 'string' || value.name.length === 0
+        || (value.title !== undefined && typeof value.title !== 'string')) {
+        throw new TypeError(`${packageName}: dsh.plugins entries require a name and an optional string title`)
+      }
+      const name = value.name
+      const subpath = name.replace(/^\.\//, '')
+      if (name !== '.' && (subpath.startsWith('/') || subpath.split('/').some(part => part === '..' || part === '.' || part === '')
+        || /[:\\]/.test(subpath))) throw new TypeError(`${packageName}: invalid plugin subpath ${JSON.stringify(name)}`)
+      const key = name === '.' ? '.' : subpath
+      if (names.has(key)) throw new TypeError(`${packageName}: repeated plugin subpath ${JSON.stringify(name)}`)
+      names.add(key)
+      addable.push({
+        name,
+        ...value.title === undefined ? {} : { title: value.title },
+        ...value.config === undefined ? {} : { config: value.config },
+      })
+    }
+  }
+  const rows: PackageMetadataRow[] = []
+  const own = new Set<string>()
+  const declaredBundle = manifest.dsh?.bundle?.patch
+  const patches = declaredBundle === undefined ? [] : loadOverlayPatches(binName, join(dir, declaredBundle))
+  visitPatchRows(patches, (row) => {
+    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 && row.disabled !== false })
+  })
+  const overrides = patches.flatMap(patch => patch.insert === undefined && typeof patch.id === 'string' && !own.has(patch.id) ? [patch.id] : [])
+  const cordisSameCopy = compareCordisCopies(dir)
+  return {
+    packageName,
+    ...manifest.version === undefined ? {} : { version: manifest.version },
+    ...manifest.description === undefined ? {} : { description: manifest.description },
+    ...manifest.dsh?.title === undefined ? {} : { title: manifest.dsh.title },
+    ...manifest.engines?.dsh === undefined ? {} : { enginesDsh: manifest.engines.dsh },
+    kind: declaredBundle !== undefined ? 'bundle' : addable.length > 0 ? 'plugin' : 'unknown',
+    cordisSameCopy, rows, overrides, addable,
+  }
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/** Resolve physical package identity through a packaged-runtime module proxy. */
+function cordisPackageDir(resolved: string): string | undefined {
+  let dir = dirname(resolved.startsWith('file:') ? fileURLToPath(resolved) : resolved)
+  for (;;) {
+    const path = join(dir, 'package.json')
+    if (existsSync(path)) {
+      const manifest = JSON.parse(readFileSync(path, 'utf8')) as { name?: string; dsh?: { moduleFallback?: { targets?: Record<string, string> } } }
+      if (manifest.name === '@deepseek-ai/cordis') {
+        const target = Object.values(manifest.dsh?.moduleFallback?.targets ?? {})[0]
+        return target === undefined ? realpathSync(dir) : cordisPackageDir(target)
+      }
+    }
+    const parent = dirname(dir)
+    if (parent === dir) return undefined
+    dir = parent
+  }
+}
+
+/** Compare resolvable physical peers; a missing or unreadable peer has no verdict. */
+function compareCordisCopies(dir: string): boolean | null {
+  try {
+    const installed = cordisPackageDir(createRequire(join(dir, 'package.json')).resolve('@deepseek-ai/cordis'))
+    const host = cordisPackageDir(import.meta.resolve('@deepseek-ai/cordis'))
+    return installed === undefined || host === undefined ? null : installed === host
+  } catch {
+    return null
+  }
+}

+ 1 - 1
packages/boot/app-boot/src/patch-rows.ts

@@ -1,6 +1,6 @@
 /**
  * The one walk over the rows a patch list introduces. Composition, ownership,
- * the package probe, and patch loading all read those rows and their group
+ * static package metadata, and patch loading all read those rows and their group
  * children; sharing the walk keeps them reading the same tree.
  * @module @deepseek-ai/dsh-app-boot/patch-rows
  */

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

@@ -1,50 +0,0 @@
-/**
- * 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; the environment carries the token
- * the report echoes. 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
-// The report is the parent's to receive: its token leaves the environment and
-// the channel's `send` leaves `process` before any of the package's code runs,
-// so nothing that code sends at import can pass as the report.
-const token = process.env.DSH_PROBE_REPORT ?? ''
-delete process.env.DSH_PROBE_REPORT
-const send = process.send?.bind(process)
-Reflect.deleteProperty(process, 'send')
-
-/** 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 = { token, 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)
-send?.(report, undefined, undefined, () => { process.disconnect() })

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

@@ -1,63 +0,0 @@
-/**
- * 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 token the parent handed the child for this run; a message without it is not the report. */
-  token: string
-  /** 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 (typeof value.token !== 'string') 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
-}

+ 0 - 475
packages/boot/app-boot/src/probe.ts

@@ -1,475 +0,0 @@
-/**
- * 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,
- * 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
- * (`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
- */
-
-import { spawn } from 'node:child_process'
-import { randomUUID } from 'node:crypto'
-import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
-import { dirname, join } from 'node:path'
-import { fileURLToPath } from 'node:url'
-import { withoutSensitiveEnv } from '@deepseek-ai/dsh-launch-environment'
-import { loadOverlayPatches } from './index.ts'
-import { readProfileManifest, resolveBundleDir, type ProfileManifest } from './profile.ts'
-import { visitPatchRows } from './patch-rows.ts'
-import { isRecord, parseChildReport, type ChildReport } from './probe-report.ts'
-
-/** Directory under a profile holding one probe record per package. */
-export const PLUGIN_PROBE_DIR = '.dsh-plugins'
-
-/**
- * The probe record format. Bumped when the probe's verdicts change meaning,
- * so a record an older probe wrote is re-probed instead of trusted: 2 made a
- * bare function export a `library` rather than a `plugin`.
- */
-export const PLUGIN_PROBE_FORMAT = 2
-
-/** One row a bundle's patch inserts, as its own patch declares it. */
-export interface PluginProbeRow {
-  /** The row id the bundle declares, or undefined when the row leaves it to the Loader. */
-  readonly id?: string
-  /** The module specifier the row names. */
-  readonly name: string
-  /** Whether the row carries a `disabled` gate. */
-  readonly gated: boolean
-}
-
-/** One agent-plane module a package declares addable to a composition. */
-export interface PluginProbeAddable {
-  /** The subpath or bare specifier, as declared in `dsh.plugins[].name`. */
-  readonly name: string
-  /** Display title, when declared. */
-  readonly title?: string
-  /** Default row config, when declared. */
-  readonly config?: unknown
-  /** Whether the child process imported the module and found a plugin. */
-  readonly ok: boolean
-  /** The import failure, when `ok` is false. */
-  readonly error?: string
-  /** The module's `Config` schema envelope, when it declares one. */
-  readonly configSchema?: unknown
-}
-
-/** What the probe learned about one installed package. */
-export interface PluginProbe {
-  /** The package name. */
-  readonly packageName: string
-  /** The package version as installed. */
-  readonly version?: string
-  /** Free text from the manifest. */
-  readonly description?: string
-  /** Display title from `dsh.title`, when declared. */
-  readonly title?: string
-  /**
-   * `bundle` (declares `dsh.bundle`); `plugin` (declares itself to dsh — a
-   * `dsh` section or a dependency on `@deepseek-ai/cordis` — and its main
-   * export is a cordis plugin); else `library`. A function export alone is
-   * not a plugin: `lodash` exports one too.
-   */
-  readonly kind: 'bundle' | 'plugin' | 'library'
-  /**
-   * 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
-  /** Why the import or the cordis check failed, when `ok` is false. */
-  readonly reason?: string
-  /** Whether the package resolves `@deepseek-ai/cordis` to the harness's own copy; null when unknown. */
-  readonly cordisSameCopy: boolean | null
-  /** The harness version range the package declares in `engines.dsh`. */
-  readonly enginesDsh?: string
-  /** Rows the bundle's patch introduces, inserted or set as a group's config, empty for a non-bundle. */
-  readonly rows: readonly PluginProbeRow[]
-  /** Ids of rows outside the bundle that its patch overrides. */
-  readonly overrides: readonly string[]
-  /** Agent-plane modules the package declares addable. */
-  readonly addable: readonly PluginProbeAddable[]
-  /** The main export's `Config` schema envelope, for a `plugin` kind. */
-  readonly configSchema?: unknown
-  /** ISO time of the probe. */
-  readonly checkedAt: string
-}
-
-/** Inputs of {@link probePackage}. */
-export interface ProbeOptions {
-  /** The diagnostic prefix on thrown errors. */
-  binName: string
-  /** The profile directory the package is installed in. */
-  profileDir: string
-  /** Absolute path of the dsh app's package.json (first resolution anchor). */
-  installAnchor: string
-  /** The package to probe. */
-  packageName: string
-  /** Bound on the child process; default 20 seconds. */
-  timeoutMs?: number
-  /** The Node executable; defaults to the running one. */
-  nodeExecutable?: string
-}
-
-/** The manifest slice the probe reads beyond the profile launcher's. */
-interface ProbedManifest extends ProfileManifest {
-  main?: string
-  exports?: unknown
-  engines?: Record<string, string>
-}
-
-/**
- * The child entry beside this module: the TypeScript source under a source
- * launch, run through tsx; the bundled `lib/probe-child.js` otherwise.
- */
-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))]
-}
-
-/**
- * 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.
- * The child gets the parent environment minus credential-shaped names, plus
- * a per-run token it echoes in its report — a message without the token is
- * the package's own, not the report — and the probe settles only once the
- * child closed, so its pipes and channel are gone when the caller continues.
- */
-function runChild(options: ProbeOptions, packageDir: string, mainSpecifier: string, addable: string[]): Promise<ChildReport> {
-  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
-  return new Promise((resolve, reject) => {
-    const token = randomUUID()
-    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: { ...withoutSensitiveEnv(process.env), NODE_NO_WARNINGS: '1', [PROBE_REPORT_VARIABLE]: token },
-      },
-    )
-    // The tail of stderr, for the failure text: a package that floods stderr at
-    // import must not grow this process's heap by as much.
-    const err = new StreamTail(STDERR_TAIL_BYTES)
-    child.stderr?.on('data', (chunk: Buffer) => { err.push(chunk) })
-    // The outcome lands on `close`. A kill — after the report, or at the
-    // timeout — records its outcome and waits for the close it causes, so the
-    // child's pipes and channel are gone when the caller continues; a spawn
-    // failure emits `error` with no process to wait for.
-    let outcome: (() => void) | undefined
-    const finish = (next: () => void): void => {
-      /* v8 ignore next -- a report the kill still let through after the timeout decided, or the reverse: the first outcome stands */
-      if (outcome !== undefined) return
-      outcome = next
-      clearTimeout(timer)
-      child.kill('SIGKILL')
-    }
-    const timer = setTimeout(() => {
-      finish(() => { reject(new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`)) })
-    }, timeoutMs)
-    child.on('message', (message) => {
-      const report = parseChildReport(message)
-      /* v8 ignore next -- the imported code finds no process.send; a message through the raw channel would still lack the token */
-      if (report === undefined || report.token !== token) return
-      finish(() => { resolve(report) })
-    })
-    child.on('error', (error) => {
-      clearTimeout(timer)
-      reject(error)
-    })
-    child.on('close', (code) => {
-      clearTimeout(timer)
-      if (outcome !== undefined) {
-        outcome()
-        return
-      }
-      const tail = err.text().trim()
-      reject(new Error(`${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${tail}`))
-    })
-  })
-}
-
-const DEFAULT_TIMEOUT_MS = 20_000
-
-/** How much of the child's stderr the failure text keeps: the end, where the cause usually is. */
-const STDERR_TAIL_BYTES = 16 * 1024
-
-/**
- * The last `limit` bytes a stream delivered, kept as the chunks it arrived in:
- * as more arrives, whole chunks leave the front and the oldest kept chunk is
- * trimmed, so one chunk larger than the limit keeps its end and a small chunk
- * after it never drops what was kept.
- */
-export class StreamTail {
-  private readonly chunks: Buffer[] = []
-  private bytes = 0
-
-  /** @param limit - how many bytes of the end to keep. */
-  constructor(private readonly limit: number) {}
-
-  /**
-   * Take one more chunk from the stream.
-   * @param chunk - the bytes as delivered.
-   */
-  push(chunk: Buffer): void {
-    this.chunks.push(chunk)
-    this.bytes += chunk.length
-    while (this.bytes > this.limit) {
-      const excess = this.bytes - this.limit
-      const first = this.chunks[0] as Buffer
-      if (first.length <= excess) {
-        this.chunks.shift()
-        this.bytes -= first.length
-      } else {
-        this.chunks[0] = first.subarray(excess)
-        this.bytes = this.limit
-      }
-    }
-  }
-
-  /**
-   * The kept end of the stream.
-   * @returns the last bytes delivered, at most the limit, as UTF-8 text.
-   */
-  text(): string {
-    return Buffer.concat(this.chunks).toString('utf8')
-  }
-}
-
-/** The environment name carrying the run's report token; the child removes it before importing anything. */
-const PROBE_REPORT_VARIABLE = 'DSH_PROBE_REPORT'
-
-/** The rows a bundle patch introduces, flattened from nested groups, with the ids of overrides on other rows. */
-function describeBundlePatch(binName: string, patchPath: string): { rows: PluginProbeRow[]; overrides: string[] } {
-  const rows: PluginProbeRow[] = []
-  const own = new Set<string>()
-  const patches = loadOverlayPatches(binName, patchPath)
-  visitPatchRows(patches, (row) => {
-    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 })
-  })
-  const overrides = patches
-    .filter(patch => patch.insert === undefined && typeof patch.id === 'string' && !own.has(patch.id))
-    .map(patch => patch.id as string)
-  return { rows, overrides }
-}
-
-/**
- * Whether a package declares itself to dsh: a `dsh` section in its manifest,
- * or a dependency on `@deepseek-ai/cordis`. A cordis plugin needs at least
- * cordis's types, and the harness's plugin conventions put display and
- * composition metadata under `dsh`; a package with neither that happens to
- * export a function (`lodash`, `debug`) is a library.
- * @param manifest - the package manifest.
- * @returns true for a package that declares itself to dsh.
- */
-function declaresDsh(manifest: ProbedManifest): boolean {
-  if (manifest.dsh !== undefined) return true
-  return '@deepseek-ai/cordis' in (manifest.peerDependencies ?? {}) || '@deepseek-ai/cordis' in (manifest.dependencies ?? {})
-}
-
-/**
- * Probe one installed package: read its manifest, then import it in a child
- * process. The child never runs inside the host, so a package that throws at
- * import, exits, or hangs costs one child process and yields a `ok: false`
- * record with the reason.
- * @param options - where the package lives and how to run the child.
- * @returns the probe record.
- * @throws when the package is not resolvable from the profile, or the child produced no report.
- */
-export async function probePackage(options: ProbeOptions): Promise<PluginProbe> {
-  const packageDir = resolveBundleDir(options.binName, options.packageName, options.installAnchor, options.profileDir)
-  const manifest = readProfileManifest(options.binName, packageDir) as ProbedManifest
-  const declaredBundle = manifest.dsh?.bundle?.patch
-  const declaredAddable = manifest.dsh?.plugins ?? []
-  const { rows, overrides } = declaredBundle === undefined
-    ? { rows: [], overrides: [] }
-    : describeBundlePatch(options.binName, join(packageDir, declaredBundle))
-  const hasMain = manifest.main !== undefined || manifest.exports !== undefined
-  const report = await runChild(options, packageDir, hasMain ? options.packageName : '', declaredAddable.map(entry => entry.name))
-  const cordisSameCopy = sameCordisCopy(report.cordis, resolveHarnessCordis())
-  const kind: PluginProbe['kind'] = declaredBundle !== undefined
-    ? 'bundle'
-    : report.main.isPlugin && declaresDsh(manifest) ? 'plugin' : 'library'
-  const addable: PluginProbeAddable[] = declaredAddable.map((entry) => {
-    // The child reports every declared name; a missing report is a child
-    // that did not run the loop, which the report parse already rejected.
-    /* v8 ignore next */
-    const inspected = report.addable[entry.name] ?? { ok: false, isPlugin: false, configSchema: null, error: 'no report' }
-    return {
-      name: entry.name,
-      ...entry.title === undefined ? {} : { title: entry.title },
-      ...entry.config === undefined ? {} : { config: entry.config },
-      ok: inspected.ok && inspected.isPlugin,
-      ...inspected.error === undefined ? {} : { error: inspected.error },
-      ...inspected.configSchema === null ? {} : { configSchema: inspected.configSchema },
-    }
-  })
-  let reason: string | undefined
-  // The child attaches the thrown value to every failed import; the fallback keeps the type total.
-  /* v8 ignore next */
-  if (hasMain && !report.main.ok) reason = `the package failed to import: ${report.main.error ?? 'unknown error'}`
-  else if (cordisSameCopy === false) reason = 'the package resolves its own copy of @deepseek-ai/cordis instead of the harness\'s; declare cordis as a peer dependency'
-  return {
-    packageName: options.packageName,
-    ...manifest.version === undefined ? {} : { version: manifest.version },
-    ...manifest.description === undefined ? {} : { description: manifest.description },
-    ...manifest.dsh?.title === undefined ? {} : { title: manifest.dsh.title },
-    kind,
-    ok: reason === undefined,
-    ...reason === undefined ? {} : { reason },
-    cordisSameCopy,
-    ...manifest.engines?.dsh === undefined ? {} : { enginesDsh: manifest.engines.dsh },
-    rows,
-    overrides,
-    addable,
-    ...report.main.configSchema === null || report.main.configSchema === undefined ? {} : { configSchema: report.main.configSchema },
-    checkedAt: new Date().toISOString(),
-  }
-}
-
-/**
- * The package directory of the `@deepseek-ai/cordis` copy this harness runs:
- * the one this module resolves, which every built-in plugin shares. Undefined
- * only in an environment that cannot resolve it at all.
- */
-function resolveHarnessCordis(): string | undefined {
-  try {
-    return cordisPackageDir(import.meta.resolve('@deepseek-ai/cordis'))
-  } catch {
-    // Only an embedder without cordis on its module path lands here; the
-    // harness itself always resolves its own peer.
-    /* v8 ignore next */
-    return undefined
-  }
-}
-
-/**
- * Whether the package's cordis is the harness's: the two package directories
- * compare equal. Null when the package resolves no cordis, when the harness
- * cannot resolve its own, or when no cordis manifest encloses what the
- * package resolved, since none of those says the package brought a copy.
- */
-function sameCordisCopy(reported: string | null, harness: string | undefined): boolean | null {
-  if (reported === null) return null
-  /* v8 ignore next -- the harness resolves its own peer; only an embedder without cordis lands here */
-  if (harness === undefined) return null
-  const dir = cordisPackageDir(reported)
-  return dir === undefined ? null : dir === harness
-}
-
-/** The manifest fields that tell a dsh module proxy from the package it stands for. */
-interface ProxyAwareManifest {
-  name?: unknown
-  dsh?: { moduleFallback?: { targets?: Record<string, string> } }
-}
-
-/**
- * The directory of the `@deepseek-ai/cordis` package a resolved module URL
- * belongs to, with symlinks resolved and a dsh module proxy followed to the
- * package it re-exports. Two resolutions of one installed package compare
- * equal this way whether they landed on `src` or `lib`, on a link or its
- * target, or on the proxy a packaged executable writes for its peers; the
- * URLs themselves differ in every one of those cases.
- * @param resolved - the URL `import.meta.resolve('@deepseek-ai/cordis')` gave.
- * @returns the real package directory, or undefined when no cordis manifest encloses the URL.
- */
-export function cordisPackageDir(resolved: string): string | undefined {
-  let dir = dirname(fileURLToPath(resolved))
-  for (;;) {
-    const manifestPath = join(dir, 'package.json')
-    if (existsSync(manifestPath)) {
-      const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as ProxyAwareManifest
-      if (manifest.name === '@deepseek-ai/cordis') {
-        const target = Object.values(manifest.dsh?.moduleFallback?.targets ?? {})[0]
-        return target === undefined ? realpathSync(dir) : cordisPackageDir(target)
-      }
-    }
-    const parent = dirname(dir)
-    if (parent === dir) return undefined
-    dir = parent
-  }
-}
-
-/** The cache file for one package under a profile. */
-function probeCachePath(profileDir: string, packageName: string): string {
-  return join(profileDir, PLUGIN_PROBE_DIR, `${packageName.replaceAll('/', '__')}.json`)
-}
-
-/**
- * Read one package's cached probe record.
- * @param profileDir - the profile directory.
- * @param packageName - the package.
- * @param version - when given, a record for a different version is treated as absent.
- * @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 {
-  const path = probeCachePath(profileDir, packageName)
-  if (!existsSync(path)) return undefined
-  let stored: unknown
-  try {
-    stored = JSON.parse(readFileSync(path, 'utf8'))
-  } catch {
-    return undefined // not JSON: a truncated or hand-edited file is probed again
-  }
-  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
-  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.
- * @param profileDir - the profile directory.
- * @param probe - the record to persist.
- */
-export function writeProbeCache(profileDir: string, probe: PluginProbe): void {
-  const path = probeCachePath(profileDir, probe.packageName)
-  mkdirSync(join(profileDir, PLUGIN_PROBE_DIR), { recursive: true })
-  writeFileSync(path, JSON.stringify({ format: PLUGIN_PROBE_FORMAT, ...probe }, undefined, 2) + '\n')
-}

+ 38 - 34
packages/boot/app-boot/src/profile-runtime.ts

@@ -1,26 +1,12 @@
-/**
- * The `profileRuntime` service: the booted profile's facts and the one
- * recomposition entry point every live change to the host tree goes through —
- * 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.
- * Recompositions run one at a time: each reads the committed composition
- * only after the previous one settled, so a watcher firing while a bundle is
- * being enabled recomposes the enabled tree instead of the one before it.
- * 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.
- * @module @deepseek-ai/dsh-app-boot/profile-runtime
- */
+/** Serialized profile recomposition with declared provenance and observed per-entry outcomes. */
 
 import { Context, Service } from '@deepseek-ai/cordis'
 import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
 import type Include from '@deepseek-ai/cordis-plugin-include'
-import type { ProfilePatchReload } from '@deepseek-ai/dsh-package-manifest'
+import type { BundleStage, ProfilePatchReload } from '@deepseek-ai/dsh-package-manifest'
 import type { ComposedStack, RowConflict } from './compose-stack.ts'
 import type { BundleTrust, Profile, ProfileLayer } from './profile.ts'
+import { inspectEntryIssues, type EntryIssue } from './entry-issues.ts'
 
 declare module '@deepseek-ai/cordis' {
   interface Context {
@@ -33,6 +19,8 @@ declare module '@deepseek-ai/cordis' {
 export interface RowOrigin {
   /** Who supplied the layer that inserted the row. */
   readonly trust: BundleTrust
+  /** Effective startup-failure policy of the supplying bundle. */
+  readonly stage: BundleStage
   /** The bundle package that inserted the row. */
   readonly packageName: string
   /** The package's version, when its manifest declares one. */
@@ -115,11 +103,27 @@ export class ProfileRuntime extends Service {
     if (layer === undefined) return undefined
     return {
       trust: layer.trust,
+      stage: layer.stage,
       packageName: layer.packageName,
       ...layer.version === undefined ? {} : { version: layer.version },
     }
   }
 
+  /**
+   * Resolve provenance within its Loader tree; nested includes inherit their owning entry.
+   * @param entry - the live entry, including an entry inside another Include.
+   * @returns its supplying bundle, or undefined for a user-owned entry.
+   */
+  originOfEntry(entry: Entry): RowOrigin | undefined {
+    const rootTree = this.options.rootEntry()?.subtree
+    let current: Entry | undefined = entry
+    while (current !== undefined) {
+      if (current.parent.tree === rootTree) return this.originOf(current.options.id)
+      current = current.parent.tree.ctx.fiber.entry
+    }
+    return undefined
+  }
+
   /**
    * Row ids the user patch layers disable with a literal `disabled: true`,
    * as the committed composition read them. The set describes the running
@@ -144,40 +148,40 @@ export class ProfileRuntime extends Service {
   }
 
   /**
-   * 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.
-   * Calls queue: one that arrives while another is in flight starts after it
-   * settled and reads what it committed. A rejection is that call's outcome
-   * alone and does not stop the ones behind it.
-   * @param options - `reloadBundles` re-reads the profile manifest first, so a
-   * bundle enabled or installed since boot joins the stack.
-   * @throws when the root include is not mounted, or the Loader rejected the update.
+   * Apply a fresh profile stack and wait for live entries and removed fibers to settle.
+   * Parse/composition failures leave the applied stack unchanged. Accepted options
+   * can coexist with failed entries or fibers running their previous valid config.
+   * Calls serialize; a failed call does not block later changes.
+   * @param options - whether to reread installed bundle layers from disk.
+   * @returns current entry issues after application, without rolling back successful siblings.
+   * @throws when preparation fails or the root Include cannot accept the update.
    */
-  async recompose(options: { reloadBundles?: boolean } = {}): Promise<void> {
+  async recompose(options: { reloadBundles?: boolean } = {}): Promise<readonly EntryIssue[]> {
     const run = this.queue.then(() => this.recomposeNow(options))
     this.queue = run.then(() => undefined, () => undefined)
     return run
   }
 
-  private async recomposeNow(options: { reloadBundles?: boolean }): Promise<void> {
+  private async recomposeNow(options: { reloadBundles?: boolean }): Promise<readonly EntryIssue[]> {
     const entry = this.options.rootEntry()
     if (entry === undefined) throw new Error('profileRuntime: the root include is not mounted')
     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 before = [...this.ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [row.fiber])
     await entry.update({
       config: {
         ...includeConfig,
-        patches: stack.patches,
+        patches: structuredClone(stack.patches),
       },
     })
+    await this.ctx.loader.await()
+    const current = new Set([...this.ctx.loader.entries()].map(row => row.fiber))
+    // Removed entries are absent from Loader.getTasks(), but still own teardown.
+    await Promise.allSettled(before.filter(fiber => fiber.uid === null || !current.has(fiber)).map(fiber => fiber.await()))
+    await this.ctx.loader.await()
     this.committed = { profile, stack }
+    return inspectEntryIssues(this.ctx)
   }
 }
 

+ 1 - 1
packages/boot/app-boot/src/profile.ts

@@ -75,7 +75,7 @@ export interface ProfileLayer {
   packageDir: string
   /** Absolute path of the bundle's patch file. */
   patchPath: string
-  /** Who supplied the layer; decides isolation, id prefixing, and failure semantics. */
+  /** Who supplied the layer; decides row ownership priority and startup failure policy. */
   trust: BundleTrust
   /** Effective mount stage: the profile's override, else the bundle's declaration, else `runtime`. */
   stage: BundleStage

+ 27 - 16
packages/boot/app-boot/tests/app-boot.spec.ts

@@ -8,7 +8,7 @@ import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
 import {
   addHarnessSourceSection, auditStartupEntries, boot,
   FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION,
-  installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
+  installFailLoud, ProfileRuntime, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
 } from '../src/index.ts'
 
 const NAME = 'dsh-test-bin'
@@ -420,7 +420,16 @@ describe('installFailLoud', () => {
     installFailLoud(NAME, proc)
     const error = new Error('assembled activation failure')
     const warn = vi.fn()
+    let checkpoint!: () => void
+    const atCheckpoint = new Promise<void>((resolve) => { checkpoint = resolve })
+    let release!: () => void
+    const schedule = vi.spyOn(globalThis, 'setImmediate').mockImplementationOnce(((callback: () => void) => {
+      release = callback
+      checkpoint()
+      return {} as NodeJS.Immediate
+    }))
     const audit = auditStartupEntries({
+      get: () => ({ originOfEntry: () => ({ trust: 'external', stage: 'runtime' }) }),
       loader: {
         entries: () => ['broken-a', 'broken-b'].map(name => ({
           options: { id: name, name },
@@ -433,11 +442,15 @@ describe('installFailLoud', () => {
         })),
       },
     } as unknown as Context, NAME, warn)
-    await Promise.resolve()
-    await Promise.resolve()
-    proc.handlers[0]!(error)
-    expect(proc.written).toEqual([])
-    expect(proc.exits).toEqual([])
+    await atCheckpoint
+    try {
+      proc.handlers[0]!(error)
+      expect(proc.written).toEqual([])
+      expect(proc.exits).toEqual([])
+    } finally {
+      schedule.mockRestore()
+      release()
+    }
     await audit
     expect(warn).toHaveBeenCalledWith(expect.stringContaining('assembled activation failure'))
     proc.handlers[0]!(error)
@@ -504,15 +517,7 @@ describe('installFailLoud', () => {
 })
 
 describe('auditStartupEntries', () => {
-  const requiredIds = [
-    'agent-loop',
-    'webserver',
-    'modules',
-    'connection',
-    'headless-runner',
-    'acp',
-    'sdk-jsonrpc-server',
-  ]
+  const requiredIds = ['required-a', 'webserver', 'headless-runner']
 
   interface FakeEntry {
     fiber?: {
@@ -526,6 +531,7 @@ describe('auditStartupEntries', () => {
   }
 
   const ctxWith = (entries: FakeEntry[]): Context => ({
+    get: () => ({ originOfEntry: (entry: FakeEntry) => requiredIds.includes(entry.options.id) ? undefined : { trust: 'external', stage: 'runtime' } }),
     loader: { entries: () => entries.values() },
   }) as unknown as Context
 
@@ -551,6 +557,9 @@ describe('auditStartupEntries', () => {
         options: { id, name: './required.mjs' },
       }))), NAME, warn)).resolves.toBeUndefined()
     }
+    await expect(auditStartupEntries(ctxWith([{
+      options: { id: 'recovered', name: './plugin.mjs' }, fiber: fiber(3),
+    }]), NAME, warn)).resolves.toBeUndefined()
     expect(warn).not.toHaveBeenCalled()
   })
 
@@ -915,7 +924,9 @@ describe('boot', () => {
     const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
     let ctx: Context | undefined
     try {
-      ctx = await boot(NAME, configPath)
+      ctx = await boot(NAME, configPath, [], (ctx) => {
+        ctx.provide('profileRuntime', { originOfEntry: () => ({ trust: 'external', stage: 'runtime' }) } as unknown as ProfileRuntime)
+      })
       expect(ctx.get('goodStarted')).toBe(true)
       const entries = [...ctx.loader.entries()]
       expect(entries.find(entry => entry.options.id === 'good')?.fiber?.state).toBe(2)

+ 6 - 7
packages/boot/app-boot/tests/compose-stack.spec.ts

@@ -10,7 +10,6 @@ import { describe, expect, it } from 'vitest'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { claimLayerIds, composeProfileStack, formatRowConflict, type ProfileLayer } from '../src/index.ts'
-import { CONTAINED_GROUP_MODULE } from '../src/external-bundles.ts'
 
 const NAME = 'dsh-test-bin'
 
@@ -102,8 +101,8 @@ describe('claimLayerIds', () => {
 
   it('leaves out a bundle whose config override of its own group lists an id twice', () => {
     const self = layer('self', 'external', [
-      { insert: [{ id: 'row', name: 'self/row' }] },
-      { id: 'bundle/self', config: [{ id: 'row', name: 'self/row' }, { id: 'row', name: 'self/row-again' }] },
+      { insert: [{ id: 'self-group', name: 'cordis:group', group: true, config: [{ id: 'row', name: 'self/row' }] }] },
+      { id: 'self-group', config: [{ id: 'row', name: 'self/row' }, { id: 'row', name: 'self/row-again' }] },
     ])
     const { skipped, composed } = claimLayerIds([base, self])
     expect(skipped.get('self')?.map(conflict => conflict.message)).toEqual(['row "row" is declared twice by self'])
@@ -120,7 +119,7 @@ describe('claimLayerIds', () => {
     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' }] })
+    expect(composed.get('clean')?.patches[0]).toEqual({ insert: [{ id: 'y', name: 'clean' }] })
   })
 
   it('gives the earlier external bundle the id and leaves the later one out whole', () => {
@@ -128,7 +127,7 @@ describe('claimLayerIds', () => {
     const second = layer('second', 'external', [{ insert: [{ id: 'hello', name: 'second' }, { id: 'only-second', name: 'second/x' }] }])
     const { owners, skipped } = claimLayerIds([base, first, second])
     expect(owners.get('hello')?.packageName).toBe('first')
-    expect(owners.get('bundle/first')?.packageName).toBe('first')
+    expect(owners.has('bundle/first')).toBe(false)
     expect(owners.has('only-second')).toBe(false)
     expect(skipped.get('second')?.map(conflict => conflict.rowId)).toEqual(['hello'])
   })
@@ -160,8 +159,8 @@ 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[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[1]?.patches[0]?.insert?.[0]).toMatchObject({ id: 'ext-tool', name: 'ext' })
+    expect([...stack.owners.keys()]).toEqual(['settings', 'tools', 'tool-bash', 'ext-tool'])
     expect(stack.layers[2]?.patches).toEqual([
       { id: 'settings', config: { path: '/x' } },
       { insert: [{ id: 'mine', name: 'mine' }] },

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

@@ -1,315 +0,0 @@
-/**
- * Startup isolation: a row inside a `cordis:contained-group` fails on its
- * own and is recorded, the group and its siblings stay up, and the boot
- * audit treats the record as a report rather than a fatal. Built-in rows keep
- * the fatal path, and a built-in row left waiting because an isolated bundle
- * failed still fails the boot while naming that bundle.
- */
-
-import { mkdtempSync, writeFileSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { afterEach, describe, expect, it } from 'vitest'
-import { Context, type Plugin } from '@deepseek-ai/cordis'
-import Loader, { type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
-import { assertEntriesActivated, boot, ensurePluginFailures, installRuntimeGuards, rootIncludeEntry, warnNestedFiberFailures, type RuntimeGuardProcess } from '../src/index.ts'
-import { ContainedFailureRegistry, ContainedGroup, isContainedEntry } from '../src/contained-group.ts'
-
-const NAME = 'dsh-test-bin'
-
-const contexts: Context[] = []
-afterEach(async () => {
-  await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
-})
-
-const good: Plugin.Function = () => {}
-const throws: Plugin.Function = () => { throw new Error('boom at apply') }
-const pending: Plugin.Object = { inject: ['neverReady'], apply() {} }
-const nestedThrows: Plugin.Function = (ctx) => {
-  ctx.inject([], () => { throw new Error('nested boom') })
-}
-let flakyCalls = 0
-const flaky: Plugin.Function = () => {
-  flakyCalls += 1
-  if (flakyCalls > 1) throw new Error('flaky on reload')
-}
-
-const prepare = (ctx: Context): void => {
-  ctx.loader.builtins.good = good
-  ctx.loader.builtins.throws = throws
-  ctx.loader.builtins.pending = pending
-  ctx.loader.builtins['nested-throws'] = nestedThrows
-  ctx.loader.builtins.flaky = flaky
-}
-
-function stage(config: string): string {
-  const dir = mkdtempSync(join(tmpdir(), 'dsh-contained-'))
-  const path = join(dir, 'cordis.yml')
-  writeFileSync(path, config)
-  return path
-}
-
-const settle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 30))
-
-describe('cordis:contained-group', () => {
-  it('isolates a failing row, keeps its siblings, and records the failure', async () => {
-    const ctx = await boot(NAME, stage(`
-- id: bundle/ext
-  name: cordis:contained-group
-  group: true
-  config:
-    - id: ext/ok
-      name: cordis:good
-    - id: ext/bad
-      name: cordis:throws
-- id: builtin-ok
-  name: cordis:good
-`), [], prepare)
-    contexts.push(ctx)
-    // Tree-wide ids carry the root include's prefix; the composition's own ids do not.
-    const ids = [...ctx.loader.entries()].map(entry => entry.id)
-    expect(ids).toEqual(expect.arrayContaining(['include:bundle/ext', 'include:ext/ok', 'include:builtin-ok']))
-    expect(ids).not.toContain('include:ext/bad')
-    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
-    expect(registry.list()).toEqual([expect.objectContaining({
-      entryId: 'include:ext/bad', rowId: 'ext/bad', moduleName: 'cordis:throws', groupId: 'include:bundle/ext', stage: 'apply',
-    })])
-    expect(registry.get('include:ext/bad')?.message).toContain('boom at apply')
-    expect(isContainedEntry(ctx.loader.resolve('include:ext/ok'))).toBe(true)
-    expect(isContainedEntry(ctx.loader.resolve('include:builtin-ok'))).toBe(false)
-    expect(rootIncludeEntry(ctx)?.id).toBe('include')
-  })
-
-  it('records a contained row that waits forever for a service instead of failing the boot', async () => {
-    const ctx = await boot(NAME, stage(`
-- id: bundle/ext
-  name: cordis:contained-group
-  group: true
-  config:
-    - id: ext/waiting
-      name: cordis:pending
-`), [], prepare)
-    contexts.push(ctx)
-    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
-    expect(registry.get('include:ext/waiting')).toEqual(expect.objectContaining({ stage: 'inject-pending', rowId: 'ext/waiting' }))
-    expect(registry.get('include:ext/waiting')?.message).toContain('neverReady')
-  })
-
-  it('still fails the boot for a built-in row, and names isolated bundles when a built-in row is left waiting', async () => {
-    await expect(boot(NAME, stage(`
-- id: builtin-bad
-  name: cordis:throws
-`), [], prepare)).rejects.toThrow(/boom at apply/)
-
-    await expect(boot(NAME, stage(`
-- id: bundle/ext
-  name: cordis:contained-group
-  group: true
-  config:
-    - id: ext/bad
-      name: cordis:throws
-- id: builtin-waiting
-  name: cordis:pending
-`), [], prepare)).rejects.toThrow(/isolated bundle failure\(s\) may be the missing provider — check include:bundle\/ext/)
-  })
-
-  it('re-running the audit clears a record whose row later mounted', async () => {
-    const ctx = await boot(NAME, stage(`
-- id: bundle/ext
-  name: cordis:contained-group
-  group: true
-  config:
-    - id: ext/ok
-      name: cordis:good
-`), [], prepare)
-    contexts.push(ctx)
-    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
-    registry.record({ entryId: 'include:ext/ok', rowId: 'ext/ok', moduleName: 'cordis:good', groupId: 'include:bundle/ext', stage: 'unknown', message: 'stale' })
-    await assertEntriesActivated(ctx, NAME)
-    expect(registry.get('include:ext/ok')).toBeUndefined()
-  })
-
-  it('records an import failure, and spells ids without a prefix in a tree with no include', async () => {
-    const ctx = new Context()
-    contexts.push(ctx)
-    await ctx.plugin(Loader)
-    ctx.loader.builtins['contained-group'] = ContainedGroup
-    ensurePluginFailures(ctx)
-    const group: EntryOptions = {
-      id: 'bundle/ext', name: 'cordis:contained-group', group: true,
-      config: [{ id: 'ext/missing', name: 'no-such-package-for-dsh-tests' }],
-    }
-    await ctx.loader.create(group)
-    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
-    expect(registry.get('ext/missing')).toEqual(expect.objectContaining({ entryId: 'ext/missing', rowId: 'ext/missing', stage: 'import', groupId: 'bundle/ext' }))
-  })
-
-  it('records a contained row whose fiber fails on a later reload', async () => {
-    flakyCalls = 0
-    const ctx = await boot(NAME, stage(`
-- id: bundle/ext
-  name: cordis:contained-group
-  group: true
-  config:
-    - id: ext/flaky
-      name: cordis:flaky
-`), [], prepare)
-    contexts.push(ctx)
-    const entry = ctx.loader.resolve('include:ext/flaky')
-    // The restart rethrows the reload failure; the fiber stays mounted as FAILED.
-    await entry.fiber?.restart().catch(() => undefined)
-    await settle()
-    await assertEntriesActivated(ctx, NAME)
-    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
-    expect(registry.get('include:ext/flaky')).toEqual(expect.objectContaining({ stage: 'apply', rowId: 'ext/flaky' }))
-    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('drops the record of a row its configuration no longer names, while the group stays', 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
-`), [], prepare)
-    contexts.push(ctx)
-    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
-    expect(registry.get('include:ext/bad')).toBeDefined()
-    const group = ctx.loader.resolve('include:bundle/ext')
-    // The bundle updates to a version without the failing row: the group is updated, not unmounted.
-    await group.update({ config: [{ id: 'ext/ok', name: 'cordis:good' }] })
-    expect(registry.get('include:ext/bad')).toBeUndefined()
-    expect([...ctx.loader.entries()].some(entry => entry.id === 'include:ext/ok')).toBe(true)
-  })
-
-  it('records a pending row of a nested plain group under the contained group that isolates it, so unmounting the bundle clears it', async () => {
-    const ctx = await boot(NAME, stage(`
-- id: bundle/ext
-  name: cordis:contained-group
-  group: true
-  config:
-    - id: ext/inner
-      name: cordis:group
-      group: true
-      config:
-        - id: ext/waiting
-          name: cordis:pending
-        - name: cordis:good
-`), [], prepare)
-    contexts.push(ctx)
-    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
-    expect(registry.get('include:ext/waiting')).toEqual(expect.objectContaining({ stage: 'inject-pending', groupId: 'include:bundle/ext' }))
-    const group = ctx.loader.resolve('include:bundle/ext')
-    await group.parent.remove(group.options.id)
-    expect(registry.get('include:ext/waiting')).toBeUndefined()
-  })
-
-  it('provides one registry per runtime', async () => {
-    const ctx = new Context()
-    contexts.push(ctx)
-    const registry = ensurePluginFailures(ctx)
-    expect(ensurePluginFailures(ctx.extend({}))).toBe(registry)
-    registry.record({ entryId: 'a', rowId: 'a', moduleName: 'm', groupId: 'g', stage: 'import', message: 'x' })
-    registry.record({ entryId: 'a', rowId: 'a', moduleName: 'm', groupId: 'g', stage: 'apply', message: 'y' })
-    expect(registry.list()).toHaveLength(1)
-    registry.clear('a')
-    expect(registry.list()).toEqual([])
-  })
-})
-
-describe('warnNestedFiberFailures', () => {
-  it('reports a failed nested fiber under a built-in entry and ignores contained ones', async () => {
-    const ctx = await boot(NAME, stage(`
-- id: builtin-nested
-  name: cordis:nested-throws
-- id: bundle/ext
-  name: cordis:contained-group
-  group: true
-  config:
-    - id: ext/nested
-      name: cordis:nested-throws
-`), [], prepare)
-    contexts.push(ctx)
-    await settle()
-    const lines: string[] = []
-    expect(warnNestedFiberFailures(ctx, NAME, line => lines.push(line))).toBe(1)
-    expect(lines).toEqual([expect.stringContaining('cordis:nested-throws')])
-    // The default sink is the context logger; it must accept the call too.
-    expect(warnNestedFiberFailures(ctx, NAME)).toBe(1)
-  })
-
-  it('reports nothing for a clean tree', async () => {
-    const ctx = new Context()
-    contexts.push(ctx)
-    await ctx.plugin(Loader)
-    expect(warnNestedFiberFailures(ctx, NAME, () => { throw new Error('unexpected') })).toBe(0)
-  })
-})
-
-describe('installRuntimeGuards', () => {
-  function fakeProc(): RuntimeGuardProcess & { handlers: Map<string, (err: unknown) => void>; exits: number[] } {
-    const handlers = new Map<string, (err: unknown) => void>()
-    const exits: number[] = []
-    return {
-      handlers,
-      exits,
-      on: (event, handler) => { handlers.set(event, handler) },
-      off: (event) => { handlers.delete(event) },
-      exit: (code) => { exits.push(code) },
-    }
-  }
-
-  it('reports a rejection without exiting, exits on an exception, and uninstalls both', () => {
-    const proc = fakeProc()
-    const lines: string[] = []
-    const uninstall = installRuntimeGuards(NAME, line => lines.push(line), proc)
-    proc.handlers.get('unhandledRejection')?.(new Error('stray'))
-    expect(proc.exits).toEqual([])
-    expect(lines[0]).toContain('unhandled rejection after boot')
-    expect(lines[0]).toContain('stray')
-    proc.handlers.get('uncaughtException')?.('plain')
-    expect(proc.exits).toEqual([1])
-    expect(lines[1]).toContain('uncaught exception after boot')
-    uninstall()
-    expect(proc.handlers.size).toBe(0)
-  })
-
-  it('defaults to the real process', () => {
-    const before = process.listenerCount('uncaughtException')
-    const uninstall = installRuntimeGuards(NAME, () => {})
-    expect(process.listenerCount('uncaughtException')).toBe(before + 1)
-    uninstall()
-    expect(process.listenerCount('uncaughtException')).toBe(before)
-  })
-})

+ 211 - 0
packages/boot/app-boot/tests/entry-issues.spec.ts

@@ -0,0 +1,211 @@
+/** Native entries preserve siblings and report requested options separately from live fibers. */
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context, type Plugin } from '@deepseek-ai/cordis'
+import Loader, { type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
+import { boot, composeProfileStack, entryIssue, inspectEntryIssues, ProfileRuntime, rootIncludeEntry, warnNestedFiberFailures, type Profile, type ProfileLayer } from '../src/index.ts'
+
+const roots: string[] = []
+const contexts: Context[] = []
+afterEach(async () => {
+  await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+  roots.splice(0).forEach((root) => { rmSync(root, { recursive: true, force: true }) })
+})
+const good: Plugin.Function = () => {}
+const bad: Plugin.Function = () => { throw new Error('apply failed') }
+const asyncBad: Plugin.Function = async () => { await Promise.resolve(); throw new Error('async apply failed') }
+const configured: Plugin.Object<{ value: number }> = {
+  Config: { '~standard': { version: 1, vendor: 'entry-test', validate: (input) => {
+    const config = input as { value: number }
+    return config.value < 0 ? { issues: [{ message: 'negative value' }] } : { value: config }
+  } } },
+  apply() {},
+}
+const nested: Plugin.Function = (ctx) => { ctx.inject([], () => { throw new Error('nested failure') }) }
+const waiting: Plugin.Object = { inject: ['testService'], apply() {} }
+function layer(trust: ProfileLayer['trust'], stage: ProfileLayer['stage'], patches: PatchOptions[]): ProfileLayer {
+  return { packageName: 'pkg', version: undefined, packageDir: '/pkg', patchPath: '/pkg/patch.yml', trust, stage, patches }
+}
+async function profileBoot(layers: ProfileLayer[], user: PatchOptions[] = []): Promise<Context> {
+  const dir = mkdtempSync(join(tmpdir(), 'dsh-entry-policy-'))
+  roots.push(dir)
+  const path = join(dir, 'cordis.yml')
+  writeFileSync(path, '[]\n')
+  const profile: Profile = { name: 'test', dir, layers: [...layers], patches: [], patchPath: join(dir, 'cordis.patch.yml'), patchReload: 'live' }
+  const compose = (current: Profile) => composeProfileStack('test', current.layers, [{ label: profile.patchPath, patches: user }])
+  const stack = compose(profile)
+  const ctx = await boot('test', path, stack.patches, async (ctx) => {
+    Object.assign(ctx.loader.builtins, { good, bad, asyncBad, configured, waiting, nested })
+    await ctx.plugin(ProfileRuntime, {
+      profile, stack, compose, loadProfile: () => ({ ...profile, layers: [...layers] }), rootEntry: () => rootIncludeEntry(ctx),
+    })
+  })
+  contexts.push(ctx)
+  return ctx
+}
+const failures: EntryOptions[] = [
+  { id: 'missing', name: './dsh-missing-fixture.mjs' },
+  { id: 'bad', name: 'cordis:bad' },
+  { id: 'asyncBad', name: 'cordis:asyncBad' },
+  { id: 'configured', name: 'cordis:configured', config: { value: -1 } },
+  { id: 'waiting', name: 'cordis:waiting' },
+  { id: 'gate', name: 'cordis:good', disabled: { __jsExpr: 'missing.disabled' } as unknown as boolean },
+]
+describe('entry startup policy', () => {
+  it.each(failures)('keeps a failing external runtime row and its successful sibling: $id', async (row) => {
+    const ctx = await profileBoot([layer('external', 'runtime', [{ insert: [row, { id: 'ok', name: 'cordis:good' }] }])])
+    expect(ctx.loader.resolve('include:ok').fiber?.state).toBe(2)
+    const entry = ctx.loader.resolve(`include:${row.id}`)
+    expect((await entryIssue(entry))?.message).toBeTruthy()
+    expect(ctx.profileRuntime.originOfEntry(entry)).toMatchObject({ trust: 'external', stage: 'runtime' })
+    expect([...ctx.loader.entries()].some(e => e.options.name === 'cordis:contained-group')).toBe(false)
+  })
+  it.each([
+    ['builtin', 'boot'], ['builtin', 'runtime'], ['external', 'boot'],
+  ] as const)('rejects %s/%s failures regardless of module id', async (trust, stage) => {
+    for (const row of failures) {
+      await expect(profileBoot([layer(trust, stage, [{ insert: [row] }])])).rejects.toThrow('required startup failure')
+    }
+  })
+  it('keeps user rows strict and does not transfer ownership with a config override', async () => {
+    await expect(profileBoot([], [{ insert: [{ id: 'user', name: 'cordis:bad' }] }])).rejects.toThrow('required startup failure')
+    const builtin = layer('builtin', 'runtime', [{ insert: [{ id: 'webserver', name: 'cordis:configured', config: { value: 1 } }] }])
+    const ext = { ...layer('external', 'runtime', [{ id: 'webserver', config: { value: -1 } }]), packageName: 'ext' }
+    await expect(profileBoot([builtin, ext])).rejects.toThrow('negative value')
+  })
+  it('rejects a builtin consumer left pending by an optional provider failure', async () => {
+    await expect(profileBoot([
+      layer('builtin', 'boot', [{ insert: [{ id: 'consumer', name: 'cordis:waiting' }] }]),
+      { ...layer('external', 'runtime', [{ insert: [{ id: 'provider', name: 'cordis:bad' }] }]), packageName: 'ext' },
+    ])).rejects.toThrow('consumer')
+  })
+  it('preserves anonymous provenance and nested Include provenance without matching unrelated root ids', async () => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-nested-entry-'))
+    roots.push(dir)
+    const path = join(dir, 'nested.yml')
+    writeFileSync(path, '- id: shared\n  name: cordis:bad\n')
+    const ctx = await profileBoot([
+      layer('builtin', 'boot', [{ insert: [{ id: 'shared', name: 'cordis:good' }] }]),
+      { ...layer('external', 'runtime', [{ insert: [
+        { name: 'cordis:good' } as EntryOptions,
+        { id: 'nested', name: 'cordis:include', config: { path } },
+      ] }]), packageName: 'ext' },
+    ])
+    expect(ctx.profileRuntime.originOfEntry(ctx.loader.resolve('include:shared'))?.trust).toBe('builtin')
+    expect(ctx.profileRuntime.originOfEntry(ctx.loader.resolve('include:nested:shared'))?.trust).toBe('external')
+    const anonymous = [...ctx.loader.entries()].find(e => e.options.id.startsWith('anonymous/'))!
+    expect(ctx.profileRuntime.originOfEntry(anonymous)?.packageName).toBe('ext')
+  })
+})
+describe('entry diagnostics', () => {
+  it('disables every part of a bundle layer and restores overrides while preserving user row choices', async () => {
+    const base = layer('builtin', 'boot', [{ insert: [
+      { id: 'webserver', name: 'cordis:configured', config: { value: 1 } },
+      { id: 'tools', name: 'cordis:group', group: true, config: [] },
+      { id: 'ui', name: 'cordis:group', group: true, config: [] },
+    ] }])
+    const ext = { ...layer('external', 'runtime', [
+      { insert: [{ id: 'own', name: 'cordis:group', group: true, config: [{ id: 'child', name: 'cordis:good' }] }] },
+      { id: 'tools', insert: [{ id: 'tool', name: 'cordis:good' }] },
+      { id: 'ui', insert: [{ id: 'panel', name: 'cordis:good' }] },
+      { id: 'webserver', config: { value: 2 } },
+    ]), packageName: 'ext' }
+    const layers = [base, ext]
+    const ctx = await profileBoot(layers, [{ id: 'tool', disabled: true }])
+    expect(ctx.loader.resolve('include:webserver').fiber?.config).toEqual({ value: 2 })
+    expect(ctx.loader.resolve('include:tool').disabled).toBe(true)
+    layers.pop()
+    expect(await ctx.profileRuntime.recompose({ reloadBundles: true })).toEqual([])
+    const ids = [...ctx.loader.entries()].map(entry => entry.options.id)
+    for (const id of ['own', 'child', 'tool', 'panel']) expect(ids).not.toContain(id)
+    expect(ctx.loader.resolve('include:webserver').fiber?.config).toEqual({ value: 1 })
+    layers.push(ext)
+    expect(await ctx.profileRuntime.recompose({ reloadBundles: true })).toEqual([])
+    expect(ctx.loader.resolve('include:panel').fiber?.state).toBe(2)
+    expect(ctx.loader.resolve('include:child').fiber?.state).toBe(2)
+    expect(ctx.loader.resolve('include:tool').disabled).toBe(true)
+    expect(ctx.loader.resolve('include:webserver').fiber?.config).toEqual({ value: 2 })
+  })
+
+  it('retains an active old config on invalid update, reports the failure, and clears it on a valid change', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.configured = configured
+    const id = await ctx.loader.create({ name: 'cordis:configured', config: { value: 1 } })
+    const entry = ctx.loader.resolve(id)
+    await expect(entry.update({ config: { value: -1 } })).rejects.toThrow('negative value')
+    expect(entry.options.config).toEqual({ value: -1 })
+    expect(entry.fiber?.config).toEqual({ value: 1 })
+    expect(entry.fiber?.state).toBe(2)
+    expect((await entryIssue(entry))?.stage).toBe('update')
+    expect((await entryIssue(entry))?.message).toContain('negative value')
+    await entry.update({ config: { value: 2 } })
+    await ctx.loader.await()
+    expect(entry.fiber?.config).toEqual({ value: 2 })
+    expect(await entryIssue(entry)).toBeUndefined()
+  })
+  it('clears import and pending issues after recovery and ignores disabled failures', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.waiting = waiting
+    const broken = await ctx.loader.create({ name: './dsh-missing-fixture.mjs' })
+    const pending = await ctx.loader.create({ name: 'cordis:waiting' })
+    expect((await inspectEntryIssues(ctx)).map(issue => issue.stage).sort()).toEqual(['import', 'inject-pending'])
+    ctx.loader.builtins.later = good
+    await ctx.loader.resolve(broken).update({ disabled: true })
+    expect(await entryIssue(ctx.loader.resolve(broken))).toBeUndefined()
+    await ctx.loader.resolve(broken).update({ name: 'cordis:later', disabled: false })
+    ctx.provide('testService', {})
+    await ctx.loader.await()
+    expect(await entryIssue(ctx.loader.resolve(pending))).toBeUndefined()
+    expect(await inspectEntryIssues(ctx)).toEqual([])
+  })
+  it('reports partial recompose, preserves successful siblings, and waits for removed fibers', async () => {
+    const patches: PatchOptions[] = [{ insert: [
+      { id: 'config', name: 'cordis:configured', config: { value: 1 } },
+      { id: 'ok', name: 'cordis:good' },
+    ] }]
+    const ctx = await profileBoot([layer('external', 'runtime', patches)])
+    let release!: () => void
+    let started!: () => void
+    const removing = new Promise<void>((resolve) => { started = resolve })
+    const gate = new Promise<void>((resolve) => { release = resolve })
+    ctx.loader.builtins.slow = (ctx: Context) => { ctx.effect(() => async () => { started(); await gate }) }
+    patches[0]!.insert!.push({ id: 'slow', name: 'cordis:slow' })
+    await ctx.profileRuntime.recompose({ reloadBundles: true })
+    patches[0]!.insert![0]!.config = { value: -1 }
+    patches[0]!.insert!.pop()
+    const done = vi.fn()
+    const updating = ctx.profileRuntime.recompose({ reloadBundles: true }).then((issues) => { done(); return issues })
+    try {
+      await removing
+      expect(done).not.toHaveBeenCalled()
+    } finally { release() }
+    expect((await updating).map(issue => issue.stage)).toContain('update')
+    expect(ctx.loader.resolve('include:ok').fiber?.state).toBe(2)
+    expect(ctx.loader.resolve('include:config').fiber?.config).toEqual({ value: 1 })
+  })
+})
+
+describe('nested continuation diagnostics', () => {
+  it('reports required nested failures without treating them as entry activation failures', async () => {
+    const ctx = await profileBoot([
+      layer('builtin', 'boot', [{ insert: [{ id: 'required', name: 'cordis:nested' }] }]),
+      { ...layer('external', 'runtime', [{ insert: [
+        { id: 'optional', name: 'cordis:nested' }, { id: 'failed-entry', name: 'cordis:bad' },
+      ] }]), packageName: 'ext' },
+    ])
+    await expect(ctx.plugin(() => { throw new Error('unowned failure') })).rejects.toThrow('unowned failure')
+    await Promise.allSettled([...ctx.registry.values()].flatMap(runtime => [...runtime.fibers].map(fiber => fiber.await())))
+    const lines: string[] = []
+    expect(warnNestedFiberFailures(ctx, 'test', (line) => { lines.push(line) })).toBe(1)
+    expect(lines[0]).toContain('cordis:nested')
+    expect(ctx.loader.resolve('include:required').fiber?.state).toBe(2)
+    expect(warnNestedFiberFailures(ctx, 'test')).toBe(1)
+  })
+})

+ 43 - 223
packages/boot/app-boot/tests/external-bundles.spec.ts

@@ -1,231 +1,51 @@
-/**
- * External bundle composition (one contained group per bundle, ids as
- * declared) and the manifest operations behind installing, enabling, and
- * disabling.
- */
-
-import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
+/** Bundle ownership analysis and installed/enabled manifest lists. */
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { describe, expect, it } from 'vitest'
-import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
-import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import { bundleGroupId, disableBundle, enableBundle, reconcileInstalledBundles, readProfileManifest, type ProfileLayer } from '../src/index.ts'
-import { composeExternalLayer, CONTAINED_GROUP_MODULE, exportsBundlePatch, isContainedLayer } from '../src/external-bundles.ts'
-
+import { afterEach, describe, expect, it } from 'vitest'
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
+import { disableBundle, enableBundle, reconcileInstalledBundles, readProfileManifest, type ProfileLayer } from '../src/index.ts'
+import { analyzeBundleLayer, exportsBundlePatch, isOptionalRuntimeLayer } from '../src/external-bundles.ts'
 const NAME = 'dsh-test-bin'
-
-const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-external-bundles-'))
-
-function layer(packageName: string, patches: PatchOptions[]): ProfileLayer {
-  return {
-    packageName, version: '1.0.0', packageDir: '/nowhere', patchPath: '/nowhere/cordis.patch.yml',
-    trust: 'external', stage: 'runtime', patches,
-  }
+const roots: string[] = []
+const tmp = (): string => {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-external-bundles-'))
+  roots.push(root)
+  return root
 }
-
-describe('composeExternalLayer', () => {
-  it('inserts an empty contained group first and re-targets root inserts into it, ids as declared', () => {
-    const composed = composeExternalLayer(layer('pkg-a', [
-      { insert: [{ id: 'tool', name: 'pkg-a' }, { name: 'pkg-a/anonymous' } as EntryOptions] },
-      { insert: [{ id: 'grp', name: 'cordis:group', group: true, config: [{ id: 'inner', name: 'pkg-a/inner' }] }] },
-    ]))
-    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([
-      ['tool', 'pkg-a'], ['grp', 'cordis:group'], ['inner', 'pkg-a/inner'], ['bundle/pkg-a', CONTAINED_GROUP_MODULE],
-    ])
-    expect(composed.duplicates).toEqual([])
-    expect(composed.overrides).toEqual([])
-  })
-
-  it('keeps the written order of inserts and id-targeted patches, reporting patches on other rows as overrides', () => {
-    const composed = composeExternalLayer(layer('pkg-b', [
-      { insert: [{ id: 'own', name: 'pkg-b' }] },
-      { id: 'own', config: { flag: true } },
-      { id: 'settings', config: { path: '/x' } },
-      { id: 'own', insert: [{ id: 'child', name: 'pkg-b/child' }] },
-      { id: 'own', disabled: true },
-      { disabled: true },
-    ]))
-    // A patch with no config, or no id at all, passes through as written; the include reports the latter.
-    expect(composed.patches.slice(1)).toEqual([
-      { id: 'bundle/pkg-b', insert: [{ id: 'own', name: 'pkg-b' }] },
-      { id: 'own', config: { flag: true } },
-      { id: 'settings', config: { path: '/x' } },
-      { id: 'own', insert: [{ id: 'child', name: 'pkg-b/child' }] },
-      { id: 'own', disabled: true },
-      { disabled: true },
-    ])
-    expect(composed.overrides).toEqual(['settings'])
-  })
-
-  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' }] },
+afterEach(() => { roots.splice(0).forEach((root) => { rmSync(root, { recursive: true, force: true }) }) })
+function layer(patches: PatchOptions[]): ProfileLayer {
+  return { packageName: 'ext', version: undefined, packageDir: '/nowhere', patchPath: '/nowhere/patch.yml', trust: 'external', stage: 'runtime', patches }
+}
+describe('analyzeBundleLayer', () => {
+  it('preserves parents, explicit ids and overrides across several groups', () => {
+    const patches: PatchOptions[] = [
+      { insert: [{ id: 'own', name: 'cordis:group', group: true, config: [{ id: 'child', name: 'ext/child' }] }] },
+      { id: 'tools', insert: [{ id: 'tool', name: 'ext/tool' }] },
+      { id: 'ui', insert: [{ id: 'panel', name: 'ext/panel' }] },
+      { id: 'webserver', config: { port: 0 } },
+      { id: 'child', disabled: true },
     ]
-    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', [
-      { 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([
-      { insert: [{ id: 'bundle/pkg-c', name: CONTAINED_GROUP_MODULE, group: true, config: [] }] },
-      {
-        id: 'persistent-shell',
-        insert: [{ id: 'bundle/pkg-c/in/persistent-shell', name: CONTAINED_GROUP_MODULE, group: true, config: [{ id: '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.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('counts the rows a config override sets as the bundle\'s own, without calling a restated row a repeat', () => {
-    const composed = composeExternalLayer(layer('pkg-v', [
-      { insert: [{ id: 'own', name: 'cordis:group', group: true, config: [{ id: 'kept', name: 'pkg-v/kept' }] }] },
-      { id: 'own', config: [{ id: 'kept', name: 'pkg-v/kept' }, { id: 'core', name: 'pkg-v/impostor' }, 'not a row' as never] },
-      { id: 'own', insert: [{ id: 'child', name: 'pkg-v/child' }] },
-    ]))
-    expect([...composed.rows.keys()]).toEqual(['own', 'kept', 'core', 'child', 'bundle/pkg-v'])
-    expect(composed.duplicates).toEqual([])
-    expect(composed.overrides).toEqual([])
-  })
-
-  it('creates a new wrapper after a patch replaced the target group\'s config', () => {
-    const composed = composeExternalLayer(layer('pkg-w', [
-      { id: 'tools', insert: [{ id: 'first', name: 'pkg-w/a' }] },
-      { id: 'tools', config: [] },
-      { id: 'tools', insert: [{ id: 'last', name: 'pkg-w/b' }] },
-    ]))
-    expect(composed.patches.slice(1)).toEqual([
-      { id: 'tools', insert: [{ id: 'bundle/pkg-w/in/tools', name: CONTAINED_GROUP_MODULE, group: true, config: [{ id: 'first', name: 'pkg-w/a' }] }] },
-      { id: 'tools', config: [] },
-      { id: 'tools', insert: [{ id: 'bundle/pkg-w/in/tools', name: CONTAINED_GROUP_MODULE, group: true, config: [{ id: 'last', name: 'pkg-w/b' }] }] },
-    ])
-    expect(composed.duplicates).toEqual([])
-    const tree = applyEntryPatches([{ id: 'tools', name: 'cordis:group', group: true, config: [] }], composed.patches, () => {})
-    const tools = tree.find(row => row.id === 'tools')?.config as EntryOptions[]
-    expect(tools.map(row => row.id)).toEqual(['bundle/pkg-w/in/tools'])
-    expect((tools[0]?.config as EntryOptions[]).map(row => row.id)).toEqual(['last'])
-  })
-
-  it('reports a declared row that spells a wrapper\'s id as a repeat', () => {
-    const composed = composeExternalLayer(layer('pkg-i', [
-      { insert: [{ id: 'bundle/pkg-i/in/tools', name: 'pkg-i/impostor' }] },
-      { id: 'tools', insert: [{ id: 'tool', name: 'pkg-i/tool' }] },
-    ]))
-    expect(composed.duplicates).toEqual([{ rowId: 'bundle/pkg-i/in/tools', moduleName: CONTAINED_GROUP_MODULE }])
-  })
-
-  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('reports a config override that lists one id twice or sets a row under a group other than the one holding it', () => {
-    const composed = composeExternalLayer(layer('pkg-o', [
-      { insert: [
-        { id: 'a', name: 'cordis:group', group: true, config: [{ id: 'shared', name: 'pkg-o/x' }] },
-        { id: 'b', name: 'cordis:group', group: true, config: [] },
-      ] },
-      { id: 'b', config: [{ id: 'shared', name: 'pkg-o/moved' }] },
-      { id: 'a', config: [{ id: 'twice', name: 'pkg-o/one' }, { id: 'twice', name: 'pkg-o/two' }] },
-      // The bundle's own group is a predictable target: a duplicate under it
-      // would fail the group's update before any row is contained.
-      { id: 'bundle/pkg-o', config: [{ id: 'own', name: 'pkg-o/own' }, { id: 'own', name: 'pkg-o/own-again' }] },
-    ]))
-    expect(composed.duplicates).toEqual([
-      { rowId: 'shared', moduleName: 'pkg-o/moved' },
-      { rowId: 'twice', moduleName: 'pkg-o/two' },
-      { rowId: 'own', moduleName: 'pkg-o/own-again' },
-    ])
-    expect(composed.overrides).toEqual([])
-  })
-
-  it('lets a config override restate a row under the built-in group it was inserted into', () => {
-    const composed = composeExternalLayer(layer('pkg-s', [
-      { id: 'tools', insert: [{ id: 'tool', name: 'pkg-s/tool' }] },
-      { id: 'tools', config: [{ id: 'tool', name: 'pkg-s/tool' }] },
-    ]))
-    expect(composed.duplicates).toEqual([])
-    expect(composed.overrides).toEqual(['tools'])
-  })
-
-  it('never mutates the layer\'s own patch objects', () => {
-    const patches: PatchOptions[] = [{ insert: [{ id: 'row', name: 'pkg-d' }] }, { id: 'row', config: { a: 1 } }]
-    const snapshot = structuredClone(patches)
-    composeExternalLayer(layer('pkg-d', patches))
-    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' }] },
-      { id: 'tools', config: [] },
-      { id: 'tools', insert: [{ id: 'later', name: 'pkg-t/later' }] },
-    ]))
-    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)
-    // Neither the inserted rows nor the override values were mutated by either application.
-    expect(composed.patches).toEqual(snapshot)
-    expect((first[1]?.config as EntryOptions[]).map(row => row.id)).toEqual(['row'])
-    const tools = first[0]?.config as EntryOptions[]
-    expect((tools[0]?.config as EntryOptions[]).map(row => row.id)).toEqual(['later'])
-  })
-
-  it('spells the group id without the Loader\'s nested-id separator', () => {
-    expect(bundleGroupId('@scope/pkg')).toBe('bundle/@scope/pkg')
-    expect(bundleGroupId('x')).not.toContain(':')
-  })
-
-  it('contains only runtime-staged external layers', () => {
-    const contained = layer('pkg-e', [{ insert: [{ id: 'row', name: 'pkg-e' }] }])
-    expect(isContainedLayer(contained)).toBe(true)
-    expect(isContainedLayer({ ...contained, stage: 'boot' })).toBe(false)
-    expect(isContainedLayer({ ...contained, trust: 'builtin' })).toBe(false)
+    const result = analyzeBundleLayer(layer(patches))
+    expect(result.patches).toBe(patches)
+    expect([...result.rows.keys()]).toEqual(['own', 'child', 'tool', 'panel'])
+    expect(result.overrides).toEqual(['webserver'])
+    expect(result.duplicates).toEqual([])
+  })
+  it('rejects repeated insert ids but permits restating children in the same group', () => {
+    const patches: PatchOptions[] = [
+      { insert: [{ id: 'group', name: 'cordis:group', group: true, config: [{ id: 'child', name: 'ext/child' }] }] },
+      { id: 'group', config: [{ id: 'child', name: 'ext/child' }] },
+      { insert: [{ id: 'child', name: 'ext/duplicate' }] },
+    ]
+    expect(analyzeBundleLayer(layer(patches)).duplicates).toEqual([{ rowId: 'child', moduleName: 'ext/duplicate' }])
+  })
+  it('makes only external runtime layers optional at startup', () => {
+    const ext = layer([])
+    expect(isOptionalRuntimeLayer(ext)).toBe(true)
+    expect(isOptionalRuntimeLayer({ ...ext, stage: 'boot' })).toBe(false)
+    expect(isOptionalRuntimeLayer({ ...ext, trust: 'builtin' })).toBe(false)
+    expect(isOptionalRuntimeLayer({ ...ext, trust: 'builtin', stage: 'boot' })).toBe(false)
   })
 })
 

+ 116 - 0
packages/boot/app-boot/tests/package-metadata.spec.ts

@@ -0,0 +1,116 @@
+/** Static package declarations never execute installed code. */
+import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { readPackageMetadata } from '../src/index.ts'
+const NAME = 'dsh-test-bin'
+const roots: string[] = []
+afterEach(() => { roots.splice(0).forEach((root) => { rmSync(root, { recursive: true, force: true }) }) })
+interface StagedPackage {
+  main?: string
+  manifest?: Record<string, unknown>
+  files?: Record<string, string>
+}
+
+/** Stage a profile with packages under its node_modules and an empty app anchor. */
+function stage(packages: Record<string, StagedPackage>): { profileDir: string; installAnchor: string } {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-metadata-'))
+  roots.push(root)
+  const appDir = join(root, 'app')
+  mkdirSync(join(appDir, 'node_modules'), { recursive: true })
+  writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: 'dsh-app', version: '0.0.0' }))
+  const profileDir = join(root, 'profile')
+  mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
+  writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-test', private: true }))
+  for (const [name, spec] of Object.entries(packages)) {
+    const dir = join(profileDir, 'node_modules', name)
+    mkdirSync(dir, { recursive: true })
+    writeFileSync(join(dir, 'package.json'), JSON.stringify({
+      name, version: '1.0.0', type: 'module', ...spec.main === undefined ? {} : { main: './index.js' }, ...spec.manifest,
+    }))
+    if (spec.main !== undefined) writeFileSync(join(dir, 'index.js'), spec.main)
+    for (const [file, content] of Object.entries(spec.files ?? {})) {
+      mkdirSync(join(dir, file, '..'), { recursive: true })
+      writeFileSync(join(dir, file), content)
+    }
+  }
+  return { profileDir, installAnchor: join(appDir, 'package.json') }
+}
+
+
+describe('readPackageMetadata', () => {
+  it('preserves anonymous rows and absent package versions as static declarations', () => {
+    const staged = stage({ pkg: {
+      manifest: { version: undefined, dsh: { bundle: { patch: './patch.yml' } } },
+      files: { 'patch.yml': '- insert:\n    - name: pkg/tool\n      disabled: false\n' },
+    } })
+    const metadata = readPackageMetadata({ ...staged, binName: NAME, packageName: 'pkg' })
+    expect(metadata.version).toBeUndefined()
+    expect(metadata.rows).toEqual([{ name: 'pkg/tool', gated: false }])
+  })
+  it.each(['foreign', 'proxy', 'unidentified', 'broken'] as const)('reports physical Cordis resolution for a %s peer without executing it', (kind) => {
+    const peerManifest = {
+      name: kind === 'unidentified' ? 'wrong-package' : '@deepseek-ai/cordis',
+      exports: kind === 'broken' ? './absent.js' : './lib/index.js',
+      ...kind === 'proxy' ? { dsh: { moduleFallback: { targets: { '.': import.meta.resolve('@deepseek-ai/cordis') } } } } : {},
+    }
+    const staged = stage({ pkg: { files: {
+      'node_modules/@deepseek-ai/cordis/package.json': JSON.stringify(peerManifest),
+      'node_modules/@deepseek-ai/cordis/lib/index.js': 'throw new Error("must not execute")',
+    } } })
+    const metadata = readPackageMetadata({ ...staged, binName: NAME, packageName: 'pkg' })
+    expect(metadata.cordisSameCopy).toBe(kind === 'foreign' ? false : kind === 'proxy' ? true : null)
+  })
+
+  it('reads explicit main and subpath entries without importing them or reading a probe cache', () => {
+    const staged = stage({ pkg: {
+      main: "import { writeFileSync } from 'node:fs'; writeFileSync(new URL('./executed', import.meta.url), 'bad'); throw new Error('must never execute')",
+      manifest: { dsh: { title: 'Package', plugins: [{ name: '.', title: 'Main', config: { a: 1 } }, { name: './tools', title: 'Tools' }] } },
+    } })
+    const options = { ...staged, binName: NAME, packageName: 'pkg' }
+    const result = readPackageMetadata(options)
+    expect(result).toMatchObject({ kind: 'plugin', title: 'Package', version: '1.0.0',
+      addable: [{ name: '.', title: 'Main', config: { a: 1 } }, { name: './tools', title: 'Tools' }],
+    })
+    const dir = join(staged.profileDir, 'node_modules/pkg')
+    expect(existsSync(join(dir, 'executed'))).toBe(false)
+    writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'pkg', version: '2.0.0' }))
+    expect(readPackageMetadata(options)).toMatchObject({ kind: 'unknown', version: '2.0.0', addable: [] })
+  })
+  it('lists bundle rows, nested groups, unevaluated gates and external overrides', () => {
+    const staged = stage({ pkg: {
+      manifest: { description: 'Bundle', engines: { dsh: '>=0.1.0' }, dsh: { bundle: { patch: './patch.yml' } } },
+      files: { 'patch.yml': `- insert:
+    - id: group
+      name: cordis:group
+      group: true
+      config:
+        - id: tool
+          name: pkg/tool
+          disabled: !!js 'missingService.disabled'
+- id: tool
+  config: { a: 1 }
+- id: webserver
+  config: { port: 0 }
+` },
+    } })
+    expect(readPackageMetadata({ ...staged, binName: NAME, packageName: 'pkg' })).toMatchObject({
+      kind: 'bundle', description: 'Bundle', enginesDsh: '>=0.1.0',
+      rows: [{ id: 'group', name: 'cordis:group', gated: false }, { id: 'tool', name: 'pkg/tool', gated: true }],
+      overrides: ['webserver'], addable: [],
+    })
+  })
+  it.each(['export default () => {}', 'export function apply() {}', 'throw new Error("broken")'])('keeps an undeclared package unknown: %s', (main) => {
+    const staged = stage({ pkg: { main } })
+    expect(readPackageMetadata({ ...staged, binName: NAME, packageName: 'pkg' }).kind).toBe('unknown')
+    expect(existsSync(join(staged.profileDir, 'node_modules/pkg/package.json'))).toBe(true)
+  })
+  it.each([
+    'invalid', [{}], [{ name: '' }], [{ name: '../outside' }], [{ name: '/absolute' }],
+    [{ name: './tools' }, { name: 'tools' }], [{ name: '.', title: 1 }],
+  ])('rejects malformed or ambiguous plugin declarations: %j', (plugins) => {
+    const staged = stage({ pkg: { manifest: { dsh: { plugins } } } })
+    expect(() => readPackageMetadata({ ...staged, binName: NAME, packageName: 'pkg' })).toThrow()
+  })
+})

+ 0 - 361
packages/boot/app-boot/tests/probe.spec.ts

@@ -1,361 +0,0 @@
-/**
- * The install-time probe: manifest facts read in-process, imports checked in
- * a child process, and the per-profile cache.
- */
-
-import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { describe, expect, it } from 'vitest'
-import { PLUGIN_PROBE_DIR, probePackage, readProbeCache, writeProbeCache, type PluginProbe } from '../src/index.ts'
-import { PLUGIN_PROBE_FORMAT } from '../src/probe.ts'
-import { cordisPackageDir, parseProbeRecord, StreamTail } from '../src/probe.ts'
-import { parseChildReport, type ChildReport } from '../src/probe-report.ts'
-
-const NAME = 'dsh-test-bin'
-
-interface StagedPackage {
-  main?: string
-  manifest?: Record<string, unknown>
-  files?: Record<string, string>
-}
-
-/** Stage a profile with packages under its node_modules and an empty app anchor. */
-function stage(packages: Record<string, StagedPackage>): { profileDir: string; installAnchor: string } {
-  const root = mkdtempSync(join(tmpdir(), 'dsh-probe-'))
-  const appDir = join(root, 'app')
-  mkdirSync(join(appDir, 'node_modules'), { recursive: true })
-  writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: 'dsh-app', version: '0.0.0' }))
-  const profileDir = join(root, 'profile')
-  mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
-  writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-test', private: true }))
-  for (const [name, spec] of Object.entries(packages)) {
-    const dir = join(profileDir, 'node_modules', name)
-    mkdirSync(dir, { recursive: true })
-    writeFileSync(join(dir, 'package.json'), JSON.stringify({
-      name, version: '1.0.0', type: 'module', ...spec.main === undefined ? {} : { main: './index.js' }, ...spec.manifest,
-    }))
-    if (spec.main !== undefined) writeFileSync(join(dir, 'index.js'), spec.main)
-    for (const [file, content] of Object.entries(spec.files ?? {})) {
-      mkdirSync(join(dir, file, '..'), { recursive: true })
-      writeFileSync(join(dir, file), content)
-    }
-  }
-  return { profileDir, installAnchor: join(appDir, 'package.json') }
-}
-
-const PLUGIN_MAIN = `
-export const name = 'probe-plugin'
-export const Config = { toJSON: () => ({ type: 'object', properties: { flag: { type: 'boolean' } } }) }
-export function apply() {}
-`
-
-describe('probePackage', () => {
-  it('classifies a bundle, lists its rows and overrides, and keeps the config schema', async () => {
-    const { profileDir, installAnchor } = stage({
-      'ext-bundle': {
-        main: PLUGIN_MAIN,
-        manifest: {
-          description: 'A test bundle',
-          engines: { dsh: '>=0.1.0' },
-          dsh: { title: 'Ext', bundle: { patch: './cordis.patch.yml' }, plugins: [{ name: 'ext-bundle', title: 'Main as row', config: { a: 1 } }] },
-        },
-        files: {
-          'cordis.patch.yml': [
-            '- insert:',
-            '    - id: seam',
-            '      name: other-pkg',
-            "      disabled: !!js 'false'",
-            '    - id: ext',
-            '      name: ext-bundle',
-            '    - name: ext-bundle/anonymous',
-            '    - id: grp',
-            '      name: cordis:group',
-            '      group: true',
-            '      config:',
-            '        - id: nested',
-            '          name: ext-bundle/nested',
-            '- id: ext',
-            '  config: { flag: true }',
-            '- id: settings',
-            '  config: { path: /x }',
-            '',
-          ].join('\n'),
-        },
-      },
-    })
-    const probe = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'ext-bundle' })
-    expect(probe).toMatchObject({
-      packageName: 'ext-bundle', version: '1.0.0', description: 'A test bundle', title: 'Ext', kind: 'bundle', ok: true,
-      enginesDsh: '>=0.1.0', overrides: ['settings'],
-      rows: [
-        { id: 'seam', name: 'other-pkg', gated: true },
-        { id: 'ext', name: 'ext-bundle', gated: false },
-        { name: 'ext-bundle/anonymous', gated: false },
-        { id: 'grp', name: 'cordis:group', gated: false },
-        { id: 'nested', name: 'ext-bundle/nested', gated: false },
-      ],
-      configSchema: { type: 'object', properties: { flag: { type: 'boolean' } } },
-    })
-    expect(probe.addable).toEqual([{ name: 'ext-bundle', title: 'Main as row', config: { a: 1 }, ok: true, configSchema: { type: 'object', properties: { flag: { type: 'boolean' } } } }])
-    expect(probe.cordisSameCopy).toBeNull()
-    expect(Date.parse(probe.checkedAt)).not.toBeNaN()
-  })
-
-  it('classifies a plugin by its declaration to dsh, and a library by the lack of one', async () => {
-    const { profileDir, installAnchor } = stage({
-      'peer-plugin': { main: 'export function apply() {}\n', manifest: { peerDependencies: { '@deepseek-ai/cordis': '*' } } },
-      'titled-plugin': { main: 'export default { apply() {} }\n', manifest: { dsh: { title: 'Titled' } } },
-      'plain-lib': { main: 'export const helper = 1\n' },
-      // A function export alone is not a plugin: lodash exports one too.
-      'function-lib': { main: 'export default function _() {}\n' },
-    })
-    const kindOf = async (packageName: string): Promise<string> =>
-      (await probePackage({ binName: NAME, profileDir, installAnchor, packageName })).kind
-    expect(await kindOf('peer-plugin')).toBe('plugin')
-    expect(await kindOf('titled-plugin')).toBe('plugin')
-    expect(await kindOf('function-lib')).toBe('library')
-    const lib = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'plain-lib' })
-    expect(lib.kind).toBe('library')
-    expect(lib.ok).toBe(true)
-  })
-
-  it('reports a package that fails to import, and one that brings its own cordis', async () => {
-    const { profileDir, installAnchor } = stage({
-      'broken': { main: 'throw new Error("import-time explosion")\n' },
-      'own-cordis': {
-        main: 'export function apply() {}\n',
-        files: {
-          'node_modules/@deepseek-ai/cordis/package.json': JSON.stringify({ name: '@deepseek-ai/cordis', version: '0.0.0', type: 'module', main: './index.js' }),
-          'node_modules/@deepseek-ai/cordis/index.js': 'export const Context = class {}\n',
-        },
-      },
-    })
-    const broken = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'broken' })
-    expect(broken.ok).toBe(false)
-    expect(broken.reason).toContain('import-time explosion')
-    expect(broken.kind).toBe('library')
-
-    const own = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'own-cordis' })
-    expect(own.cordisSameCopy).toBe(false)
-    expect(own.ok).toBe(false)
-    expect(own.reason).toContain('own copy of @deepseek-ai/cordis')
-  })
-
-  it('recognizes the harness\'s cordis through a link and through a packaged executable\'s proxy', async () => {
-    const harnessCordis = cordisPackageDir(import.meta.resolve('@deepseek-ai/cordis')) as string
-    const harnessEntry = import.meta.resolve('@deepseek-ai/cordis')
-    const { profileDir, installAnchor } = stage({
-      'linked': { main: 'export function apply() {}\n', manifest: { peerDependencies: { '@deepseek-ai/cordis': '*' } } },
-      // A cordis directory whose manifest names no package: an unknown copy, not a second one.
-      'unnamed': {
-        main: 'export function apply() {}\n',
-        manifest: { peerDependencies: { '@deepseek-ai/cordis': '*' } },
-        files: {
-          'node_modules/@deepseek-ai/cordis/package.json': JSON.stringify({ version: '0.0.0', type: 'module', main: './index.js' }),
-          'node_modules/@deepseek-ai/cordis/index.js': 'export const Context = class {}\n',
-        },
-      },
-      'proxied': {
-        main: 'export function apply() {}\n',
-        manifest: { peerDependencies: { '@deepseek-ai/cordis': '*' } },
-        files: {
-          // The proxy a packaged executable writes: a manifest naming its targets, and an entry that re-exports one.
-          'node_modules/@deepseek-ai/cordis/package.json': JSON.stringify({
-            name: '@deepseek-ai/cordis', version: '0.0.0', type: 'module', exports: { '.': './entry-0.js' },
-            dsh: { moduleFallback: { targets: { '.': harnessEntry } } },
-          }),
-          'node_modules/@deepseek-ai/cordis/entry-0.js': `export * from ${JSON.stringify(harnessEntry)}\n`,
-        },
-      },
-    })
-    // The profile links the harness's own cordis, as plain Node installs do.
-    mkdirSync(join(profileDir, 'node_modules', '@deepseek-ai'), { recursive: true })
-    symlinkSync(harnessCordis, join(profileDir, 'node_modules', '@deepseek-ai', 'cordis'), 'dir')
-    const linked = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'linked' })
-    expect(linked).toMatchObject({ kind: 'plugin', ok: true, cordisSameCopy: true })
-    const proxied = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'proxied' })
-    expect(proxied).toMatchObject({ kind: 'plugin', ok: true, cordisSameCopy: true })
-    const unnamed = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'unnamed' })
-    expect(unnamed).toMatchObject({ kind: 'plugin', ok: true, cordisSameCopy: null })
-    // A URL no cordis manifest encloses is an unknown copy, not a different one.
-    expect(cordisPackageDir(`file://${tmpdir()}/nowhere/index.js`)).toBeUndefined()
-  })
-
-  it('probes a package with the minimal manifest and no main export', async () => {
-    const { profileDir, installAnchor } = stage({
-      'minimal': { manifest: { version: undefined } },
-    })
-    const probe = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'minimal' })
-    // No version, description, title, engines, or main: only the facts every package has.
-    expect(probe).toEqual({
-      packageName: 'minimal', kind: 'library', ok: true, cordisSameCopy: null,
-      rows: [], overrides: [], addable: [], checkedAt: expect.any(String) as string,
-    })
-  })
-
-  it('records an addable module that fails to import without failing the package', async () => {
-    const { profileDir, installAnchor } = stage({
-      'partial': {
-        main: 'export function apply() {}\n',
-        manifest: { dsh: { plugins: [{ name: 'partial/missing' }] } },
-      },
-    })
-    const probe = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'partial' })
-    expect(probe.ok).toBe(true)
-    expect(probe.addable).toEqual([expect.objectContaining({ name: 'partial/missing', ok: false })])
-    expect(probe.addable[0]?.error).toBeDefined()
-  })
-
-  it('keeps probing a package that prints at import, and kills one that lingers once it reported', async () => {
-    const { profileDir, installAnchor } = stage({
-      '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 })
-    // The probe settled on the child's close: nothing of the killed child is still open.
-    expect(process.getActiveResourcesInfo()).not.toContain('ChildProcess')
-  })
-
-  it('takes only the message that echoes its token, hides credentials from the child, and keeps a stderr tail', async () => {
-    const { profileDir, installAnchor } = stage({
-      // A report forged at import, complete with the token's name: the token is
-      // gone from the environment and `process.send` from `process` by then.
-      'forges': {
-        main: 'process.send?.({ token: process.env.DSH_PROBE_REPORT ?? "", cordis: null, main: { ok: true, isPlugin: true, configSchema: null }, addable: {} })\n'
-          + 'export const notAPlugin = 1\n',
-        manifest: { dsh: { title: 'Forges' } },
-      },
-      'peeks': {
-        main: 'throw new Error("env=" + Object.keys(process.env).filter(k => k.startsWith("PROBE_TEST") || k === "DSH_PROBE_REPORT").sort().join(","))\n',
-      },
-      // The child's stderr is a pipe Node put in non-blocking mode (tsx touches
-      // process.stderr at start), so one raw write returns short or throws EAGAIN
-      // once the pipe is full: the flood retries until the parent has drained it.
-      'floods': {
-        main: 'import { writeSync } from "node:fs"\n'
-          + 'const put = (text) => { let rest = Buffer.from(text); while (rest.length > 0) { try { rest = rest.subarray(writeSync(2, rest)) } catch (error) { if (error.code !== "EAGAIN") throw error } } }\n'
-          + 'put("x".repeat(200_000))\nput("tail-marker")\nprocess.exit(3)\n',
-      },
-    })
-    const forged = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'forges' })
-    expect(forged).toMatchObject({ kind: 'library', ok: true })
-    process.env.PROBE_TEST_SECRET = 'hidden'
-    process.env.PROBE_TEST_PLAIN = 'visible'
-    try {
-      const peeked = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'peeks' })
-      expect(peeked.reason).toContain('env=PROBE_TEST_PLAIN')
-    } finally {
-      delete process.env.PROBE_TEST_SECRET
-      delete process.env.PROBE_TEST_PLAIN
-    }
-    const flood = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'floods' }).then(() => undefined, (error: unknown) => error as Error)
-    expect(flood?.message).toMatch(/exited with 3 without a report: x+tail-marker$/)
-    expect(flood?.message.length).toBeLessThan(17_000)
-  })
-
-  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' },
-      // `process.send` is gone by the time the package runs; a message it could send would not carry the token anyway.
-      'spoofs': { main: 'process.send?.({ nope: true }); process.exit(0)\n' },
-    })
-    await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'hangs', timeoutMs: 300 }))
-      .rejects.toThrow(/timed out after 300ms/)
-    await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'exits' }))
-      .rejects.toThrow(/exited with 3 without a report: refusing to report/)
-    await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'spoofs' }))
-      .rejects.toThrow(/exited with 0 without a report/)
-    await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'ghost' }))
-      .rejects.toThrow(/cannot resolve profile bundle "ghost"/)
-    await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'exits', nodeExecutable: '/no/such/node' }))
-      .rejects.toThrow(/ENOENT/)
-  })
-})
-
-describe('probe cache', () => {
-  it('round-trips a record per package and treats a different version as absent', () => {
-    const profileDir = mkdtempSync(join(tmpdir(), 'dsh-probe-cache-'))
-    const record: PluginProbe = {
-      packageName: '@scope/pkg', version: '1.0.0', kind: 'plugin', ok: true, cordisSameCopy: null,
-      rows: [], overrides: [], addable: [], checkedAt: '2026-09-04T00:00:00.000Z',
-    }
-    expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
-    writeProbeCache(profileDir, record)
-    expect(readProbeCache(profileDir, '@scope/pkg')).toEqual(record)
-    expect(readProbeCache(profileDir, '@scope/pkg', '1.0.0')).toEqual(record)
-    expect(readProbeCache(profileDir, '@scope/pkg', '2.0.0')).toBeUndefined()
-    writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), '{ not json')
-    expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
-    // A record an older probe wrote — no format, or another one — is probed again, not trusted.
-    writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), JSON.stringify(record))
-    expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
-    writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), JSON.stringify({ format: PLUGIN_PROBE_FORMAT - 1, ...record }))
-    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 = { token: 't', 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>[] = [
-      { token: undefined }, { token: 1 }, { 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()
-  })
-})
-
-describe('StreamTail', () => {
-  it('keeps the end of one oversized chunk and what a later small chunk pushes out of it', () => {
-    // Linux hands the parent a flood as one read; the marker written after it arrives alone.
-    const tail = new StreamTail(16)
-    tail.push(Buffer.from('x'.repeat(200)))
-    tail.push(Buffer.from('tail-marker'))
-    expect(tail.text()).toBe('xxxxxtail-marker')
-  })
-
-  it('drops whole leading chunks before trimming the oldest kept one', () => {
-    // macOS drains a flood in small reads: the kept chunks are the last few.
-    const tail = new StreamTail(10)
-    for (const piece of ['abcd', 'efgh', 'ijkl', 'mn']) tail.push(Buffer.from(piece))
-    expect(tail.text()).toBe('efghijklmn')
-    tail.push(Buffer.from('opq'))
-    expect(tail.text()).toBe('hijklmnopq')
-  })
-})

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

@@ -5,7 +5,7 @@
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
-import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import Loader, { type Entry, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { claimLayerIds, ProfileRuntime, type ComposedStack, type Profile, type ProfileLayer } from '../src/index.ts'
 
@@ -28,6 +28,7 @@ async function harness(
 ): Promise<{ ctx: Context; runtime: ProfileRuntime; compose: ReturnType<typeof vi.fn> }> {
   const ctx = new Context()
   contexts.push(ctx)
+  await ctx.plugin(Loader)
   // The conflicts, when given, belong to the reloaded profile only.
   const compose = vi.fn((current: Profile): ComposedStack => {
     const patches = [{ id: `composed-for-${current.layers.length}` }] as PatchOptions[]
@@ -70,11 +71,11 @@ describe('ProfileRuntime', () => {
       // Declares an id the first external layer owns: left out whole, so none of its rows has an origin.
       layer('late', 'external', [{ insert: [{ id: 'tool', name: 'late' }, { id: 'late-only', name: 'late/x' }] }]),
     ])
-    expect(runtime.originOf('settings')).toEqual({ trust: 'builtin', packageName: '@deepseek-ai/dsh-base', version: '2.0.0' })
-    expect(runtime.originOf('child')).toEqual({ trust: 'builtin', packageName: '@deepseek-ai/dsh-base', version: '2.0.0' })
-    expect(runtime.originOf('tool')).toEqual({ trust: 'external', packageName: 'ext', version: '2.0.0' })
-    expect(runtime.originOf('bundle/ext')).toEqual({ trust: 'external', packageName: 'ext', version: '2.0.0' })
-    expect(runtime.originOf('svc')).toEqual({ trust: 'external', packageName: 'boot-ext', version: '2.0.0' })
+    expect(runtime.originOf('settings')).toEqual({ trust: 'builtin', stage: 'runtime', packageName: '@deepseek-ai/dsh-base', version: '2.0.0' })
+    expect(runtime.originOf('child')).toEqual({ trust: 'builtin', stage: 'runtime', packageName: '@deepseek-ai/dsh-base', version: '2.0.0' })
+    expect(runtime.originOf('tool')).toEqual({ trust: 'external', stage: 'runtime', packageName: 'ext', version: '2.0.0' })
+    expect(runtime.originOf('bundle/ext')).toBeUndefined()
+    expect(runtime.originOf('svc')).toEqual({ trust: 'external', stage: 'boot', packageName: 'boot-ext', version: '2.0.0' })
     expect(runtime.originOf('late-only')).toBeUndefined()
     expect(runtime.originOf('user-row')).toBeUndefined()
   })
@@ -88,7 +89,7 @@ describe('ProfileRuntime', () => {
 
   it('omits the version when the layer has none', async () => {
     const { runtime } = await harness([{ ...layer('local', 'builtin', [{ insert: [{ id: 'r', name: 'local' }] }]), version: undefined }])
-    expect(runtime.originOf('r')).toEqual({ trust: 'builtin', packageName: 'local' })
+    expect(runtime.originOf('r')).toEqual({ trust: 'builtin', stage: 'runtime', packageName: 'local' })
   })
 
   it('reports the user-disabled rows of the committed composition and keeps them when the include rejects an update', async () => {
@@ -127,7 +128,7 @@ describe('ProfileRuntime', () => {
     expect(runtime.layers).toHaveLength(2)
     expect(update).toHaveBeenLastCalledWith({ config: { path: 'file:///root/cordis.yml', patches: [{ id: 'composed-for-2' }] } })
     // Provenance, conflicts, and the user-disabled rows 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')).toBeUndefined()
     expect(runtime.conflicts).toEqual(conflicts)
     expect([...runtime.userDisabledRowIds()]).toEqual(['reloaded-off'])
   })
@@ -167,7 +168,7 @@ describe('ProfileRuntime', () => {
     const first = runtime.recompose()
     const second = runtime.recompose()
     await expect(first).rejects.toThrow('rejected')
-    await expect(second).resolves.toBeUndefined()
+    await expect(second).resolves.toEqual([])
   })
 
   it('keeps the committed profile, provenance, and conflicts when the root include rejects the update', async () => {

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

@@ -18,16 +18,4 @@ export default defineConfig([
       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,
-  },
 ])

+ 1 - 0
packages/bundle/web-app/tests/web-app.spec.ts

@@ -323,6 +323,7 @@ describe('web-app runtime glue', () => {
   ])('announces readiness=$announces after the $id sibling fails', async ({ id, announces }) => {
     stageDist()
     const ctx = new Context()
+    ctx.provide('profileRuntime', { originOfEntry: () => announces ? { trust: 'external', stage: 'runtime' } : undefined } as unknown as AppBoot.ProfileRuntime)
     onTestFinished(() => ctx.fiber.dispose())
     ctx.baseUrl = 'file:///'
     ctx.provide('webServer', fakeHttpServer().server)

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

@@ -1409,6 +1409,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         parameters: [{ name: 'rowId', description: 'the row\'s id as the composition declares it.' }],
         returns: 'the origin, or undefined for a row no bundle layer owns (a user or overlay row, or a bundle left out by a conflict).',
       },
+      {
+        signature: 'originOfEntry(entry: Entry): RowOrigin | undefined',
+        description: 'Resolve provenance within its Loader tree; nested includes inherit their owning entry.',
+        parameters: [{ name: 'entry', description: 'the live entry, including an entry inside another Include.' }],
+        returns: 'its supplying bundle, or undefined for a user-owned entry.',
+      },
       {
         signature: 'userDisabledRowIds(): ReadonlySet<string>',
         description: 'Row ids the user patch layers disable with a literal `disabled: true`, as the committed composition read them. The set describes the running tree: a user file the include rejected, or one that cannot be parsed, changes nothing here until a composition with it is accepted.',
@@ -1422,10 +1428,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         returns: 'true when the user\'s patches disable the entry or one of the groups holding it.',
       },
       {
-        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 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. Calls queue: one that arrives while another is in flight starts after it settled and reads what it committed. A rejection is that call\'s outcome alone and does not stop the ones behind it.',
-        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.'],
+        signature: 'async recompose(options: { reloadBundles?: boolean } = {}): Promise<readonly EntryIssue[]>',
+        description: 'Apply a fresh profile stack and wait for live entries and removed fibers to settle. Parse/composition failures leave the applied stack unchanged. Accepted options can coexist with failed entries or fibers running their previous valid config. Calls serialize; a failed call does not block later changes.',
+        parameters: [{ name: 'options', description: 'whether to reread installed bundle layers from disk.' }],
+        returns: 'current entry issues after application, without rolling back successful siblings.',
+        throws: ['when preparation fails or the root Include cannot accept the update.'],
       },
     ],
   },
@@ -3841,6 +3848,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'BrandedNumber',
     declaration: 'export type BrandedNumber<B extends string> = number & {\n    readonly [BRAND]: B;\n};',
   },
+  {
+    name: 'BundleStage',
+    declaration: 'export type BundleStage = \'boot\' | \'runtime\';',
+  },
   {
     name: 'BundleTrust',
     declaration: 'export type BundleTrust = \'builtin\' | \'external\';',
@@ -4233,6 +4244,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'EncodedImageAttachment',
     declaration: 'export interface EncodedImageAttachment {\n    mediaType: ImageMediaType;\n    data: string;\n    name?: string;\n}',
   },
+  {
+    name: 'EntryIssue',
+    declaration: 'export interface EntryIssue {\n    readonly entry: Entry;\n    readonly stage: \'import\' | \'activation\' | \'update\' | \'disabled-expression\' | \'inject-pending\';\n    readonly message: string;\n    readonly error?: unknown;\n}',
+  },
   {
     name: 'EpochHeader',
     declaration: 'export interface EpochHeader {\n    config: LlmCallConfig;\n    adapterDefaults?: LlmCallConfigAdapterDefaults;\n    tools?: ToolSchema[];\n}',
@@ -4991,7 +5006,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'RowOrigin',
-    declaration: 'export interface RowOrigin {\n    readonly trust: BundleTrust;\n    readonly packageName: string;\n    readonly version?: string;\n}',
+    declaration: 'export interface RowOrigin {\n    readonly trust: BundleTrust;\n    readonly stage: BundleStage;\n    readonly packageName: string;\n    readonly version?: string;\n}',
   },
   {
     name: 'RunnerFailureRule',

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/plugin-inventory/README.md
-README.md: 6be50a1c0681003d43078ee6e048e988eaffb4c5
-README.zh.md: ea7aec0e12401e1d09f9d190786492fc795f6549
+README.md: b9b3056481e53aa523cdee658c1bb3e4d1ad273e
+README.zh.md: 10b7c05d616e03d079b6a928af86ee383b4b3797

+ 4 - 4
packages/host/plugin-inventory/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Clients can call `pluginInventory/list` to display the host’s current plugins in load order, including each entry’s identifier, module specifier, effective enablement, and live phase. Deployments with an agent-preset roster also report each preset’s metadata, health, and flattened plugin composition; without a roster, preset data is absent. Each response is a point-in-time, read-only snapshot for display and diagnostics: it cannot mutate plugins and provides no history, provenance, or change subscription.
+Clients call `pluginInventory/list` for a read-only snapshot of non-group Loader entries, their provenance, effective enablement, running phase, and current failures. Deployments with an agent-preset roster also report each preset’s metadata and composition. The snapshot carries no durable history or change subscription.
 
 ## Table of Contents
 
@@ -31,7 +31,7 @@ Call `pluginInventory/list` when a client or settings page needs to show what is
 
 Each row is one non-group Loader entry: its entry id, the exact module specifier, the effective enablement (including disabled ancestor groups), and the current root Fiber phase. `pending` means the entry waits to load, `loading` that it is being read, `active` that it is running, `failed` that its fiber rejected, and `unloading` that it is being torn down; `null` means no live root Fiber exists at all. Structural group rows are skipped.
 
-When the profile launcher composed the tree, each row also says who supplied it: `trust` is `builtin` for a row of the installation's own bundles, `external` for a row of a bundle the user installed, and `user` for a row the user's own patch file inserted that the composition left out, `package` names that bundle with its version and, for an external row, the id the bundle's own patch declared before the launcher prefixed it, and a disabled row carries `disabledBy` — `user` when a user patch file disabled it, or a group holding it, with a literal `disabled: true`, `composition` for a bundle's own gate or tombstone. A row an isolated external bundle failed to start is gone from the tree; it is still listed, with `fiberPhase: 'failed'` and a `failure` naming the stage and message, from the launcher's failure registry. Without the launcher every row reads `builtin` and none carries a package or failure.
+`trust` identifies the supplying bundle, `package` names it with its version, and `disabledBy` distinguishes a user override from a composition condition. Explicit row ids remain unchanged. Failed entries remain in the Loader tree; `failure` reports import, activation, update, disabled-expression, or missing-service issues. An `active` fiber may still have an update failure and run its previous valid config. Conflicting rows omitted before loading are projected from `ProfileRuntime.conflicts`. Without profile provenance, rows carry no package.
 
 ### Per-preset compositions
 
@@ -55,7 +55,7 @@ The gateway is a direct projection with no second lifecycle truth: every `list()
 
 ### The phase mapping
 
-Fiber states map onto the public phase vocabulary, with `disposed` folding into `null` — an entry whose fiber is gone has no live root to report. The phase therefore never distinguishes why no live root exists: the entry may never have started, or its fiber may already have been disposed.
+Fiber states map onto the public phase vocabulary, with `disposed` folding into `null`. An enabled entry with a recorded failure and no fiber reports `failed`; a normally disabled entry without a live fiber reports `null`.
 
 ### Source map
 
@@ -98,7 +98,7 @@ None; this package neither assembles nor sends a provider request.
 
 These limits define what a point-in-time inventory cannot tell a client. They are current package constraints, not a task backlog.
 
-- **Point-in-time state only** — the result contains no durable failure history or subscription; a missing root Fiber is reported as `null`, regardless of why no live root exists.
+- **Point-in-time state only** — the result contains no durable failure history or subscription; removing an entry removes its diagnostics.
 - **No mutation** — the service cannot enable, disable, add, or remove plugins in either plane; provenance stops at the bundle layer, so a row a user patch or a `--patch` overlay inserted carries no package.
 - **Presets appear only with a roster** — a deployment without `dsh-agent-presets` serves Loader entries alone; the `agentPresets` field is absent rather than empty.
 

+ 4 - 4
packages/host/plugin-inventory/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-客户端可以调用 `pluginInventory/list`,按加载顺序展示宿主的当前插件,包括每个条目的标识符、模块标识、有效启用状态与存活阶段。部署组合了 agent preset roster 时,还会报告各预设的元数据、健康状态与压平后的插件组合;没有 roster 时,预设数据缺席。每次响应都是供展示和诊断使用的只读即时快照:它不能修改插件,也不提供历史、来源信息或变更订阅。
+客户端调用 `pluginInventory/list`,读取非 group Loader 条目的只读快照,包括来源、有效启用状态、运行阶段和当前失败。包含 agent-preset 目录的部署还报告各预设的元信息与组合。快照不提供持久历史或变化订阅。
 
 ## 目录
 
@@ -31,7 +31,7 @@ kind: "package-reference"
 
 每一行是一个非组 Loader 条目:其条目 id、精确模块标识、有效启用状态(含被禁用的祖先组)与当前根 Fiber 阶段。`pending` 表示条目等待加载,`loading` 表示正在读取,`active` 表示正在运行,`failed` 表示其 fiber 被拒绝,`unloading` 表示正在拆除;`null` 表示完全不存在存活的根 Fiber。结构性的 group 行会被跳过。
 
-当树由 profile launcher 组合时,每一行还会说明是谁提供的:`trust` 对安装自带组合包的行是 `builtin`,对用户安装的组合包的行是 `external`,对用户自己的补丁文件插入却被组合排除的行是 `user`;`package` 给出该组合包的名称与版本,对外部行还给出组合包自己的 patch 在 launcher 加前缀之前声明的 id;停用的行带 `disabledBy`——用户 patch 文件用字面量 `disabled: true` 停用它、或停用了持有它的组的是 `user`,组合包自己的门或墓碑是 `composition`。被隔离的外部组合包启动失败的行已经不在树里;它仍从 launcher 的失败注册表列出,`fiberPhase` 为 `'failed'`,并带一个说明阶段与消息的 `failure`。没有 launcher 时每一行都读作 `builtin`,也没有 package 或 failure。
+`trust` 标识提供该行的组合包,`package` 给出包名与版本,`disabledBy` 区分用户覆盖与组合条件。显式行 id 保持不变。失败条目留在 Loader 树中;`failure` 报告导入、激活、更新、disabled 表达式或缺失服务问题。`active` fiber 也可能更新失败,并继续运行先前的有效配置。加载前因冲突被排除的行从 `ProfileRuntime.conflicts` 投影。没有 profile 来源时,行不携带 package。
 
 ### 每个预设的组合
 
@@ -55,7 +55,7 @@ kind: "package-reference"
 
 ### 阶段映射
 
-Fiber 状态映射到公共阶段词汇,其中 `disposed` 折叠为 `null`——fiber 已消失的条目没有可报告的存活根。因此阶段从不区分为什么没有存活根:条目可能从未启动,也可能其 fiber 已被释放。
+Fiber 状态映射为公开阶段,其中 `disposed` 映射为 `null`。启用但没有 fiber 且存在失败记录的条目报告 `failed`;正常禁用且没有活跃 fiber 的条目报告 `null`。
 
 ### 源码地图
 
@@ -98,7 +98,7 @@ Typert 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产
 
 这些限制说明即时清单无法向客户端提供哪些信息。它们是当前包约束,不是任务积压。
 
-- **仅表示调用当下**——结果不包含持久的失败历史或订阅;只要不存在存活的根 Fiber,就会报告 `null`,而不区分其原因。
+- **仅提供当前状态**——结果不包含持久失败历史或订阅;移除条目也会移除其诊断。
 - **无修改能力**——服务不能在任一平面启用、停用、添加或移除插件;来源只追溯到组合包层,用户 patch 或 `--patch` overlay 插入的行不带 package。
 - **预设仅随 roster 出现**——未装 `dsh-agent-presets` 的部署只提供 Loader 条目;`agentPresets` 字段缺席而非为空。
 

+ 7 - 25
packages/host/plugin-inventory/src/index.ts

@@ -4,9 +4,8 @@ import type { Context, FiberState } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/cordis-plugin-loader'
 // Type-only: the optional agent-preset roster resolved through `ctx.get`.
 import type {} from '@deepseek-ai/dsh-agent-presets'
-// Type-only: the optional profile runtime and contained-failure registry the
-// boot glue provides, both resolved through `ctx.get`.
-import type {} from '@deepseek-ai/dsh-app-boot'
+// Profile provenance and live entry diagnostics share the boot implementation.
+import { inspectEntryIssues } from '@deepseek-ai/dsh-app-boot'
 import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
 // Typert-generated ./typert and ./remote artifacts import Zod at runtime.
 import type {} from 'zod'
@@ -78,42 +77,25 @@ export class PluginInventoryGateway extends TypertRemoteService {
   async list(): Promise<PluginInventorySnapshot> {
     const entries: PluginInventoryEntry[] = []
     const runtime = this.ctx.get('profileRuntime')
-    const failures = this.ctx.get('pluginFailures')
-    const listed = new Set<string>()
+    const failures = new Map((await inspectEntryIssues(this.ctx)).map(issue => [issue.entry, issue]))
     for (const entry of this.ctx.loader.entries()) {
       if (entry.options.group) continue
-      listed.add(entry.id)
       // Provenance and user patches address rows by the id the composition
       // declares; the tree-wide `entry.id` adds the owning include's prefix.
-      const origin = runtime?.originOf(entry.options.id)
-      const enabled = !entry.disabled
-      const failure = failures?.get(entry.id)
+      const origin = runtime?.originOfEntry(entry)
+      const failure = failures.get(entry)
+      const enabled = failure?.stage === 'disabled-expression' || !entry.disabled
       entries.push({
         entryId: pluginEntryId(entry.id),
         moduleName: entry.options.name,
         enabled,
-        fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
+        fiberPhase: entry.fiber === undefined ? (failure === undefined ? null : 'failed') : FIBER_PHASE[entry.fiber.state],
         trust: origin?.trust ?? 'builtin',
         ...origin === undefined ? {} : { package: packageRef(origin) },
         ...enabled ? {} : { disabledBy: runtime?.userDisables(entry) === true ? 'user' as const : 'composition' as const },
         ...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } },
       })
     }
-    // A row an isolated bundle failed to start is gone from the tree; the
-    // registry is its only record, and the list must still show it.
-    for (const failure of failures?.list() ?? []) {
-      if (listed.has(failure.entryId)) continue
-      const origin = runtime?.originOf(failure.rowId)
-      entries.push({
-        entryId: pluginEntryId(failure.entryId),
-        moduleName: failure.moduleName,
-        enabled: true,
-        fiberPhase: 'failed',
-        trust: origin?.trust ?? 'external',
-        ...origin === undefined ? {} : { package: packageRef(origin) },
-        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) {

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

@@ -29,10 +29,10 @@ export interface PluginPackageRef {
   readonly version?: string
 }
 
-/** A recorded failure of a row inside an isolated external bundle, or a row the composition left out. */
+/** A current entry failure or unresolved dependency, or a row the composition left out. */
 export interface PluginFailure {
   /** The lifecycle step that failed; `conflict` is a row another layer already declares. */
-  readonly stage: 'import' | 'apply' | 'inject-pending' | 'conflict' | 'unknown'
+  readonly stage: 'import' | 'activation' | 'update' | 'disabled-expression' | 'inject-pending' | 'conflict'
   /** The failure text. */
   readonly message: string
 }
@@ -51,7 +51,7 @@ export interface PluginInventoryEntry {
   readonly package?: PluginPackageRef
   /** Present exactly when `enabled` is false. */
   readonly disabledBy?: PluginDisabledBy
-  /** Present for a row an isolated bundle failed to start, or a row the composition left out. */
+  /** Present for a failed attempt or unresolved dependency, including updates that leave an old fiber active. */
   readonly failure?: PluginFailure
 }
 

+ 15 - 15
packages/host/plugin-inventory/tests/inventory.spec.ts

@@ -3,7 +3,7 @@ import { Context, FiberState, type Plugin } from '@deepseek-ai/cordis'
 import Loader, { Group } from '@deepseek-ai/cordis-plugin-loader'
 import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
 import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets'
-import { ensurePluginFailures, ProfileRuntime, type ComposedStack, type Profile, type RowOrigin } from '@deepseek-ai/dsh-app-boot'
+import { ProfileRuntime, type ComposedStack, type Profile, type RowOrigin } from '@deepseek-ai/dsh-app-boot'
 import PluginInventoryGateway from '../src/index.ts'
 
 const contexts: Context[] = []
@@ -72,6 +72,7 @@ describe('PluginInventoryGateway', () => {
         enabled: true,
         fiberPhase: 'pending',
         trust: 'builtin',
+        failure: { stage: 'inject-pending', message: 'pending (waiting for service: neverReady)' },
       },
       {
         entryId: disabledId,
@@ -104,13 +105,13 @@ describe('PluginInventoryGateway', () => {
     const bare = await ctx.loader.create({ name: 'cordis:active' })
     const off = await ctx.loader.create({ name: 'cordis:active', disabled: true })
     const origins = new Map<string, RowOrigin>([
-      [versioned, { trust: 'external', packageName: 'ext', version: '1.2.3' }],
-      [bare, { trust: 'external', packageName: 'ext' }],
-      [off, { trust: 'external', packageName: 'ext' }],
-      ['gone', { trust: 'external', packageName: 'ext' }],
+      [versioned, { trust: 'external', stage: 'runtime', packageName: 'ext', version: '1.2.3' }],
+      [bare, { trust: 'external', stage: 'runtime', packageName: 'ext' }],
+      [off, { trust: 'external', stage: 'runtime', packageName: 'ext' }],
+      ['gone', { trust: 'external', stage: 'runtime', packageName: 'ext' }],
     ])
     ctx.provide('profileRuntime', {
-      originOf: (rowId: string) => origins.get(rowId),
+      originOfEntry: (entry: { id: string }) => origins.get(entry.id),
       userDisables: (entry: { id: string }) => entry.id === off,
       layers: [{ packageName: 'late', version: '9.9.9' }],
       conflicts: [
@@ -119,12 +120,11 @@ describe('PluginInventoryGateway', () => {
         { 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)
-    registry.record({ entryId: 'gone', rowId: 'gone', moduleName: 'cordis:throws', groupId: 'bundle/ext', stage: 'apply', message: 'boom' })
-    // A record of a row that later mounted rides on the live entry and is not listed twice.
-    registry.record({ entryId: bare, rowId: bare, moduleName: 'cordis:active', groupId: 'bundle/ext', stage: 'import', message: 'stale' })
-    // 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' })
+    await ctx.loader.create({ id: 'gone', name: './dsh-missing-fixture.mjs' } as Parameters<typeof ctx.loader.create>[0])
+    await ctx.loader.create({ id: 'orphan', name: './dsh-missing-fixture.mjs' } as Parameters<typeof ctx.loader.create>[0])
+    ctx.loader.resolve('gone').lastFailure = { stage: 'import', error: 'boom' }
+    ctx.loader.resolve(bare).lastFailure = { stage: 'update', error: 'invalid config' }
+    ctx.loader.resolve('orphan').lastFailure = { stage: 'import', error: 'lost' }
 
     const { entries } = await inventory.list()
     // Compared in id order: the Loader assigns ids, and one that comes out all
@@ -132,11 +132,11 @@ describe('PluginInventoryGateway', () => {
     const byId = (left: { entryId: string }, right: { entryId: string }): number => left.entryId.localeCompare(right.entryId)
     expect([...entries].sort(byId)).toEqual([
       { entryId: versioned, moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', trust: 'external', package: { name: 'ext', version: '1.2.3' } },
-      { entryId: bare, moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', trust: 'external', package: { name: 'ext' }, failure: { stage: 'import', message: 'stale' } },
+      { entryId: bare, moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', trust: 'external', package: { name: 'ext' }, failure: { stage: 'update', message: 'invalid config' } },
       { entryId: off, moduleName: 'cordis:active', enabled: false, fiberPhase: null, trust: 'external', package: { name: 'ext' }, disabledBy: 'user' },
       // A failed row the tree no longer holds is attributed through the runtime.
-      { entryId: 'gone', moduleName: 'cordis:throws', enabled: true, fiberPhase: 'failed', trust: 'external', package: { name: 'ext' }, failure: { stage: 'apply', message: 'boom' } },
-      { entryId: 'orphan', moduleName: 'cordis:throws', enabled: true, fiberPhase: 'failed', trust: 'external', failure: { stage: 'apply', message: 'lost' } },
+      { entryId: 'gone', moduleName: './dsh-missing-fixture.mjs', enabled: true, fiberPhase: 'failed', trust: 'external', package: { name: 'ext' }, failure: { stage: 'import', message: 'failed to import: boom' } },
+      { entryId: 'orphan', moduleName: './dsh-missing-fixture.mjs', enabled: true, fiberPhase: 'failed', trust: 'builtin', failure: { stage: 'import', message: 'failed to import: lost' } },
       // 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',

+ 7 - 7
packages/util/package-manifest/src/types.ts

@@ -34,9 +34,9 @@ export interface DshManifest {
   profile?: DshProfileManifest
   /** Client module loading and build metadata. */
   client?: DshClientManifest
-  /** Display title of the package, read by the package probe. */
+  /** Display title of the package, read without executing package code. */
   title?: string
-  /** Agent-plane modules the package declares addable to a composition, read by the package probe. */
+  /** Agent-plane modules the package declares addable to a composition, read without executing package code. */
   plugins?: DshPluginDeclaration[]
 }
 
@@ -53,16 +53,16 @@ export interface DshEnginesManifest {
 }
 
 /**
- * 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.
+ * Startup policy for an external bundle. `runtime` (the default) permits
+ * failed rows while retaining successful siblings; conflicting row ids omit
+ * the whole bundle. `boot` rows are required and claim ids with built-in layers.
+ * This field does not delay execution until after startup.
  */
 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. */
+  /** The package's export subpath, with `.` naming its main entry and `./tools` a subpath. */
   name: string
   /** Display title. */
   title?: string

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

@@ -185,8 +185,6 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
   // through a hashed chunk. The committed bin.js is the link target pnpm can
   // resolve at install time, before the build produces lib/bin.js.
   '@deepseek-ai/dsh-experimental-webworker-packer': ['bin.js', 'lib/repository-*.js'],
-  // 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 {

+ 1 - 1
scripts/gen-cordis-catalog.ts

@@ -154,7 +154,6 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
   configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns this launcher contract',
   launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns this launcher contract',
   dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract',
-  pluginFailures: 'not a service: boot-provided contained-failure registry (ContainedFailureRegistry | undefined) the plugin inventory reads — packages/boot/app-boot/README.md owns the boot contract',
   launchEnvironment: 'not a service: launcher-provided root accessor value (LaunchEnvironmentSnapshot | undefined) — packages/util/launch-environment/README.md owns this launcher contract',
   connection: 'interface-typed (HostConnectionHandle); implementing class HostConnectionService is declared in rpc-host.ts — packages/client/connection/README.md owns the API',
   fileUpload: 'client-side browser upload service — packages/client/file-upload/README.md owns the API',
@@ -700,6 +699,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
   InsertTextRequest: 'event-local request contract is owned by packages/client/ui-input-trigger/src/types.ts',
   AgentHandle: 'agent ownership handle is owned by packages/core/agent/README.md',
   AgentPreset: 'discovered preset record is owned by packages/preset/agent-presets/README.md',
+  EntryIssue: 'live entry diagnostics are owned by packages/boot/app-boot/README.md',
   RowOrigin: 'row provenance record of the profile runtime is owned by packages/boot/app-boot/README.md',
   AgentPresetRoster: 'path-free preset roster is owned by packages/preset/agent-presets/README.md',
   AgentPresetDocument: 'preset composition view is owned by packages/preset/agent-presets/README.md',

+ 2 - 2
vendor/README.md

@@ -49,8 +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).
 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.
-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 and overrides**: `applyEntryPatches` clones inserted rows and object-valued override values before they join the list, so a later patch that inserts into or overrides a row an earlier patch introduced 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 while an aliased override value carried them back into the patch itself. Covered by `packages/boot/app-boot/tests/external-bundles.spec.ts`.
+20. **`loader/src/config/entry.ts` passive diagnostics**: `Entry.lastFailure` retains the original error and import, activation, or update stage without changing native per-row continuation or exception propagation. It clears on disable, a fresh import, or a configuration application. A rejected config update may coexist with an active fiber running its prior config; consumers must inspect both. Covered by `packages/boot/app-boot/tests/entry-issues.spec.ts`.
+21. **`include/src/index.ts` patch copies**: inserted rows and object-valued overrides are cloned before joining the output, so later patches cannot mutate reusable input patches. Repeated Include application preserves the same children and can remove user overrides. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts`; app-boot additionally supplies fresh execution copies during profile recomposition.
 
 ## Sync procedure
 

+ 1 - 2
vendor/include/src/index.ts

@@ -50,8 +50,7 @@ function retryableWriteError(error: unknown): boolean {
  * values are cloned before they join the list for the same reason: a later
  * patch that inserts into or overrides a row an earlier patch introduced
  * mutates the copy, never the patch, 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
+ * profile recomposition or a file refresh) 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.

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

@@ -21,15 +21,10 @@ export interface EntryOptions {
   inject?: Inject | null
 }
 
-/** 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 })
-  }
+/** Failure of the latest entry import, activation, or option update. */
+export interface EntryFailure {
+  stage: 'import' | 'activation' | 'update'
+  error: unknown
 }
 
 function takeEntries(object: {}, keys: string[]) {
@@ -55,6 +50,8 @@ export class Entry {
 
   public ctx: Context
   public fiber?: Fiber
+  /** Latest failed attempt; an active fiber can still run its previous config. */
+  public lastFailure?: EntryFailure
   public parent!: EntryGroup
   // safety: call `entry.update()` immediately after creating an entry
   public options = {} as EntryOptions
@@ -124,6 +121,15 @@ export class Entry {
 
   /** Merge new options, restart as needed, and persist through the parent tree. */
   async update(options: Partial<EntryOptions>, create = false, force = false) {
+    try {
+      await this.updateOptions(options, create, force)
+    } catch (error) {
+      if (this.lastFailure?.error !== error) this.lastFailure = { stage: 'update', error }
+      throw error
+    }
+  }
+
+  private async updateOptions(options: Partial<EntryOptions>, create: boolean, force: boolean) {
     const legacy = { ...this.options }
 
     // step 1: update options
@@ -142,6 +148,7 @@ export class Entry {
 
     // step 2: execute
     if (this.disabled) {
+      this.lastFailure = undefined
       this.fiber?.dispose()
       return
     }
@@ -152,6 +159,7 @@ export class Entry {
         .keys({ ...this.options, ...legacy })
         .filter(key => !deepEqual(this.options[key], legacy[key]))
       if (!diff.length && !force) return
+      if (diff.includes('config') || this.options.group) this.lastFailure = undefined
       this.context.emit('loader/partial-dispose', this, legacy, true)
       this._patchContext(diff)
     } else {
@@ -184,18 +192,25 @@ export class Entry {
   }
 
   private async _init() {
+    this.lastFailure = undefined
     let exports: any
     try {
       exports = await this.parent.tree.import(this.options.name, this.getOuterStack)
     } catch (error) {
+      this.lastFailure = { stage: 'import', error }
       this.ctx.logger.error(error)
       return
     } finally {
       this._initTask = undefined
     }
-    const plugin = this.loader.unwrapExports(exports)
-    this._patchContext([])
-    this.loader.showLog(this, 'apply')
-    this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack)
+    try {
+      const plugin = this.loader.unwrapExports(exports)
+      this._patchContext([])
+      this.loader.showLog(this, 'apply')
+      this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack)
+    } catch (error) {
+      this.lastFailure = { stage: 'activation', error }
+      throw error
+    }
   }
 }