dsh-m 0.9.22 → 0.9.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,20 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
8
8
 
9
9
  ## 中文
10
10
 
11
+ ### 0.9.24 变更:排除条目代管——供应链等待期从「提前拒绝」到「治理 + 登记」(ADR-0009)
12
+
13
+ - **问题(实机 2026-10-03 实证)**:0.9.19 的委派前预检把「目标版本未满 24h 等待期」预测成"官方管理器必拦"并提前拒绝——同目标 dsh-market 却能装上(copilot-auth@1.2.4 发布 8 分钟、quota-watch@0.1.21 两例)。pnpm 11.7 默认非严格策略对显式点名的新版本本来就放行并自动登记排除条目,预检的墙并不存在。
14
+ - **三挂点代管**:① 委派前治理——desktop profile 排除块的坏形态(pnpm 自追加的死规则)合并为"每包一条、版本并集复合";② 装机成功后登记——等待期内目标并入排除块(scoped 精确单条 / 非 scoped 目标+上一版双选择器);③ 双码失败(`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` / `ERR_PNPM_NO_MATURE_MATCHING_VERSION`)→ 治理 → 至多重试一次,仍败走既有失败翻译与账实分裂复读。web ladder 同套三挂点。
15
+ - **边界与安全**:红线收窄出唯一例外(desktop 仅此块、仅委派前后挂点、原子写、解析失败即弃、留痕不落全文);治理/登记全程持官方同款锁并 fail-open——任何失败不阻塞委派,pnpm 仍是最终执行者;显式设置 age 或 strict 的 profile 仍前置拒绝并给可重试时刻;机理按"首条规则生效"口径改判(know-how 020 §2.4 形态论退役)。
16
+ - **文档**:决策全文 `docs/adr/0009-release-age-exclude-governance.md`(含 9 项评估过不做的栅栏附录与观察预案);GLOSSARY 新词「首条规则/排除条目/治理/登记/等待期」。
17
+
18
+ ### 0.9.23 变更:已装页两段加载——列表秒开 + 更新提示就地补 ⬆(ADR-0008)
19
+
20
+ - **问题(实机 2026-10-03 实证)**:更新探测焊在已装列表接口里且带 60min 内存 TTL——发版后重开面板吃到陈旧缓存,升级提示迟迟不出现(quota-watch 新版发布 17 分钟后面板仍无提示,dshmarket 同刻已见)。
21
+ - **两段并行(零新增按钮)**:第一段 `installed {probe:false}` 立即返回已装列表(registry 匹配与开关相位不受影响);第二段新方法 `installedUpdates`(`probeMode: 'only'`,**TTL=0 每次挂载真实探测**)完成后把 ⬆ 徽标 / tab 红点 / 「全部升级 (N)」就地补上;探测失败仍逐项走「检查未完成」,不冒充「没得更新」。
22
+ - **边界与副作用(ADR-0008 如实留痕)**:范围仅已装页——市场浏览页探测、`dshm_list`/`dshm_outdated` 工具、CLI 行为不变(缺省 `probeMode: 'full'`);ttl=0 会先删 host namespace 共享缓存条目再重探,浏览页/工具的 TTL 内命中被刷新为更新值(数据只更新鲜、代码路径与自身 TTL 制度不变),GitHub 被动预算消耗速率上升(上限 25/req、50/h 滚动不变),超限走 latestError 优雅降级。
23
+ - **文档**:决策全文 `docs/adr/0008-installed-two-phase-probe.md`;DESIGN「已装页/缓存语义」补句;GLOSSARY 新词「两段加载」。
24
+
11
25
  ### 0.9.22 变更:升级生效判定——tarball 差异三态分类 + 三端重启提示分流(ADR-0007)
12
26
 
13
27
  - **问题(0.9.21 实证)**:`@iasiv5/dsh-skins` 1.2.3→1.3.0 升级后未重启即已生效(客户端 bundle rev 热更),三端仍无条件提示「需要重启」——警报疲劳会侵蚀提示的权威性,重启本身也有真实成本(web 服务瞬断 / desktop 手动重开)。
@@ -242,6 +256,20 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
242
256
 
243
257
  ## English
244
258
 
259
+ ### Changed in 0.9.24 — release-age exclusion governance: from pre-flight refusal to govern + register (ADR-0009)
260
+
261
+ - **Problem (observed live 2026-10-03)**: the 0.9.19 pre-flight precheck predicted "the official manager will block a target inside the 24h release-age window" and refused up front — while dsh-market installed the very same targets (copilot-auth@1.2.4 published 8 minutes prior; quota-watch@0.1.21). pnpm 11.7's default non-strict policy admits explicitly named fresh releases and auto-registers their exclusion entries; the wall the precheck predicted did not exist.
262
+ - **Three governance hooks**: (1) pre-delegation govern — merge malformed exclusion shapes (pnpm's auto-appended dead rules) into "one rule per package, version union"; (2) post-success register — fold in-window targets into the exclusion block (scoped exact / non-scoped target+previous composite); (3) on dual-code failure (`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` / `ERR_PNPM_NO_MATURE_MATCHING_VERSION`) → govern → retry the command at most once, then fall through to the existing failure translation. The web ladder gets the same three hooks.
263
+ - **Boundary & safety**: the red line narrows by exactly one exception (desktop: this block only, delegation hooks only, atomic write, parse-failure bail-out, tracing without file dumps); govern/register hold the official same lock and fail open — any failure never blocks delegation, pnpm stays the final enforcer; profiles that explicitly set `minimumReleaseAge` or `minimumReleaseAgeStrict` still get refused up front with a retry time; the mechanism verdict follows "first rule wins" (know-how 020 §2.4 form-theory retired).
264
+ - **Docs**: full decision record in `docs/adr/0009-release-age-exclude-governance.md` (with a fence appendix of 9 evaluated-and-skipped items and the observation contingency); GLOSSARY terms "First Rule / Release-Age Exclusion / Govern / Register / Release Age".
265
+
266
+ ### Changed in 0.9.23 — two-phase Installed tab: instant list + update hints filled in place (ADR-0008)
267
+
268
+ - **Problem (observed live 2026-10-03)**: update probes were welded into the installed-list endpoint behind a 60-min in-memory TTL — after publishing a release, reopening the panel served the stale cache and update hints lagged (quota-watch's new version stayed invisible 17 minutes after publish while dsh-market already showed it).
269
+ - **Two parallel phases (zero new buttons)**: phase 1 `installed {probe:false}` returns the installed list immediately (registry matching and toggle phase untouched); phase 2 is a new `installedUpdates` method (`probeMode: 'only'`, **TTL=0 — a real probe on every panel mount**) that fills in ⬆ badges / the tab dot / "Upgrade all (N)" in place; failed checks still surface per item as "check incomplete" instead of pretending "up to date".
270
+ - **Scope & side effects (documented in ADR-0008)**: installed tab only — browse-page probes, `dshm_list`/`dshm_outdated` tools and CLI are unchanged (default `probeMode: 'full'`); ttl=0 evicts shared host-namespace cache entries before re-probing, so browse/tools TTL hits receive fresher write-backs (data only gets fresher; code paths and their own TTL regimes unchanged); GitHub passive budget consumption rate rises (limits unchanged: 25/req, 50/h rolling) with graceful `latestError` degradation.
271
+ - **Docs**: full decision record in `docs/adr/0008-installed-two-phase-probe.md`; DESIGN notes on the installed page / cache semantics; GLOSSARY term "Two-phase Load".
272
+
245
273
  ### Changed in 0.9.22 — upgrade activation classification (tarball diff, three states) + three-surface restart-hint split (ADR-0007)
246
274
 
247
275
  - **Problem (observed in 0.9.21)**: after upgrading `@iasiv5/dsh-skins` 1.2.3→1.3.0 the plugin was already live without a restart (client bundle rev hot-reload), yet all three surfaces still said "restart required" — the noise trains users to ignore the hint, and restarts have real costs (web service blip / manual desktop reopen).
package/docs/DESIGN.md CHANGED
@@ -21,7 +21,7 @@
21
21
  - **严格 v1 schema**:顶层只允许 `version/plugins`,条目只允许 `id/name/description/category/tags/source/npm/github/homepage/icon`;未知字段、非法 ID/npm/GitHub/URL、重复 ID/tag、字段超限、`plugins` 超过 1,000 条均拒绝整份清单(不截断、不部分加载);超过 200 条提示性能边界。
22
22
  - **缓存模型**:cache 按 namespace 分目录(Host 与 Agent tools 用 `host/`,独立 CLI 用 `cli/`,互不删除),按带算法版本的 cacheKey 分文件(`CacheFile v2`:原子临时文件 + fsync + rename,0700/0600,symlink 拒写,同 key 进程内写锁)。**候选验证只写候选 cache,绝不 prune 旧源**;settings 写入成功后才 `commitActiveSource`:先原子写 accepted-source metadata(`host/active-source.json`,仅 Host),再清理非当前 custom cache;metadata 失败不 prune、prune 失败保留旧 cache,均只降级为 warning,不撤销已生效的配置。切换后只保留默认缓存与当前 custom 缓存。
23
23
  - **统一安全 HTTP**:所有 JSON/text/HEAD 请求共享同一 primitive——手动重定向(每跳校验协议、最多 3 跳、循环检测、signal 传播、返回最终 URL)+ 响应大小上限 + 超时。
24
- - **秒开缓存语义(0.9.14 SWR)**:`loadRegistry` 与社区获取链的 TTL 过期不再同步等网络——磁盘缓存存在即**先回 stale**(registry `source='default-cache'`、社区 `status='stale'` 既有契约;stale 状态由设置页社区卡承接,市场页不提示临时缓存状态——0.9.15 修订),后台**单飞**刷新写回 cache(同 key 进行中复用;不接调用者 signal,后台自愈不被请求 abort 腰斩;`force`/candidate/显式下载三路径不经 SWR)。默认双线路**粘性**:`loadDefaultChain` 读 cache 记录的上次成功 `source` 排序线路(稳定排序,cache 缺失 = 原序,force 同样生效)。社区 bg 自愈是 force flight——**不走同版本短路**(`if (!opts.force)` 守卫),每 TTL 至多全量重拉一次正文(同步路径的短路优化保留)。页条目 latest 探测缓存为 `latest-cache.ts` 的**纯内存 Map + TTL**(0.9.20 ADR-0006,推翻 0.9.14 的「磁盘信封 write-through + 懒 seed、跨服务重启存活」——2026-10-03 事故实证发版窗口内重启/重开面板全吃陈旧值,重启即失效回归为特性;install/upgrade/uninstall 成功点由 market.ts 按 itemId 定向作废该条目全部 registryKey 变体,只失效不回写;0.9.14 遗留 `<cacheRoot>/latest/<ns>.json` 惰性文件由探测段一次性 best-effort 清扫,`latest/` 在 nsDir 之外、不受 `pruneCaches` 清扫的不变量不变)。客户端默认首页响应存 localStorage(10min TTL、双 zone 分键、读侧再过 normalize 收敛)——面板打开先渲染快照后 background 换新。
24
+ - **秒开缓存语义(0.9.14 SWR)**:`loadRegistry` 与社区获取链的 TTL 过期不再同步等网络——磁盘缓存存在即**先回 stale**(registry `source='default-cache'`、社区 `status='stale'` 既有契约;stale 状态由设置页社区卡承接,市场页不提示临时缓存状态——0.9.15 修订),后台**单飞**刷新写回 cache(同 key 进行中复用;不接调用者 signal,后台自愈不被请求 abort 腰斩;`force`/candidate/显式下载三路径不经 SWR)。默认双线路**粘性**:`loadDefaultChain` 读 cache 记录的上次成功 `source` 排序线路(稳定排序,cache 缺失 = 原序,force 同样生效)。社区 bg 自愈是 force flight——**不走同版本短路**(`if (!opts.force)` 守卫),每 TTL 至多全量重拉一次正文(同步路径的短路优化保留)。页条目 latest 探测缓存为 `latest-cache.ts` 的**纯内存 Map + TTL**(0.9.20 ADR-0006,推翻 0.9.14 的「磁盘信封 write-through + 懒 seed、跨服务重启存活」——2026-10-03 事故实证发版窗口内重启/重开面板全吃陈旧值,重启即失效回归为特性;install/upgrade/uninstall 成功点由 market.ts 按 itemId 定向作废该条目全部 registryKey 变体,只失效不回写;0.9.14 遗留 `<cacheRoot>/latest/<ns>.json` 惰性文件由探测段一次性 best-effort 清扫,`latest/` 在 nsDir 之外、不受 `pruneCaches` 清扫的不变量不变)。客户端默认首页响应存 localStorage(10min TTL、双 zone 分键、读侧再过 normalize 收敛)——面板打开先渲染快照后 background 换新。已装面板第二段探测为 TTL=0 例外(ADR-0008)。
25
25
  - **live 语义**:registry 地址是 live 设置,应用成功即时生效(无需重启);只有**首次部署新 Host 代码**需要一次用户确认后的 DSH Web 重启。`timeoutMs`/`cacheTtlMin` 亦为 live 读取;agent 框架注册时的静态工具 deadline 不承诺热更新。
26
26
 
27
27
  ### 2.2 Schema(v1 定稿,严格校验)
@@ -152,6 +152,8 @@ npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模
152
152
 
153
153
  与 `application`(Profile 变更事务词汇)的边界:`activation` 只是「新版本如何生效」的操作建议,不是事务状态——事务四态(applied/restart-required/overridden/failed)描述**变更本身**,`activation` 描述**生效方式**;两者不互相推导(GLOSSARY Avoid 行同源)。
154
154
 
155
+ 排除条目治理与登记(desktop 红线例外的唯一记载处为 ADR):见 [ADR-0009](./adr/0009-release-age-exclude-governance.md)。
156
+
155
157
  ## 4. GUI(旗舰,v1 必须做好)
156
158
 
157
159
  0.4.0 增补:已装卡右上 `dshm-switch` 开关(受 `toggleable` 控制,锁因 title 提示);sub 行相位点 `● active · v1.0.8 · npm`(相位点只映射 phase 五值,「已停用」归 Switch,两输入源各管各的);开关结果通知按 `applied` 分流(live → 绿 toast「即时生效」;restart-required → 沿用重启横幅 + 一键重启);安装遇兼容拦截(409 + issue)弹「仍要安装」确认(红字风险 + peers 清单 → `forceIncompatible` 重发)。
@@ -162,7 +164,7 @@ npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模
162
164
  3 个视图,**中文优先**,跟随 DSH Web 深色主题(0.7.0:卡片展开式详情已由 Modal 取代):
163
165
 
164
166
  1. **市场页**(默认):**分区制**(0.7.0 §2.6)——社区/精选/收藏三分区,各分区独立分类/搜索/排序/分页状态;搜索/分类/排序为**服务端处理**(Host 强制 `withLatest=true`、limit clamp 1..96 缺省 24,探测仅当前页);`MarketPanel` 是市场/已装数据唯一 owner(请求 generation + AbortController 丢弃旧响应);安装/卸载/升级完成后通过统一协调器同时刷新市场与已装快照,不要求关闭并重新打开面板;页码窗口化分页(24/48/96)+ 默认/自定义/缓存/不可用短提示(不含本地路径;0.5.0 的「超过 200 条性能提示」随分区退役);详情 Modal 保留 README markdown 预览与 npm/GitHub 官方外链。
165
- 2. **已装页**:profile 实际安装列表,标注来源(市场/非市场/未知);registry 不可用时仍列出已装并标记;「可升级 → x.y.z」徽标 +「升级」;「卸载」。
167
+ 2. **已装页**:profile 实际安装列表,标注来源(市场/非市场/未知);registry 不可用时仍列出已装并标记;「可升级 → x.y.z」徽标 +「升级」;「卸载」。两段加载(ADR-0008):列表不等待探测(`probe:false`),更新提示由 `installedUpdates`(TTL=0)就地补上。
166
168
  3. **设置页**:registry 地址草稿 +「校验并应用」(先校验候选再写 settings,失败不落盘)/「恢复默认」/「下载默认 registry.json」(不改当前配置)/「检查条目可达性」(probe 统计 + 最多 100 条 issue,只读不改配置);同时展示配置地址、当前生效配置、生效来源与 configStatus(含 rejected/回滚原因与维护性 warnings);完整本地路径仅在此页显示。registry 配置为 **live 生效,不出现安装类重启横幅**;安装/卸载/升级仍保留重启横幅。
167
169
 
168
170
  技术:`src/client.js` 经 **esbuild** 打包为 `lib/client.js`;`window.__ModuleLoader__.load({ id: "dsh-m", factory })` 注册;**React 从 module loader require**(零额外运行时依赖);manifest 注入 `@deepseek-ai/dsh-client-runtime`、`@deepseek-ai/dsh-client-ui-slots`、`@deepseek-ai/dsh-client-ui-settings`。
package/lib/client.js CHANGED
@@ -689,10 +689,11 @@ var view_refresh_exports = {};
689
689
  __export(view_refresh_exports, {
690
690
  refreshAfterMutation: () => refreshAfterMutation
691
691
  });
692
- async function refreshAfterMutation({ marketReload, installedReload }) {
692
+ async function refreshAfterMutation({ marketReload, installedReload, updatesReload }) {
693
693
  await Promise.all([
694
694
  marketReload(false),
695
- installedReload()
695
+ installedReload(),
696
+ updatesReload()
696
697
  ]);
697
698
  }
698
699
  var init_view_refresh = __esm({
@@ -701,6 +702,53 @@ var init_view_refresh = __esm({
701
702
  }
702
703
  });
703
704
 
705
+ // src/client/installed-updates.js
706
+ var installed_updates_exports = {};
707
+ __export(installed_updates_exports, {
708
+ applyInstalledUpdates: () => applyInstalledUpdates,
709
+ installedUpdateStats: () => installedUpdateStats
710
+ });
711
+ function applyInstalledUpdates(items, updates) {
712
+ if (!Array.isArray(items)) return [];
713
+ const byPkg = /* @__PURE__ */ new Map();
714
+ if (Array.isArray(updates)) {
715
+ for (const u of updates) {
716
+ if (!u || typeof u !== "object" || typeof u.pkg !== "string" || u.pkg === "") continue;
717
+ byPkg.set(u.pkg, u);
718
+ }
719
+ }
720
+ return items.map((it) => {
721
+ if (!it || typeof it !== "object") return it;
722
+ const u = byPkg.get(it.pkg);
723
+ if (!u) return it;
724
+ return {
725
+ ...it,
726
+ latestVersion: u.latestVersion ?? void 0,
727
+ latestTag: u.latestTag ?? void 0,
728
+ latestSha: u.latestSha ?? void 0,
729
+ outdated: u.outdated === true,
730
+ latestError: u.latestError ?? void 0,
731
+ latestErrorCode: u.latestErrorCode ?? void 0
732
+ };
733
+ });
734
+ }
735
+ function installedUpdateStats(items) {
736
+ const arr = Array.isArray(items) ? items : [];
737
+ let outdatedCount = 0;
738
+ let incompleteCount = 0;
739
+ for (const it of arr) {
740
+ if (!it || typeof it !== "object") continue;
741
+ if (it.outdated === true) outdatedCount += 1;
742
+ if (typeof it.latestError === "string" && it.latestError !== "") incompleteCount += 1;
743
+ }
744
+ return { outdatedCount, incompleteCount };
745
+ }
746
+ var init_installed_updates = __esm({
747
+ "src/client/installed-updates.js"() {
748
+ "use strict";
749
+ }
750
+ });
751
+
704
752
  // src/client/operations.js
705
753
  var operations_exports = {};
706
754
  __export(operations_exports, {
@@ -1169,6 +1217,7 @@ var { toggleViewModel: toggleViewModel2, toggleNoticeKeys: toggleNoticeKeys2 } =
1169
1217
  var { pickPayload: pickPayload2, parseToolArgs: parseToolArgs2 } = (init_tool_view(), __toCommonJS(tool_view_exports));
1170
1218
  var { RESTART_POLL_MS: RESTART_POLL_MS2, RESTART_DEADLINE_MS: RESTART_DEADLINE_MS2, nextRestartWait: nextRestartWait2, isAmbiguousRestartRequestError: isAmbiguousRestartRequestError2 } = (init_restart_wait(), __toCommonJS(restart_wait_exports));
1171
1219
  var { refreshAfterMutation: refreshAfterMutation2 } = (init_view_refresh(), __toCommonJS(view_refresh_exports));
1220
+ var { applyInstalledUpdates: applyInstalledUpdates2, installedUpdateStats: installedUpdateStats2 } = (init_installed_updates(), __toCommonJS(installed_updates_exports));
1172
1221
  var { createOperationsStore: createOperationsStore2, restoreRecords: restoreRecords2, createOpsPump: createOpsPump2, opAppliesTo: opAppliesTo2, TERMINAL_CLEARABLE: TERMINAL_CLEARABLE2, upgradeNotify: upgradeNotify2 } = (init_operations(), __toCommonJS(operations_exports));
1173
1222
  var { createFavoritesStore: createFavoritesStore2, partitionStale: partitionStale2 } = (init_favorites(), __toCommonJS(favorites_exports));
1174
1223
  var { readSelfCheckCache: readSelfCheckCache2, writeSelfCheckCache: writeSelfCheckCache2, clearSelfCheckCache: clearSelfCheckCache2, deriveChipState: deriveChipState2 } = (init_self_check(), __toCommonJS(self_check_exports));
@@ -2924,8 +2973,9 @@ function ReadmeBlock({ pkg }) {
2924
2973
  state.truncated ? h("div", { className: "dshm-md-note" }, lookup("readme.truncated")) : null
2925
2974
  );
2926
2975
  }
2927
- function InstalledTab({ notify, installed, onMutation, ops }) {
2976
+ function InstalledTab({ notify, installed, updates, onMutation, ops }) {
2928
2977
  const { loading, data, error, reload } = installed;
2978
+ const updateRows = updates && updates.data && Array.isArray(updates.data.updates) ? updates.data.updates : [];
2929
2979
  const [openPkg, setOpenPkg] = useState(null);
2930
2980
  const [readmePkg, setReadmePkg] = useState(null);
2931
2981
  const activeMutateTarget = (ops.records.find((r) => r.kind !== "install" && (r.status === "running" || r.status === "queued" || r.status === "input")) || {}).target || null;
@@ -3004,7 +3054,7 @@ function InstalledTab({ notify, installed, onMutation, ops }) {
3004
3054
  };
3005
3055
  if (loading && !data) return h("div", { className: "dshm-empty" }, lookup("installed.loading"), Spin());
3006
3056
  if (error) return h("div", { className: "dshm-err" }, lookup("failed.read", { err: error }));
3007
- const items = data && data.items || [];
3057
+ const items = applyInstalledUpdates2(data && data.items || [], updateRows);
3008
3058
  if (!items.length) return h("div", { className: "dshm-empty" }, `${lookup("installed.empty")} (${data.profileDir})`);
3009
3059
  return h(
3010
3060
  React.Fragment,
@@ -3626,7 +3676,8 @@ function MarketPanel({ onClose }) {
3626
3676
  const marketCommunity = useMarketData("community");
3627
3677
  const marketPrimary = useMarketData("primary");
3628
3678
  const markets = { community: marketCommunity, primary: marketPrimary };
3629
- const installed = useAsync(() => api("installed"), []);
3679
+ const installed = useAsync(() => api("installed", { probe: false }), []);
3680
+ const updates = useAsync(() => api("installedUpdates"), []);
3630
3681
  const [opRecords, setOpRecords] = useState(() => opsStore.list().map((r) => ({ ...r })));
3631
3682
  const syncOps = useCallback(() => setOpRecords(opsStore.list().map((r) => ({ ...r }))), []);
3632
3683
  const runOp = useCallback(
@@ -3683,14 +3734,14 @@ function MarketPanel({ onClose }) {
3683
3734
  [marketCommunity.reload, marketPrimary.reload]
3684
3735
  );
3685
3736
  const refreshViews = useCallback(
3686
- () => refreshAfterMutation2({ marketReload: marketReloadAll, installedReload: installed.reload }),
3687
- [marketReloadAll, installed.reload]
3737
+ () => refreshAfterMutation2({ marketReload: marketReloadAll, installedReload: installed.reload, updatesReload: updates.reload }),
3738
+ [marketReloadAll, installed.reload, updates.reload]
3688
3739
  );
3689
3740
  const onRegistryChanged = refreshViews;
3690
3741
  const stillApplies = useCallback(async (r) => {
3691
3742
  let fresh = null;
3692
3743
  try {
3693
- fresh = await api("installed");
3744
+ fresh = await api("installed", { probe: false });
3694
3745
  } catch {
3695
3746
  fresh = null;
3696
3747
  }
@@ -3737,7 +3788,8 @@ function MarketPanel({ onClose }) {
3737
3788
  market: marketCommunity.data ? marketCommunity.data.total : null,
3738
3789
  installed: installed.data ? installed.data.items.length : null
3739
3790
  };
3740
- const outdatedCount = installed.data && Array.isArray(installed.data.items) ? installed.data.items.filter((x) => x && x.outdated).length : 0;
3791
+ const mergedItems = installed.data && Array.isArray(installed.data.items) ? applyInstalledUpdates2(installed.data.items, updates.data && Array.isArray(updates.data.updates) ? updates.data.updates : []) : null;
3792
+ const outdatedCount = installedUpdateStats2(mergedItems || []).outdatedCount;
3741
3793
  const [banner, setBanner] = useState(null);
3742
3794
  const [toast, setToast] = useState(null);
3743
3795
  useEffect(() => {
@@ -3817,7 +3869,7 @@ function MarketPanel({ onClose }) {
3817
3869
  "div",
3818
3870
  { className: "dshm-body" },
3819
3871
  tab === "market" ? h(MarketTab, { notify, markets, onMutation: refreshViews, ops, favorites, profileKind: profile?.kind ?? null }) : null,
3820
- tab === "installed" ? h(InstalledTab, { notify, installed, onMutation: refreshViews, ops }) : null,
3872
+ tab === "installed" ? h(InstalledTab, { notify, installed, updates, onMutation: refreshViews, ops }) : null,
3821
3873
  tab === "settings" ? h(SettingsTab, { notify, onRegistryChanged }) : null,
3822
3874
  h(OperationsPanel, {
3823
3875
  records: opRecords,
@@ -11,6 +11,9 @@ import { dirname, isAbsolute, join, resolve } from 'node:path';
11
11
  import { installTimeoutMs, WEB_PROFILE, webProfileDir } from './env.js';
12
12
  import { createProgressTracker } from './progress.js';
13
13
  import { applyPreciseBuilds, readPendingBuilds } from './build-approval.js';
14
+ import { governExcludeBlock, parseNpmSpec, registerExclusion } from './exclude-governance.js';
15
+ import { listInstalledPlugins } from './installed.js';
16
+ const DUAL_CODE_RE = /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|ERR_PNPM_NO_MATURE_MATCHING_VERSION/;
14
17
  /** pnpm patchedDependencies 条目键与目标包匹配:`pkg` 或 `pkg@任意版本/区间`。 */
15
18
  function matchesPatchedKey(key, pkg) {
16
19
  return key === pkg || key.startsWith(`${pkg}@`);
@@ -579,54 +582,118 @@ export function makeAddViaLadder(deps) {
579
582
  const resolveBuilds = deps.resolveBuilds ?? writePreciseBuilds;
580
583
  const opts = (signal) => (signal !== undefined ? { signal } : undefined);
581
584
  return async (source, profileDir, signal) => {
582
- const retryAfterPrepare = async () => {
583
- // Y2(终审复审):放行写入本身失败也必须转换为结果,维持「永不 throw」契约
584
- let decision;
585
+ // ADR-0009 挂点1:委派前治理(fail-open)——已落盘的编辑以短语进 output(成功/失败都带)
586
+ let governNote = '';
587
+ try {
588
+ const gov = await (deps.govern ?? governExcludeBlock)(profileDir);
589
+ if (gov.changed && gov.mergedNames?.length)
590
+ governNote = `;排除条目治理:合并 ${gov.mergedNames.join('、')}`;
591
+ }
592
+ catch {
593
+ // fail-open:治理失败不阻塞
594
+ }
595
+ // 登记原料:npm 精确 spec + 上一版本(本地读,失败 → undefined)
596
+ const spec = parseNpmSpec(source);
597
+ let previousVersion;
598
+ if (spec) {
585
599
  try {
586
- decision = resolveBuilds(profileDir);
600
+ previousVersion = deps.installedVersion
601
+ ? await deps.installedVersion(profileDir, spec.pkg)
602
+ : (await listInstalledPlugins(profileDir)).items.find((it) => it.pkg === spec.pkg)?.version;
587
603
  }
588
- catch (err) {
589
- const text = errText(err);
590
- return { class: 'hard-fail', output: truncateOutput(text) };
604
+ catch {
605
+ previousVersion = undefined;
591
606
  }
607
+ }
608
+ let registerNote = '';
609
+ const safeRegister = async () => {
610
+ if (!spec)
611
+ return;
592
612
  try {
593
- const output = await run(WEB_PROFILE, ['add', source], opts(signal));
594
- return { class: 'ok', output: truncateOutput(output), buildApprovals: decision.approvals, fallbackAllBuilds: decision.fallbackAll };
613
+ const reg = await (deps.register ?? registerExclusion)(profileDir, { ...spec, previousVersion });
614
+ if (reg.applied && reg.form)
615
+ registerNote = `;排除条目已登记 ${spec.pkg}@${spec.version}(${reg.form})`;
595
616
  }
596
- catch (retryErr) {
597
- return { ...classifyPnpmError(errText(retryErr)), output: truncateOutput(errText(retryErr)) };
617
+ catch {
618
+ // fail-open:登记失败不影响成功态
598
619
  }
599
620
  };
600
- try {
601
- const output = await run(WEB_PROFILE, ['add', source], opts(signal));
602
- return { class: 'ok', output: truncateOutput(output), buildApprovals: [], fallbackAllBuilds: false };
603
- }
604
- catch (err) {
605
- const text = errText(err);
606
- if (text.includes(PNPM_OUTCOME_CODES.PUBLIC_HOIST_PATTERN_DIFF)) {
621
+ const okOutcome = async (output, buildApprovals = [], fallbackAllBuilds = false) => {
622
+ await safeRegister();
623
+ return { class: 'ok', output: truncateOutput(output + governNote + governRetryNote + registerNote), buildApprovals, fallbackAllBuilds };
624
+ };
625
+ const failOutcome = (text) => ({ ...classifyPnpmError(text), output: truncateOutput(text + governNote + governRetryNote) });
626
+ // ADR-0009 挂点3:阶梯内每次真实 pnpm 命令统一包装——双码失败 → 治理 → changed 则
627
+ // 重试该命令一次(布尔全局保证每次 ladder 调用至多一次;四个失败出口共用)。
628
+ // 挂点3 已落盘的合并以短语并入 output(留痕契约含挂点3,与 hook1 短语去重)。
629
+ let governedRetryUsed = false;
630
+ let governRetryNote = '';
631
+ const attemptAdd = async (args) => {
632
+ try {
633
+ return { ok: true, output: await run(WEB_PROFILE, args, opts(signal)) };
634
+ }
635
+ catch (err) {
636
+ const text = errText(err);
637
+ if (governedRetryUsed || !DUAL_CODE_RE.test(text))
638
+ return { ok: false, text };
639
+ governedRetryUsed = true;
607
640
  try {
608
- await run(WEB_PROFILE, ['install', '--no-frozen-lockfile'], opts(signal));
641
+ const gov = await (deps.govern ?? governExcludeBlock)(profileDir);
642
+ if (!gov.changed)
643
+ return { ok: false, text };
644
+ if (gov.mergedNames?.length) {
645
+ const note = `;排除条目治理:合并 ${gov.mergedNames.join('、')}`;
646
+ if (!governNote.includes(note))
647
+ governRetryNote = note;
648
+ }
609
649
  }
610
- catch (rebuildErr) {
611
- const rebuildText = errText(rebuildErr);
612
- return { ...classifyPnpmError(rebuildText), output: truncateOutput(rebuildText) };
650
+ catch {
651
+ return { ok: false, text };
613
652
  }
614
653
  try {
615
- const output = await run(WEB_PROFILE, ['add', source], opts(signal));
616
- return { class: 'ok', output: truncateOutput(output), buildApprovals: [], fallbackAllBuilds: false };
654
+ return { ok: true, output: await run(WEB_PROFILE, args, opts(signal)) };
617
655
  }
618
656
  catch (retryErr) {
619
- const retryText = errText(retryErr);
620
- if (isPrepareBlocked(retryText))
621
- return retryAfterPrepare();
622
- return { ...classifyPnpmError(retryText), output: truncateOutput(retryText) };
657
+ return { ok: false, text: errText(retryErr) };
623
658
  }
624
659
  }
625
- if (!isPrepareBlocked(text)) {
626
- return { ...classifyPnpmError(text), output: truncateOutput(text) };
660
+ };
661
+ const retryAfterPrepare = async () => {
662
+ // Y2(终审复审):放行写入本身失败也必须转换为结果,维持「永不 throw」契约
663
+ let decision;
664
+ try {
665
+ decision = resolveBuilds(profileDir);
666
+ }
667
+ catch (err) {
668
+ const text = errText(err);
669
+ return { class: 'hard-fail', output: truncateOutput(text + governNote + governRetryNote) };
627
670
  }
628
- return retryAfterPrepare();
671
+ const retried = await attemptAdd(['add', source]);
672
+ if (retried.ok)
673
+ return okOutcome(retried.output, decision.approvals, decision.fallbackAll);
674
+ return { ...classifyPnpmError(retried.text), output: truncateOutput(retried.text + governNote + governRetryNote) };
675
+ };
676
+ const first = await attemptAdd(['add', source]);
677
+ if (first.ok)
678
+ return okOutcome(first.output);
679
+ const text = first.text;
680
+ if (text.includes(PNPM_OUTCOME_CODES.PUBLIC_HOIST_PATTERN_DIFF)) {
681
+ // 重建 install 自身也过锁文件校验:双码治理+重试同样适用(attemptAdd 包装)
682
+ const rebuild = await attemptAdd(['install', '--no-frozen-lockfile']);
683
+ if (!rebuild.ok)
684
+ return failOutcome(rebuild.text);
685
+ const second = await attemptAdd(['add', source]);
686
+ if (second.ok)
687
+ return okOutcome(second.output);
688
+ const retryText = second.text;
689
+ if (isPrepareBlocked(retryText))
690
+ return retryAfterPrepare();
691
+ return failOutcome(retryText);
692
+ }
693
+ if (!isPrepareBlocked(text)) {
694
+ return failOutcome(text);
629
695
  }
696
+ return retryAfterPrepare();
630
697
  };
631
698
  }
632
699
  function errText(err) {
@@ -0,0 +1,264 @@
1
+ /**
2
+ * 排除条目代管(ADR-0009):pnpm-workspace.yaml `minimumReleaseAgeExclude` 块的
3
+ * 纯文本变换(解析 / 治理合并 / 登记)与落盘封装(原子写 + 官方同款锁 + fail-open)。
4
+ *
5
+ * 规范形态:每包一条、版本并集复合(`name@a || b`);包名级保持裸名;scoped 名在
6
+ * YAML 中必须单引号包裹(`@` 不能作 plain scalar 开头)。治理只修不建;登记可为
7
+ * 无块的文件创建块;解析失败(含 `#` 注释行)一律弃写——pnpm 仍是最终执行者。
8
+ *
9
+ * 机理(首条规则生效):pnpm evaluateVersionPolicy 每包名只认第一条排除规则,
10
+ * 同名后续规则死亡(dshmarket #732 实证)——同名合并即消除死规则。
11
+ * 语义对照(只读参考):.dsh-research/dsh-market-clone/src/profile.ts:1310-1385(merge)。
12
+ */
13
+ import { readFile as readFileFs } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { writeFileAtomic, withFileLock } from '@deepseek-ai/dsh-atomic-write';
16
+ import { isExactVersion } from './versions.js';
17
+ import { cachedPackumentTimes, DEFAULT_MINIMUM_RELEASE_AGE_MIN, readWorkspacePolicy } from './release-age.js';
18
+ /** 块形态(`minimumReleaseAgeExclude:` + 块内 `- <条目>` 行);行内数组等其它形态不在此列。 */
19
+ const BLOCK_RE = /^minimumReleaseAgeExclude:[ \t]*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)*)/m;
20
+ /** 任意形态的键存在性(含行内);登记据此避免产生重复键。 */
21
+ const KEY_ANY_FORM_RE = /^[ \t]*minimumReleaseAgeExclude[ \t]*:/m;
22
+ const WS_FILE = 'pnpm-workspace.yaml';
23
+ function splitEntry(raw) {
24
+ let text = raw.trim();
25
+ if (text.length >= 2 && ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"')))) {
26
+ text = text.slice(1, -1);
27
+ }
28
+ const at = text.startsWith('@') ? text.indexOf('@', 1) : text.indexOf('@');
29
+ if (at === -1)
30
+ return text === '' ? null : { name: text, selectors: null };
31
+ const name = text.slice(0, at);
32
+ const selector = text.slice(at + 1).trim();
33
+ if (name === '' || selector === '')
34
+ return null;
35
+ return { name, selectors: selector.split('||').map((s) => s.trim()) };
36
+ }
37
+ /** 解析排除块:absent = 键缺失;unparseable = 块内含 `#` 或无法精确读取的条目(调用方弃写)。 */
38
+ export function parseExcludeBlock(yaml) {
39
+ const block = BLOCK_RE.exec(yaml);
40
+ if (block === null)
41
+ return { kind: 'absent' };
42
+ const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
43
+ const indentMatch = /^([ \t]+)-/.exec(block[1]);
44
+ const indent = indentMatch === null ? ' ' : indentMatch[1];
45
+ const entries = [];
46
+ const rawLines = [];
47
+ for (const line of block[1].split(/\r?\n/)) {
48
+ if (line.trim() === '')
49
+ continue;
50
+ const m = /^[ \t]+-[ \t]*(.*?)[ \t]*$/.exec(line);
51
+ if (m === null || m[1].includes('#'))
52
+ return { kind: 'unparseable' };
53
+ const split = splitEntry(m[1]);
54
+ if (split === null)
55
+ return { kind: 'unparseable' };
56
+ entries.push({ name: split.name, selectors: split.selectors, quoted: /^['"]/.test(m[1]), raw: line });
57
+ rawLines.push(line);
58
+ }
59
+ return { kind: 'ok', entries, rawLines, eol, indent };
60
+ }
61
+ /** 规范行渲染:单选择器 `name@v`、多选择器 `name@a || b`、包名级裸名;scoped 名加单引号。 */
62
+ function renderEntryLine(name, selectors, indent) {
63
+ const text = selectors === null || selectors.length === 0
64
+ ? name
65
+ : selectors.length === 1
66
+ ? `${name}@${selectors[0]}`
67
+ : `${name}@${selectors.join(' || ')}`;
68
+ return `${indent}- ${text.startsWith('@') ? `'${text.replaceAll("'", "''")}'` : text}`;
69
+ }
70
+ /**
71
+ * 治理:同名规则合并为规范形态(每包一条、版本并集、原首现顺序);只修不建;
72
+ * 块缺失 → changed=false;解析失败 → null(弃写)。changed 的判定逐名比较
73
+ * 「合并后渲染行 vs 原始行」,只有实际被改写的名字进入 mergedNames。
74
+ */
75
+ export function mergeExcludeRules(yaml) {
76
+ const parse = parseExcludeBlock(yaml);
77
+ if (parse.kind === 'absent')
78
+ return { yaml, changed: false, mergedNames: [], blockAbsent: true };
79
+ if (parse.kind === 'unparseable')
80
+ return null;
81
+ const { entries, rawLines, eol, indent } = parse;
82
+ const groups = new Map();
83
+ const order = [];
84
+ entries.forEach((e, i) => {
85
+ let g = groups.get(e.name);
86
+ if (g === undefined) {
87
+ g = { selectors: [], seen: new Set(), firstRaw: rawLines[i], count: 0 };
88
+ groups.set(e.name, g);
89
+ order.push(e.name);
90
+ }
91
+ g.count += 1;
92
+ if (e.selectors === null) {
93
+ // 包名级吞掉同名一切版本段
94
+ g.selectors = null;
95
+ g.seen.clear();
96
+ }
97
+ else if (g.selectors !== null) {
98
+ for (const s of e.selectors) {
99
+ if (!g.seen.has(s)) {
100
+ g.seen.add(s);
101
+ g.selectors.push(s);
102
+ }
103
+ }
104
+ }
105
+ });
106
+ const mergedNames = order.filter((name) => {
107
+ const g = groups.get(name);
108
+ if (g === undefined)
109
+ return false;
110
+ return g.count > 1 || renderEntryLine(name, g.selectors, indent) !== g.firstRaw;
111
+ });
112
+ if (mergedNames.length === 0)
113
+ return { yaml, changed: false, mergedNames: [], blockAbsent: false };
114
+ const lines = order.map((name) => {
115
+ const g = groups.get(name);
116
+ return renderEntryLine(name, g?.selectors ?? null, indent);
117
+ });
118
+ const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
119
+ return { yaml: yaml.replace(BLOCK_RE, () => blockText), changed: true, mergedNames, blockAbsent: false };
120
+ }
121
+ function newRuleLine(target, indent) {
122
+ if (target.pkg.startsWith('@'))
123
+ return { line: renderEntryLine(target.pkg, [target.version], indent), form: 'created-scoped-exact' };
124
+ if (target.previousVersion !== undefined && target.previousVersion !== target.version) {
125
+ return { line: renderEntryLine(target.pkg, [target.version, target.previousVersion], indent), form: 'created-composite' };
126
+ }
127
+ return { line: renderEntryLine(target.pkg, [target.version], indent), form: 'created-exact' };
128
+ }
129
+ /**
130
+ * 登记:把目标精确版本并入该包**首条**规则(首条规则生效口径);无既有规则时按
131
+ * scoped → 精确单条、非 scoped + 异版 previousVersion → 双选择器复合、其余 → 精确单条
132
+ * 新建;块缺失的文件在尾部创建块(键以行内等其它形式存在 → null,避免重复键)。
133
+ * 目标已被覆盖(包名级 / 选择器含该版本)→ noop。
134
+ */
135
+ export function registerExclusionInYaml(yaml, target) {
136
+ const parse = parseExcludeBlock(yaml);
137
+ if (parse.kind === 'unparseable')
138
+ return null;
139
+ if (parse.kind === 'absent') {
140
+ if (KEY_ANY_FORM_RE.test(yaml))
141
+ return null;
142
+ const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
143
+ const { line, form } = newRuleLine(target, ' ');
144
+ const base = yaml === '' || yaml.endsWith(eol) ? yaml : yaml + eol;
145
+ return { yaml: `${base}minimumReleaseAgeExclude:${eol}${line}${eol}`, changed: true, form };
146
+ }
147
+ const { entries, rawLines, eol, indent } = parse;
148
+ const lines = [...rawLines];
149
+ const idx = entries.findIndex((e) => e.name === target.pkg);
150
+ if (idx >= 0) {
151
+ const e = entries[idx];
152
+ if (e.selectors === null)
153
+ return { yaml, changed: false, form: 'noop' };
154
+ if (e.selectors.includes(target.version))
155
+ return { yaml, changed: false, form: 'noop' };
156
+ lines[idx] = renderEntryLine(target.pkg, [...e.selectors, target.version], indent);
157
+ const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
158
+ return { yaml: yaml.replace(BLOCK_RE, () => blockText), changed: true, form: 'merged' };
159
+ }
160
+ const { line, form } = newRuleLine(target, indent);
161
+ lines.push(line);
162
+ const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
163
+ return { yaml: yaml.replace(BLOCK_RE, () => blockText), changed: true, form };
164
+ }
165
+ export function parseNpmSpec(spec) {
166
+ let text = spec.trim();
167
+ if (text.length >= 2 && ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"')))) {
168
+ text = text.slice(1, -1);
169
+ }
170
+ if (text === '' || text.includes(':'))
171
+ return null;
172
+ const at = text.startsWith('@') ? text.indexOf('@', 1) : text.indexOf('@');
173
+ if (at <= 0)
174
+ return null;
175
+ const pkg = text.slice(0, at);
176
+ const version = text.slice(at + 1);
177
+ if (pkg === '' || version === '' || !isExactVersion(version))
178
+ return null;
179
+ return { pkg, version };
180
+ }
181
+ /**
182
+ * 委派前治理:读-改-写全程持官方同款锁(`withFileLock(<profile>/package.json)`,
183
+ * toggle 同先例),changed 才原子写。fail-open:任何失败返回结构化 reason,不抛——
184
+ * pnpm 是最终执行者。锁只串行持锁写者,不防不持锁的 pnpm 追加(残余风险见 ADR-0009)。
185
+ */
186
+ export async function governExcludeBlock(profileDir, deps = {}) {
187
+ if (!profileDir)
188
+ return { ok: true, changed: false, reason: 'no-dir' };
189
+ const doRead = deps.readFile ?? ((path) => readFileFs(path, 'utf8'));
190
+ const doWrite = deps.writeFile ??
191
+ (async (path, text) => {
192
+ await writeFileAtomic(path, text, { mode: 0o600 });
193
+ });
194
+ const file = join(profileDir, WS_FILE);
195
+ try {
196
+ return await withFileLock(join(profileDir, 'package.json'), async () => {
197
+ let yaml;
198
+ try {
199
+ yaml = await doRead(file);
200
+ }
201
+ catch {
202
+ return { ok: false, changed: false, reason: 'no-file' };
203
+ }
204
+ if (!KEY_ANY_FORM_RE.test(yaml))
205
+ return { ok: true, changed: false, reason: 'no-block' };
206
+ const merged = mergeExcludeRules(yaml);
207
+ if (merged === null)
208
+ return { ok: false, changed: false, reason: 'unparseable' };
209
+ if (!merged.changed)
210
+ return { ok: true, changed: false, reason: 'clean' };
211
+ await doWrite(file, merged.yaml);
212
+ return { ok: true, changed: true, mergedNames: merged.mergedNames };
213
+ });
214
+ }
215
+ catch {
216
+ return { ok: false, changed: false, reason: 'write-failed' };
217
+ }
218
+ }
219
+ /**
220
+ * 成功后登记:仅对发布时刻可判「窗口内」(显式 age 值优先,缺省 1440 分钟)的目标
221
+ * 触发;时刻不可得一律不登记(不发明)。本地检查(文件缺席 → no-file)先于 registry
222
+ * 探测——缺省绑定下无块文件零网络。与治理同锁同 fail-open 纪律。
223
+ */
224
+ export async function registerExclusion(profileDir, target, deps = {}) {
225
+ if (!profileDir)
226
+ return { applied: false, reason: 'no-dir' };
227
+ const doRead = deps.readFile ?? ((path) => readFileFs(path, 'utf8'));
228
+ const doWrite = deps.writeFile ??
229
+ (async (path, text) => {
230
+ await writeFileAtomic(path, text, { mode: 0o600 });
231
+ });
232
+ const file = join(profileDir, WS_FILE);
233
+ try {
234
+ return await withFileLock(join(profileDir, 'package.json'), async () => {
235
+ // 本地检查先行:文件缺席直接 no-file(不发 registry 探测——缺省绑定下测试离线可跑)
236
+ let yaml;
237
+ try {
238
+ yaml = await doRead(file);
239
+ }
240
+ catch {
241
+ return { applied: false, reason: 'no-file' };
242
+ }
243
+ const times = await (deps.packumentTimes ?? cachedPackumentTimes)(target.pkg, 12_000);
244
+ const now = deps.nowMs ?? Date.now();
245
+ const publishedAt = times ? Date.parse(times[target.version] ?? '') : NaN;
246
+ if (!Number.isFinite(publishedAt))
247
+ return { applied: false, reason: 'not-young' };
248
+ const policy = await readWorkspacePolicy(profileDir);
249
+ const windowMin = policy?.minimumReleaseAgeMin ?? DEFAULT_MINIMUM_RELEASE_AGE_MIN;
250
+ if (now >= publishedAt + windowMin * 60_000)
251
+ return { applied: false, reason: 'not-young' };
252
+ const next = registerExclusionInYaml(yaml, target);
253
+ if (next === null)
254
+ return { applied: false, reason: 'unparseable' };
255
+ if (!next.changed)
256
+ return { applied: false, reason: 'up-to-date', form: next.form };
257
+ await doWrite(file, next.yaml);
258
+ return { applied: true, form: next.form };
259
+ });
260
+ }
261
+ catch {
262
+ return { applied: false, reason: 'write-failed' };
263
+ }
264
+ }
@@ -385,15 +385,43 @@ export function createApiDispatcher(ctx) {
385
385
  // 0.9.5 双 profile 读路径接线(实机回归 2026-10-01):desktop 下已装列表此前
386
386
  // 漏传 profile → 落回 webProfileDir,已装页恒显「web profile 尚未安装任何插件」。
387
387
  // 与 tools.ts dshm_list 同款接线(profile.dir 单一事实源)。
388
+ // 两段加载(ADR-0008):body.probe === false → 跳过探测段(面板第一段快列表);
389
+ // 缺省 'full'——工具/CLI/旧客户端行为不变。
388
390
  const result = await d.listInstalledWithMeta(cfg(), {
389
391
  namespace: 'host',
390
392
  profileDir: profile.dir,
391
393
  profile: profile.name,
394
+ probeMode: body.probe === false ? 'none' : 'full',
392
395
  signal,
393
396
  });
394
397
  payload = { ...result };
395
398
  break;
396
399
  }
400
+ case 'installedUpdates': {
401
+ // 两段加载(ADR-0008)第二段:probeMode 'only' → ttlMin=0 永远新鲜(先删共享缓存条目再重探)。
402
+ // 响应只保留更新字段且含全部已装项(latestError 项也在——client 的「检查未完成」依赖它),
403
+ // 不携带 items/others 全量负载。
404
+ await ctx.controller.ensureReady();
405
+ const result = await d.listInstalledWithMeta(cfg(), {
406
+ namespace: 'host',
407
+ profileDir: profile.dir,
408
+ profile: profile.name,
409
+ probeMode: 'only',
410
+ signal,
411
+ });
412
+ payload = {
413
+ updates: result.items.map((it) => ({
414
+ pkg: it.pkg,
415
+ latestVersion: it.latestVersion ?? null,
416
+ latestTag: it.latestTag ?? null,
417
+ latestSha: it.latestSha ?? null,
418
+ outdated: it.outdated,
419
+ latestError: it.latestError ?? null,
420
+ latestErrorCode: it.latestErrorCode ?? null,
421
+ })),
422
+ };
423
+ break;
424
+ }
397
425
  case 'readme': {
398
426
  const target = strArg(body, 'pkg');
399
427
  if (!target)
@@ -637,9 +637,14 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
637
637
  }
638
638
  const registryState = stateOf(loaded);
639
639
  matchInstalled(items, community.merged);
640
- // 0.9.20:latest 缓存纯内存(ADR-0006)——探测段前仅一次性清扫 0.9.14 遗留磁盘信封
641
- await ensureLatestCacheSwept({ namespace, profile: opts.profile });
642
- await probeLatest(items, { merged: community.merged, registryAddress: loaded.configuredAddress, ttlMin: Math.max(0, cfg.cacheTtlMin ?? 60) }, d, { namespace, signal, githubBudget, remaining, timeoutMs: cfg.timeoutMs ?? 20_000 });
640
+ // 两段加载(ADR-0008):probeMode 控制探测段——缺省 'full' 行为不变;'none' 面板快列表跳过探测;
641
+ // 'only' 面板第二段 ttlMin=0 永远新鲜(readLatestCache(key,0) 先删共享条目再重探:host ns 下浏览页/
642
+ // 工具的 TTL 命中被刷新为更新值,预算消耗速率上升、上限不变,超限走 latestError 降级)。
643
+ if ((opts.probeMode ?? 'full') !== 'none') {
644
+ // 0.9.20:latest 缓存纯内存(ADR-0006)——探测段前仅一次性清扫 0.9.14 遗留磁盘信封
645
+ await ensureLatestCacheSwept({ namespace, profile: opts.profile });
646
+ await probeLatest(items, { merged: community.merged, registryAddress: loaded.configuredAddress, ttlMin: opts.probeMode === 'only' ? 0 : Math.max(0, cfg.cacheTtlMin ?? 60) }, d, { namespace, signal, githubBudget, remaining, timeoutMs: cfg.timeoutMs ?? 20_000 });
647
+ }
643
648
  return { items, others: installed.others, profileDir: installed.profileDir, registryState, community: community.summary };
644
649
  }
645
650
  /** matching(主+社区合并条目):命中主条目或社区条目都写 registryId;社区命中再标 community。 */
@@ -31,6 +31,7 @@ import { npmLatest as defaultNpmLatest, githubLatestTag as defaultGithubLatestTa
31
31
  import { classifyUpgradeActivation } from './activation.js';
32
32
  import { togglePlugin, ToggleError } from './toggle.js';
33
33
  import { describeReleaseAgeFailure, releaseAgePrecheck } from './release-age.js';
34
+ import { governExcludeBlock, registerExclusion } from './exclude-governance.js';
34
35
  // 0.9.19:等待期失败翻译移入 release-age.ts(describeReleaseAgeFailure)——全量违规解析 +
35
36
  // 目标/旁包分述。0.9.10 版只取第一条违规并把旁包名字当成本次目标、给「改用 DSH Web」的
36
37
  // 失配指引(本机 quota-watch 0.1.15 事件实证:被拦的是旁包 dsh-m@0.9.18,文案却让用户等
@@ -121,9 +122,23 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
121
122
  else {
122
123
  throw new DesktopOpsError('install-refused', `收录条目 ${entry.id} 缺少 npm/github 来源,无法在 desktop 安装`);
123
124
  }
124
- // 0.9.19:供应链等待期预检——pnpm 11 锁文件校验会拦死整次安装,且失败前目标已写入
125
- // node_modules(账实分裂);目标太新或锁内「独立精确排除条目」未满期 → 委派前结构化拒绝。
126
- if (entry.source === 'npm' && version !== undefined) {
125
+ // ADR-0009:委派前治理(排除块规范形态,fail-open)+ 供应链等待期预检收敛——默认策略
126
+ // 放行并附陈述(官方管理器对显式点名的新版本直接安装并自动登记);显式设置 age 或
127
+ // strict 开启才拒绝。治理已落盘的编辑在成功 output 与失败 message 双路留痕。
128
+ let governanceNote = '';
129
+ let releaseAgeNotice = '';
130
+ let governedMergedNames;
131
+ if (entry.source === 'npm' && version !== undefined && opts.profileDir) {
132
+ try {
133
+ const gov = await (deps.governExclude ?? governExcludeBlock)(opts.profileDir);
134
+ if (gov.changed && gov.mergedNames?.length) {
135
+ governanceNote = `;排除条目治理:合并 ${gov.mergedNames.join('、')}`;
136
+ governedMergedNames = gov.mergedNames;
137
+ }
138
+ }
139
+ catch {
140
+ // fail-open:治理失败不阻塞委派(pnpm 仍是最终执行者)
141
+ }
127
142
  const gate = await releaseAgePrecheck({
128
143
  pkg,
129
144
  version,
@@ -132,20 +147,35 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
132
147
  signal: opts.signal,
133
148
  deps: { packumentTimes: deps.packumentTimes, workspacePolicy: deps.workspacePolicy },
134
149
  });
135
- if (gate.blocked)
136
- throw new DesktopOpsError('release-age-wait', gate.message, { blockers: gate.blockers });
150
+ if (gate.blocked) {
151
+ throw new DesktopOpsError('release-age-wait', gate.message + governanceNote, { blockers: gate.blockers, ...(governedMergedNames !== undefined ? { governanceMerged: governedMergedNames } : {}) });
152
+ }
153
+ if (gate.notice)
154
+ releaseAgeNotice = `;${gate.notice}`;
137
155
  }
138
156
  let change;
139
157
  let bundleName;
158
+ let registerNote;
159
+ let managed;
140
160
  try {
141
- ({ change, bundleName } = await runManagedInstall(service, { pkg, spec, version, profileDir: opts.profileDir, approvedBuilds: opts.approvedBuilds, listInstalled: deps.listInstalled }));
161
+ managed = await runManagedInstall(service, { pkg, spec, version, profileDir: opts.profileDir, approvedBuilds: opts.approvedBuilds, listInstalled: deps.listInstalled }, { governExclude: deps.governExclude, registerExclude: deps.registerExclude, packumentTimes: deps.packumentTimes });
162
+ change = managed.change;
163
+ bundleName = managed.bundleName;
164
+ registerNote = managed.registerNote;
142
165
  }
143
166
  catch (err) {
144
167
  if (err instanceof DesktopBuildApprovalNeeded) {
145
- return { ok: false, needsBuildApproval: true, id: entry.id, pkg, spec, pendingBuilds: err.pendingBuilds, message: err.message };
168
+ return { ok: false, needsBuildApproval: true, id: entry.id, pkg, spec, pendingBuilds: err.pendingBuilds, message: err.message + governanceNote };
169
+ }
170
+ if (err instanceof Error) {
171
+ if (governanceNote !== '' && !err.message.includes(governanceNote))
172
+ err.message += governanceNote;
173
+ if (managed?.governRetryNote !== undefined && !err.message.includes(managed.governRetryNote))
174
+ err.message += managed.governRetryNote;
146
175
  }
147
176
  throw err;
148
177
  }
178
+ const retryNote = managed.governRetryNote !== undefined && !governanceNote.includes(managed.governRetryNote) ? managed.governRetryNote : '';
149
179
  return {
150
180
  id: entry.id,
151
181
  pkg,
@@ -158,7 +188,7 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
158
188
  ...(compat !== null ? { compat } : {}),
159
189
  ...(compatSkipped ? { compatSkipped } : {}),
160
190
  needsRestart: true,
161
- output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}`,
191
+ output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}${governanceNote}${retryNote}${releaseAgeNotice}${registerNote ?? ''}`,
162
192
  via: 'desktop-manager',
163
193
  ...(change.application === 'overridden' ? { overridden: true } : {}),
164
194
  };
@@ -208,7 +238,17 @@ function enableStageFailureMessage(pkg, change) {
208
238
  }
209
239
  return `安装后启用阶段失败(${pkg};application=failed、stage=enable${change.error?.code ? `、error=${change.error.code}` : ''}):${writtenText},请在官方 Desktop 插件页查看状态或重试`;
210
240
  }
211
- async function runManagedInstall(service, ctx) {
241
+ const DUAL_CODE_RE = /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|ERR_PNPM_NO_MATURE_MATCHING_VERSION/;
242
+ /** 可治理的失败形态:cancelled / build-blocked 不治理不重试(与 mapFailure 分类一致)。 */
243
+ function isGovableFailure(change) {
244
+ if (change.application === 'cancelled')
245
+ return false;
246
+ const pending = Array.isArray(change.pendingBuilds) ? change.pendingBuilds.filter((n) => typeof n === 'string') : [];
247
+ if (change.packageResult?.kind === 'build-blocked' && pending.length > 0)
248
+ return false;
249
+ return change.application === 'failed' || change.error !== undefined;
250
+ }
251
+ async function runManagedInstall(service, ctx, deps = {}) {
212
252
  const installOnce = async () => changeOf(await service.installBundle(ctx.spec, {
213
253
  enabled: true,
214
254
  ...(ctx.approvedBuilds && ctx.approvedBuilds.length > 0 ? { approvedBuilds: ctx.approvedBuilds } : {}),
@@ -276,9 +316,46 @@ async function runManagedInstall(service, ctx) {
276
316
  await sleepMs(enableRetryDelayMs);
277
317
  change = await installOnce();
278
318
  }
279
- if (change.application === 'failed' || change.application === 'cancelled' || change.error)
280
- await mapFailure(change);
281
- return verifyAndBundle(change);
319
+ // ADR-0009 挂点3:双码失败 → 治理 → changed 则重试该命令一次(每次调用至多一次;
320
+ // cancelled / build-blocked 不治理不重试)。仍败走既有 mapFailure(含账实分裂复读);
321
+ // 挂点3 已落盘的合并以短语并入失败 message 与成功 output(留痕契约含挂点3)。
322
+ let governRetryNote;
323
+ if (isGovableFailure(change) && DUAL_CODE_RE.test(`${change.error?.diagnostic ?? ''}\n${change.error?.code ?? ''}`)) {
324
+ const gov = await (deps.governExclude ?? governExcludeBlock)(ctx.profileDir ?? '');
325
+ if (gov.changed) {
326
+ if (gov.mergedNames?.length)
327
+ governRetryNote = `;排除条目治理:合并 ${gov.mergedNames.join('、')}`;
328
+ change = await installOnce();
329
+ }
330
+ }
331
+ if (change.application === 'failed' || change.application === 'cancelled' || change.error) {
332
+ try {
333
+ await mapFailure(change);
334
+ }
335
+ catch (err) {
336
+ if (err instanceof Error && governRetryNote !== undefined && !err.message.includes(governRetryNote))
337
+ err.message += governRetryNote;
338
+ throw err;
339
+ }
340
+ }
341
+ const verified = await verifyAndBundle(change);
342
+ // ADR-0009 挂点2:成功后登记(仅 npm 源窗口内目标;fail-open,applied 短语由调用方并入 output)
343
+ let registerNote;
344
+ if (ctx.profileDir && ctx.version) {
345
+ try {
346
+ const reg = await (deps.registerExclude ?? registerExclusion)(ctx.profileDir, {
347
+ pkg: ctx.pkg,
348
+ version: ctx.version,
349
+ ...(ctx.previousVersion !== undefined ? { previousVersion: ctx.previousVersion } : {}),
350
+ }, deps.packumentTimes ? { packumentTimes: deps.packumentTimes } : {});
351
+ if (reg.applied && reg.form)
352
+ registerNote = `;排除条目已登记 ${ctx.pkg}@${ctx.version}(${reg.form})`;
353
+ }
354
+ catch {
355
+ // fail-open:登记失败不影响安装/升级成功态
356
+ }
357
+ }
358
+ return { ...verified, ...(registerNote !== undefined || governRetryNote !== undefined ? { registerNote, governRetryNote } : {}) };
282
359
  }
283
360
  /**
284
361
  * Desktop 卸载(0.9.8,dsh-market 同策略):removeBundle 委派 + ChangeResult 判定 +
@@ -383,8 +460,21 @@ async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
383
460
  else {
384
461
  throw new DesktopOpsError('install-refused', `收录条目 ${entry.id} 缺少 npm/github 来源,无法在 desktop 升级`);
385
462
  }
386
- // 0.9.19:供应链等待期预检(同 desktopInstallLocked;0:15 quota-watch 事件即此形态实证)
387
- if (entry.source === 'npm' && version !== undefined) {
463
+ // ADR-0009:委派前治理 + 预检收敛(同 desktopInstallLocked;0:15 quota-watch 事件为治理前的旧形态实证)
464
+ let governanceNote = '';
465
+ let releaseAgeNotice = '';
466
+ let governedMergedNames;
467
+ if (entry.source === 'npm' && version !== undefined && opts.profileDir) {
468
+ try {
469
+ const gov = await (deps.governExclude ?? governExcludeBlock)(opts.profileDir);
470
+ if (gov.changed && gov.mergedNames?.length) {
471
+ governanceNote = `;排除条目治理:合并 ${gov.mergedNames.join('、')}`;
472
+ governedMergedNames = gov.mergedNames;
473
+ }
474
+ }
475
+ catch {
476
+ // fail-open:治理失败不阻塞委派
477
+ }
388
478
  const gate = await releaseAgePrecheck({
389
479
  pkg,
390
480
  version,
@@ -393,20 +483,35 @@ async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
393
483
  signal: opts.signal,
394
484
  deps: { packumentTimes: deps.packumentTimes, workspacePolicy: deps.workspacePolicy },
395
485
  });
396
- if (gate.blocked)
397
- throw new DesktopOpsError('release-age-wait', gate.message, { blockers: gate.blockers });
486
+ if (gate.blocked) {
487
+ throw new DesktopOpsError('release-age-wait', gate.message + governanceNote, { blockers: gate.blockers, ...(governedMergedNames !== undefined ? { governanceMerged: governedMergedNames } : {}) });
488
+ }
489
+ if (gate.notice)
490
+ releaseAgeNotice = `;${gate.notice}`;
398
491
  }
399
492
  let change;
400
493
  let bundleName;
494
+ let registerNote;
495
+ let managed;
401
496
  try {
402
- ({ change, bundleName } = await runManagedInstall(service, { pkg, spec, version, profileDir: opts.profileDir, approvedBuilds: opts.approvedBuilds, listInstalled: deps.listInstalled }));
497
+ managed = await runManagedInstall(service, { pkg, spec, version, previousVersion: target.version, profileDir: opts.profileDir, approvedBuilds: opts.approvedBuilds, listInstalled: deps.listInstalled }, { governExclude: deps.governExclude, registerExclude: deps.registerExclude, packumentTimes: deps.packumentTimes });
498
+ change = managed.change;
499
+ bundleName = managed.bundleName;
500
+ registerNote = managed.registerNote;
403
501
  }
404
502
  catch (err) {
405
503
  if (err instanceof DesktopBuildApprovalNeeded) {
406
- return { ok: false, needsBuildApproval: true, id: entry.id, pkg, spec, pendingBuilds: err.pendingBuilds, message: err.message };
504
+ return { ok: false, needsBuildApproval: true, id: entry.id, pkg, spec, pendingBuilds: err.pendingBuilds, message: err.message + governanceNote };
505
+ }
506
+ if (err instanceof Error) {
507
+ if (governanceNote !== '' && !err.message.includes(governanceNote))
508
+ err.message += governanceNote;
509
+ if (managed?.governRetryNote !== undefined && !err.message.includes(managed.governRetryNote))
510
+ err.message += managed.governRetryNote;
407
511
  }
408
512
  throw err;
409
513
  }
514
+ const retryNote = managed.governRetryNote !== undefined && !governanceNote.includes(managed.governRetryNote) ? managed.governRetryNote : '';
410
515
  // 0.9.22 生效判定:npm 源升级在成功点分类;任何异常 fail-open 到 unknown,绝不影响升级成功态
411
516
  let activation;
412
517
  let needsRestart = true;
@@ -432,7 +537,7 @@ async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
432
537
  ...(compat !== null ? { compat } : {}),
433
538
  ...(compatSkipped ? { compatSkipped } : {}),
434
539
  needsRestart,
435
- output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}`,
540
+ output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}${governanceNote}${retryNote}${releaseAgeNotice}${registerNote ?? ''}`,
436
541
  via: 'desktop-manager',
437
542
  ...(change.application === 'overridden' ? { overridden: true } : {}),
438
543
  fromVersion: target.version,
@@ -825,3 +825,14 @@ export async function loadDefaultRegistry(cfg = {}, opts = {}) {
825
825
  }
826
826
  return loadDefaultChain(cfg, opts, { namespace, profile, includeBundled: true });
827
827
  }
828
+ /**
829
+ * verified 污染检测(know-how 022):`verified` 只记 **DSH 运行时**装机实测版本;
830
+ * 历史上反复出现把**插件自身版本号**误填进 verified 的事故(实证:dsh-quota-watch
831
+ * 条目曾出现 "0.1.0"——那是 quota-watch 的插件版本,不是任何 DSH 运行时版本)。
832
+ * 把条目 verified 与该插件自身 npm 已发布版本求交集,非空即高度可疑(warn 不 fail:
833
+ * 极小概率某代 DSH 与插件版本字面重合,人工复核后放行)。
834
+ */
835
+ export function verifiedPollution(verified, publishedVersions) {
836
+ const own = new Set(publishedVersions);
837
+ return (verified ?? []).filter((v) => own.has(v));
838
+ }
@@ -1,35 +1,34 @@
1
1
  /**
2
- * Desktop 供应链等待期(minimumReleaseAge)预检与失败翻译(0.9.19)。
2
+ * 供应链等待期(minimumReleaseAge)预检与失败翻译(0.9.19 立基,0.9.x 按治理语义收敛,ADR-0009)。
3
3
  *
4
- * pnpm 11.7 实机实证(2026-10-03,本机 desktop profile,官方管理器操作日志
5
- * operation-hNjgaN / operation-l5fXEd):
6
- * 1. 显式安装「太新」目标:非严格模式放行,pnpm 自动向 pnpm-workspace.yaml 的
7
- * `minimumReleaseAgeExclude` 追加一条「独立精确条目」(`pkg@ver` 形态);
8
- * 2. 但后续任何包操作的锁文件级供应链校验(`Verifying lockfile against
9
- * supply-chain policies (N entries)`)不认可 pnpm 自己追加的这种条目——
10
- * 任何一条未满期的 `pkg@ver` 都会拦死整个安装,且与本次目标无关(旁包能拦住
11
- * 所有升级,0.9.18 拦住 quota-watch 0.1.15 升级即实证);
4
+ * 实证基准(2026-10-03,本机 desktop profile,pnpm 11.7.0,官方管理器操作日志
5
+ * operation-zsc4ei / operation-L8oq1z / operation-l5fXEd):
6
+ * 1. 默认策略(未显式设置 age、未开 strict = 非严格)下,显式点名的「太新」目标
7
+ * 直接放行安装,pnpm 自动向 pnpm-workspace.yaml 的 `minimumReleaseAgeExclude`
8
+ * 追加 `pkg@ver` 条目("Added 1 entry …");
9
+ * 2. pnpm 的 evaluateVersionPolicy **每包名只认第一条排除规则**,同名后续规则死亡
10
+ * (dshmarket #732 实证,首条规则生效机理)——死规则会让等待期内全部包操作被
11
+ * 锁文件级校验拦死(0:15 quota-watch 连坐实录);
12
12
  * 3. 校验失败前目标包可能已写入 node_modules,官方管理器只回滚 manifest/lockfile
13
- * →「账实分裂」:界面实装新版 active,账本仍旧版,下次包操作静默回退。
13
+ * →「账实分裂」,下次包操作静默回退。
14
14
  *
15
- * 对策(零文件级红线不破:只读 profile 文件,绝不写):
16
- * ① 委派前预检(releaseAgePrecheck):目标版本或锁内「不被校验认可的独立精确
17
- * 排除条目」任一未满等待期 → 结构化拒绝,不委派官方管理器;
15
+ * 职责(ADR-0009):
16
+ * ① 委派前预检(releaseAgePrecheck):仅「显式设置 age 或 strict 开启」时对窗口内
17
+ * 目标拒绝(附可重试时刻);默认策略放行并返回陈述性 notice。锁内坏形态由
18
+ * exclude-governance 的治理挂点处理,本模块不再预拒绝(0.9.19 ②腿退役)。
18
19
  * ② 失败翻译(describeReleaseAgeFailure):解析全部违规条目,按「本次目标 vs
19
- * 锁内旁包」分别给出发布时刻与可重试时刻——不再把旁包名字冒充成本次目标被拦。
20
+ * 旁包」分述发布时刻与可重试时刻。
21
+ * ③ cachedPackumentTimes:预检与登记共用的带缓存 packument 探测。
20
22
  *
21
- * fail-open 纪律:发布时刻不可得 / 策略文件不可读 / profileDir 缺席 / 命中有效排除
22
- * 条目(包名级、`||` 复合、scoped 独立精确)→ 一律放行;pnpm 仍是最终执行者。
23
+ * fail-open 纪律:发布时刻不可得 / 策略文件不可读 / profileDir 缺席 / 命中任一
24
+ * 排除规则(首条规则口径)→ 一律放行;pnpm 仍是最终执行者。
23
25
  */
24
26
  import { readFile } from 'node:fs/promises';
25
27
  import { join } from 'node:path';
26
28
  import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
27
29
  import { fetchJsonLimited } from './httpx.js';
28
- import { isExactVersion } from './versions.js';
29
30
  /** pnpm 11.7 实机测得的策略窗口:24h(operation-l5fXEd 的 cutoff = 检查时刻 − 1440min)。 */
30
31
  export const DEFAULT_MINIMUM_RELEASE_AGE_MIN = 1440;
31
- /** 锁内「独立精确排除条目」旁包探测上限(防清单失控;超出部分放弃预判,交 pnpm 兜底)。 */
32
- export const MAX_EXCLUDE_PROBES = 8;
33
32
  function scalarNumber(value) {
34
33
  if (typeof value === 'number' && Number.isFinite(value))
35
34
  return value;
@@ -43,7 +42,7 @@ function scalarNumber(value) {
43
42
  * 调用方按默认窗口 + 无排除处理,绝不抛。
44
43
  */
45
44
  export function parseWorkspacePolicy(text) {
46
- const empty = { minimumReleaseAgeMin: null, excludes: [] };
45
+ const empty = { explicitAge: false, minimumReleaseAgeMin: null, strict: false, excludes: [] };
47
46
  let document;
48
47
  try {
49
48
  document = parseDocument(text);
@@ -62,10 +61,17 @@ export function parseWorkspacePolicy(text) {
62
61
  }
63
62
  }
64
63
  }
65
- // yaml 库的 Map.get() 对标量返回已解包的原始值(number/string),对集合返回节点——两种形态都兜住
64
+ // yaml 库的 Map.get() 对标量返回已解包的原始值(number/string/boolean),对集合返回节点——两种形态都兜住
66
65
  const rawAge = document.contents.get('minimumReleaseAge');
67
66
  const age = isScalar(rawAge) ? scalarNumber(rawAge.value) : scalarNumber(rawAge);
68
- return { minimumReleaseAgeMin: age !== null && age > 0 ? age : null, excludes };
67
+ const rawStrict = document.contents.get('minimumReleaseAgeStrict');
68
+ const strict = rawStrict === true || (isScalar(rawStrict) && rawStrict.value === true);
69
+ return {
70
+ explicitAge: rawAge !== undefined && rawAge !== null,
71
+ minimumReleaseAgeMin: age !== null && age > 0 ? age : null,
72
+ strict,
73
+ excludes,
74
+ };
69
75
  }
70
76
  export function excludeMatch(selector, pkg, version) {
71
77
  const s = selector.trim();
@@ -73,7 +79,7 @@ export function excludeMatch(selector, pkg, version) {
73
79
  return 'no';
74
80
  if (s.includes('||')) {
75
81
  for (const part of s.split('||')) {
76
- if (excludeMatch(part.trim(), pkg, version) !== 'no')
82
+ if (excludeMatch(part.trim(), pkg, version) === 'effective')
77
83
  return 'effective';
78
84
  }
79
85
  return 'no';
@@ -83,30 +89,7 @@ export function excludeMatch(selector, pkg, version) {
83
89
  // 包名级(dshmarket)或裸版本号段(只应出现在复合条目内,顶层出现视为包名比较)
84
90
  return s === pkg || s === version ? 'effective' : 'no';
85
91
  }
86
- const namePart = s.slice(0, at);
87
- const verPart = s.slice(at + 1);
88
- if (namePart !== pkg)
89
- return 'no';
90
- if (verPart === version) {
91
- return pkg.startsWith('@') ? 'effective' : 'unreliable-unscoped-exact';
92
- }
93
- return 'no';
94
- }
95
- /** 从独立精确条目提取 (pkg, version);复合/包名级/版本段非精确 semver → null。 */
96
- export function splitExactSelector(selector) {
97
- const s = selector.trim();
98
- if (s === '' || s.includes('||'))
99
- return null;
100
- const at = s.lastIndexOf('@');
101
- if (at <= 0)
102
- return null;
103
- const pkg = s.slice(0, at);
104
- const version = s.slice(at + 1);
105
- if (!isExactVersion(version))
106
- return null;
107
- if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
108
- return null;
109
- return { pkg, version, reliable: pkg.startsWith('@') };
92
+ return s.slice(0, at) === pkg && s.slice(at + 1) === version ? 'effective' : 'no';
110
93
  }
111
94
  const VIOLATION_RE = /(\S+@\S+) was published at ([^,]+), within the minimumReleaseAge cutoff \(([^)]+)\)/g;
112
95
  /** 解析 pnpm 诊断里的全部等待期违规条目(0.9.10 只取第一条是「报错报错包」的根因之一)。 */
@@ -196,72 +179,65 @@ export async function readWorkspacePolicy(profileDir) {
196
179
  }
197
180
  const packumentCache = new Map();
198
181
  const PACKUMENT_CACHE_TTL_MS = 5 * 60_000;
182
+ /**
183
+ * 带进程内缓存的 packument time 探测(TTL 5min):预检与登记(exclude-governance)
184
+ * 共用——同一操作里预检刚拉过的 packument 不二次拉取(8MiB 级全量)。
185
+ */
186
+ export async function cachedPackumentTimes(pkg, timeoutMs = 12_000, signal) {
187
+ const hit = packumentCache.get(pkg);
188
+ if (hit && Date.now() - hit.at < PACKUMENT_CACHE_TTL_MS)
189
+ return hit.times;
190
+ const times = await npmPackumentTimes(pkg, timeoutMs, signal);
191
+ packumentCache.set(pkg, { at: Date.now(), times });
192
+ return times;
193
+ }
199
194
  /** 测试钩子:清空 packument 进程内缓存。 */
200
195
  export function _resetReleaseAgeCachesForTests() {
201
196
  packumentCache.clear();
202
197
  }
203
198
  /**
204
- * 委派前预检:目标版本与锁内「不被校验认可的独立精确排除条目」逐个对发布时刻,
205
- * 任一未满等待期 → 结构化拒绝(blocked + 多行理由)。零写操作;不确定一律放行。
199
+ * 委派前预检(ADR-0009 收敛语义):仅当策略显式设置 age 或开启 strict 时,对窗口内
200
+ * 且未被任一排除规则覆盖(首条规则口径)的目标结构化拒绝(附可重试时刻);默认
201
+ * 策略(非严格)放行并返回陈述性 notice——官方管理器对显式点名的新版本会直接
202
+ * 安装并自动登记,锁内坏形态由 exclude-governance 的治理挂点处理。零写操作;
203
+ * fail-open:发布时刻不可得 / profileDir 缺席 / 命中排除规则 → 放行。
206
204
  */
207
205
  export async function releaseAgePrecheck(input) {
208
- // profileDir 缺席 = 读不到操作员排除条目 → 不预判(避免误拦操作员显式 pin 的安装)
206
+ // profileDir 缺席 = 读不到操作员排除条目与策略 → 不预判
209
207
  if (!input.profileDir)
210
208
  return { blocked: false };
211
209
  const now = input.nowMs ?? Date.now();
212
210
  const policy = input.deps?.workspacePolicy
213
211
  ? await input.deps.workspacePolicy(input.profileDir)
214
212
  : await readWorkspacePolicy(input.profileDir);
215
- const policyMin = policy?.minimumReleaseAgeMin ?? DEFAULT_MINIMUM_RELEASE_AGE_MIN;
213
+ const explicit = policy?.explicitAge ?? false;
214
+ const strict = policy?.strict ?? false;
215
+ const policyMin = explicit && policy?.minimumReleaseAgeMin != null ? policy.minimumReleaseAgeMin : DEFAULT_MINIMUM_RELEASE_AGE_MIN;
216
216
  const probe = async (pkg) => {
217
- const hit = packumentCache.get(pkg);
218
- if (hit && Date.now() - hit.at < PACKUMENT_CACHE_TTL_MS)
219
- return hit.times;
220
- const times = await (input.deps?.packumentTimes ?? npmPackumentTimes)(pkg, Math.min(input.timeoutMs ?? 20_000, 12_000), input.signal);
221
- packumentCache.set(pkg, { at: Date.now(), times });
222
- return times;
217
+ if (input.deps?.packumentTimes)
218
+ return input.deps.packumentTimes(pkg, Math.min(input.timeoutMs ?? 20_000, 12_000), input.signal);
219
+ return cachedPackumentTimes(pkg, 12_000, input.signal);
223
220
  };
224
- const blockers = [];
225
- // ① 本次目标版本的发布时刻(有效排除条目覆盖 → 放行)
226
221
  const times = await probe(input.pkg);
227
222
  const publishedAt = times ? Date.parse(times[input.version] ?? '') : NaN;
228
- if (Number.isFinite(publishedAt)) {
229
- const exempt = (policy?.excludes ?? []).some((sel) => excludeMatch(sel, input.pkg, input.version) === 'effective');
230
- if (!exempt) {
231
- const deadline = publishedAt + policyMin * 60_000;
232
- if (now < deadline) {
233
- blockers.push({ entry: `${input.pkg}@${input.version}`, role: 'target', publishedAt, deadline });
234
- }
235
- }
236
- }
237
- // ② 锁内「不被校验认可的独立精确排除条目」(非 scoped)——任何一条未满期都会拦死整次安装
238
- const seen = new Set([`${input.pkg}@${input.version}`]);
239
- const candidates = (policy?.excludes ?? [])
240
- .map((sel) => splitExactSelector(sel))
241
- .filter((x) => x !== null && !x.reliable);
242
- for (const ex of candidates.slice(0, MAX_EXCLUDE_PROBES)) {
243
- const entry = `${ex.pkg}@${ex.version}`;
244
- if (seen.has(entry))
245
- continue;
246
- seen.add(entry);
247
- const t2 = await probe(ex.pkg);
248
- const pub = t2 ? Date.parse(t2[ex.version] ?? '') : NaN;
249
- if (!Number.isFinite(pub))
250
- continue;
251
- const deadline = pub + policyMin * 60_000;
252
- if (now < deadline) {
253
- blockers.push({ entry, role: 'lockfile-exclude', publishedAt: pub, deadline });
254
- }
255
- }
256
- if (blockers.length === 0)
223
+ if (!Number.isFinite(publishedAt))
257
224
  return { blocked: false };
258
- const lines = ['官方管理器的供应链策略(minimumReleaseAge)将拦截本次安装,已提前拒绝(未触碰 profile 文件):'];
259
- for (const b of blockers) {
260
- lines.push(b.role === 'target'
261
- ? `· 目标 ${b.entry}:发布于 ${fmtLocal(b.publishedAt)},预计 ${fmtLocal(b.deadline)} 后满等待期`
262
- : `· 锁内排除条目 ${b.entry}:发布于 ${fmtLocal(b.publishedAt)},预计 ${fmtLocal(b.deadline)} 后满等待期(pnpm 自动追加的独立精确条目不被锁文件校验认可,等待期内任何官方包操作都会被拦)`);
225
+ const exempt = (policy?.excludes ?? []).some((sel) => excludeMatch(sel, input.pkg, input.version) === 'effective');
226
+ const deadline = publishedAt + policyMin * 60_000;
227
+ if (exempt || now >= deadline)
228
+ return { blocked: false };
229
+ if (!explicit && !strict) {
230
+ return {
231
+ blocked: false,
232
+ notice: `目标 ${input.pkg}@${input.version} 发布于 ${fmtLocal(publishedAt)}(等待期内):官方管理器将按显式点名安装,装好后 dsh-m 会登记豁免条目`,
233
+ };
263
234
  }
264
- const maxDeadline = Math.max(...blockers.map((b) => b.deadline));
265
- lines.push(`预计 ${fmtLocal(maxDeadline)} 后可重试;等待期内也可由操作员修正 pnpm-workspace.yaml 的排除条目形态(包名级/复合)解除拦截`);
266
- return { blocked: true, message: lines.join('\n'), blockers };
235
+ const lines = ['官方管理器的供应链策略(minimumReleaseAge)将拦截本次安装,已提前拒绝(未触碰 profile 文件):'];
236
+ lines.push(`· 目标 ${input.pkg}@${input.version}:发布于 ${fmtLocal(publishedAt)},预计 ${fmtLocal(deadline)} 后满等待期`);
237
+ lines.push(`预计 ${fmtLocal(deadline)} 后可重试;等待期内也可由操作员修正 pnpm-workspace.yaml 的排除条目形态(包名级/复合)解除拦截`);
238
+ return {
239
+ blocked: true,
240
+ message: lines.join('\n'),
241
+ blockers: [{ entry: `${input.pkg}@${input.version}`, role: 'target', publishedAt, deadline }],
242
+ };
267
243
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.9.22",
3
+ "version": "0.9.24",
4
4
  "description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/registry.json CHANGED
@@ -99,10 +99,10 @@
99
99
  {
100
100
  "id": "dsh-copilot-auth",
101
101
  "name": "DSH Copilot Auth",
102
- "description": "为内置 Copilot 提供方补登录/注销设置页与开箱路由;模型目录兜底不覆盖定制。适配 0.1.7-rc.2 与 0.2.0-rc.1,旧版用 1.1.x。",
102
+ "description": "内置 Copilot 登录注销页与开箱路由;目录兜底不覆盖定制,桌面版不支持目录刷新。已适配 0.1.7-rc.2 与 0.2.0-rc.1/rc.2。",
103
103
  "category": "self-dev",
104
104
  "tags": ["Copilot", "认证", "模型接入", "工具"],
105
- "verified": ["0.1.7-rc.2", "0.2.0-rc.1"],
105
+ "verified": ["0.1.7-rc.2", "0.2.0-rc.1", "0.2.0-rc.2"],
106
106
  "source": "npm",
107
107
  "npm": "@inventec/dsh-copilot-auth",
108
108
  "github": "iasiv5/dsh-copilot-auth",
@@ -114,7 +114,7 @@
114
114
  "description": "GLM 与 Copilot 额度悬浮球:胶囊、详情面板与侧栏卡片,宿主解析凭据,适配 web 与 desktop。",
115
115
  "category": "self-dev",
116
116
  "tags": ["配额", "悬浮球", "GLM", "Copilot"],
117
- "verified": ["0.1.7-rc.2", "0.1.0"],
117
+ "verified": ["0.1.7-rc.2", "0.2.0-rc.2"],
118
118
  "source": "npm",
119
119
  "npm": "@iasiv5/dsh-quota-watch",
120
120
  "github": "iasiv5/dsh-quota-watch",