@geoly-ai/skills-hub 0.3.3 → 0.3.5

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/README.md CHANGED
@@ -12,6 +12,21 @@
12
12
  |---|---|
13
13
  | registry 浏览站 | **https://skills-hub-pearl.vercel.app/** |
14
14
  | 埋点摄入端 | `https://skills-hub-telemetry.vercel.app/v1/events` |
15
+ | **分发(客户端真正取字节的地方)** | GitHub Releases —— 见下 |
16
+
17
+ 分发地址**不配置、不发现**,由客户端从**已验签**的对象推导
18
+ (locator 契约见 [`docs/m0/02-registry.md`](docs/m0/02-registry.md) §4.0):
19
+
20
+ ```
21
+ /releases/download/timestamp/timestamp.json ← 新鲜度锚点(滚动,每 3 天刷新)
22
+ /releases/download/hub-v<N>/hub-<N>.json[.sigstore.json]
23
+ /releases/download/hub-v<N>/<asset.file> ← 制品资产
24
+ ```
25
+
26
+ 🔴 **`hub-v<N>` 与 CLI 的 `v<x.y.z>` 是两个 Release,刻意分开。**
27
+ 合成一个的话,「快照号 N → 哪个 CLI 版本」这个映射就**不在任何签名对象里**,
28
+ 客户端拿着验过签的快照也推不出去哪儿下载 —— 那正是 0.2.0
29
+ 「已发布但没人能装」的根因。
15
30
 
16
31
  🔴 **不是 `skills-hub.vercel.app`** —— `.vercel.app` 子域名全局唯一,项目名撞车时
17
32
  Vercel 会自动追加一个随机词(这就是 `-pearl` 的来历)。那个裸域名**不属于本项目**,
@@ -24,17 +39,35 @@ Vercel 会自动追加一个随机词(这就是 `-pearl` 的来历)。那个
24
39
 
25
40
  ## 安装
26
41
 
42
+ 不用先装 CLI —— `npx` 直接跑:
43
+
27
44
  ```sh
28
- npm i -g @geoly-ai/skills-hub # 或 npx @geoly-ai/skills-hub <命令>
29
- skills-hub --help
45
+ # 装一个 skill
46
+ npx @geoly-ai/skills-hub install skill:geoly-ai/skills-hub-install --clients claude
47
+
48
+ # 装一整套矩阵(pack 是一个制品,成员一次装完)
49
+ npx @geoly-ai/skills-hub install pack:prompts-map/prompt-map --clients claude
50
+
51
+ # 装全部可装的(要 --yes-i-really-want-everything,--yes 不够)
52
+ npx @geoly-ai/skills-hub install --all --clients claude --yes-i-really-want-everything
30
53
  ```
31
54
 
32
- **已发布**:[`@geoly-ai/skills-hub@0.1.0`](https://www.npmjs.com/package/@geoly-ai/skills-hub)
33
- (46 个文件,带 npm provenance;发布 workflow 会用**本包自带的验签器 + 内置信任根**
55
+ 首次装到某个 client 时目录可能还不存在,加 `--create-missing claude`。
56
+ 装过一次之后 `--offline` 可用(资产按 sha256 内容寻址缓存在
57
+ `~/.cache/geoly-skills`)。
58
+
59
+ 想常驻就装全局:`npm i -g @geoly-ai/skills-hub`。
60
+
61
+ **已发布**:[`@geoly-ai/skills-hub@0.3.3`](https://www.npmjs.com/package/@geoly-ai/skills-hub)
62
+ (带 npm provenance;发布 workflow 会用**本包自带的验签器 + 内置信任根**
34
63
  自验一遍它自己签的 tarball)。
35
64
 
36
65
  平台:**macOS / Linux / WSL**,**Node ≥ 22.13**。
37
66
 
67
+ > ⚠️ **在企业代理后面需要 Node ≥ 24。** Node 的内建 fetch 直到 24 才支持
68
+ > `HTTPS_PROXY` / `NO_PROXY`(CLI 会自动启用它)。22.x 用户可以先在能直连的
69
+ > 网络里跑一次把缓存热起来,之后 `--offline` 可用。这是**已知缺口**。
70
+
38
71
  ### 当前能装到哪几端
39
72
 
40
73
  | client | 全局 | 项目级 | 说明 |
@@ -52,36 +85,65 @@ skills-hub --help
52
85
  | 阶段 | 状态 |
53
86
  |---|---|
54
87
  | **M0 · 制品与信任模型** | ✅ 已通过(v45,2026-08-25) |
55
- | **M1 · 只读分发** | ✅ **已完成并发布 0.1.0** —— resolve / install / recover / check / list-search-why / sync-lock |
56
- | **M2 · pack 与受控 catalog** | 🚧 进行中 —— 命令面已齐(`vendor` / `install pack:` / `install --all`);promotion 的**派生**那一半已就绪(`scripts/build-snapshot.mjs`),元数据来源待 M3 |
57
- | M3 · 投稿与审核 | — |
58
- | M4 · update / remove | — |
88
+ | **M1 · 只读分发** | ✅ 已完成(0.1.0 首发)—— resolve / install / recover / check / list-search-why / sync-lock |
89
+ | **M2 · pack 与受控 catalog** | ✅ 命令面与 promotion 的派生均已就绪;元数据来源待 M3 |
90
+ | **分发真的通了** | ✅ **0.3.3** —— registry 上线 23 个制品 / 3 张快照,单个 skill、整套 `pack:`、`--offline` 三条路径在干净环境实测通过 |
91
+ | M3 · 投稿与审核 | 🚧 元数据(`owner` / `review` / `provenance`)仍靠 promotion 的显式 `--inputs` |
92
+ | M4 · update / remove | ✅ 命令面已实现 —— 边界逐条列在下面「明确没做到的」里 |
59
93
 
60
- **930 个测试**在 Node 22.13.0 / 24.19.0 双版本全绿;穷举崩溃注入(真内核 51 个注入点
61
- 逐个反向命中)是 CI 的合并门。
94
+ > 📌 **「发布了」不等于「能装」。** 0.1.0 与 0.2.0 都能发布、能验签、能浏览 registry,
95
+ > 但**任何一次 `install` 都取不到字节**:客户端推不出下载地址、CLI 没有网络层、
96
+ > 服务端一个 Release 都没有。这三件事到 0.3.3 才全部闭合,
97
+ > 判据是**从 npm 装下来的那个包在干净 home 上真的装成了**,不是测试绿。
62
98
 
63
- ### 🔴 0.1.0 明确**没有**做到的
99
+ **1386 个测试**在 Node 22.13.0 / 24.19.0 双版本全绿;穷举崩溃注入(真内核 **72** 个注入点逐个反向命中,
100
+ 数目取自 `test/harness/fault-points.mjs` 的 CATALOG)是 CI 的合并门。
101
+
102
+ ### 🔴 现在明确**没有**做到的(截至 0.3.3)
64
103
 
65
104
  不写清楚就等于默认承诺了,所以逐条列出:
66
105
 
67
- - **制品(skill tar.gz)与快照的签发链不存在** —— packer 是 M2。
68
- 这一版签的是 **npm 包本身**(provenance + cosign 对 `.tgz` 的签名),不是 skill 制品。
69
- ⚠️ M2 进行中:`scripts/build-snapshot.mjs` 已能从 `artifacts/**` 确定性地**构建**
70
- 快照与资产(打包、摘要、pack 的 clients 交集 / capabilities 并集、`degraded` 重算、
71
- `latest` 投影),但**它不签名** —— 签名仍是 release workflow 的事,尚未接上。
72
- 且 record 必填的 `owner` / `review`(以及 pack 的 `provenance`,`pack.json` 里没有
73
- 这个字段)目前由显式的 `--inputs` 提供,M3 的投稿流水线接上后才自动产出。
74
- - **registry 是纯缓存,没有网络客户端** —— `resolveCurrent()` 是同步的,接不进 `fetch`。
75
- - **`--from-generation` 只做到编译计划**,接成正向事务的入口还没写。
106
+ - **投稿流水线还没接上**:record 必填的 `owner` / `review`(以及 pack 的
107
+ `provenance`)目前由 promotion 的显式 `--inputs` 提供,不是自动产出的。
108
+ - **Node 22.x 在代理后面装不了**(见上面的安装说明)。
109
+ - **`--from-generation` 只做到编译计划**,接成正向事务的入口还没写
110
+ (M4 的 `update` / `remove` **没有**顺手把它接上 —— 两条命令都只用现成的
111
+ `runTransaction`,不新增 journal op、不新增故障注入点)。
112
+ - **`remove` 只减「你自己那一条 direct 引用」**:`remove <name>` 减掉的是
113
+ `direct:<该 entry 的 artifact>` 那条边。一个**只被 pack / `all@snapshot` 请求**的
114
+ 成员因此删不掉(它的引用永远不归零)—— 规范只给了 `remove <name>` 这一种语法,
115
+ 没有「删掉整条 pack root」的入口,CLI **不自己发明一个**。
116
+ 出路是 `update pack:<name>`(新版本不再含它就会被退役)。
117
+ - **`update` 不接受 `--snapshot`**:钉快照能决定「解析到哪个版本」,
118
+ 但回答不了「现在还该不该用」(那必须查当前快照)。两者怎么组合规范没写。
119
+ - **`update` 的冲突没有 `--replace` 出路**:在一次升级里顺手删掉你先前装过的东西
120
+ 不是你表达过的意思。
121
+ - **项目级 lockfile 的重算仍不是原子的**(R-11 第四条)。M4 把两格提前到**动手之前**
122
+ 就失败(缓存里没有要用到的历史快照;任一在册项目 target 的引用图不闭合),
123
+ 但**没有做完整的 dry-run**(没有真的用 post-state 复算一次 `projectLockfile()`),
124
+ 磁盘在预热与收尾之间坏掉也仍会落回那个缺口 ——
125
+ 兜底照旧是 `check` 报「lockfile 过时」+ `sync-lock`。
126
+ 🔴 另外,**`--clients` 会同时收窄「投影哪些 target」与「预热哪些 target」**:
127
+ 两者内部一致(不会出现「预热漏了、重算却要」),但显式 `--clients` 时
128
+ 未点名的项目 target **不进 lockfile** —— 这是 `recalcLockfile()` 既有的性质
129
+ (`install` / `sync-lock` 同样如此),不是 M4 引入的,本轮也没有改它。
130
+ - **`plan.strictlyMatches()` 不查被验目录**自己**是不是 symlink**(它从
131
+ `readdirSync(dir)` 开始递归,查的是子项)。`update` / `remove` 在自己这一侧
132
+ 补了这道门(`refgraph.entryStillMatches` / `assertEntryTreeIntact`),
133
+ 但 `install` 的 §4.2 adopt 分支仍会走进去 —— **既有缺口,本轮未修**。
134
+ - **有 lockfile 时 `install` 仍不「只按 lockfile 装」**(04-install.md §8 的那一条):
135
+ lockfile 目前只被写出与被 `check` 比对,还没有当成 `install` 的权威输入。
136
+ - **`replaces` 与 `--freeze-attic` 在 `update` / `remove` 上同样没实现** ——
137
+ 它们在 `install` 上本来就没实现,M4 没有扩大范围。
76
138
  - **`--release-frozen` 如实拒绝**(没有按 label 解冻 attic 的导出),不提供假装成功的路径。
77
139
  - `cursor` 未验证;`search` 搜不了 description(快照 record 里没有这个字段)。
78
140
 
79
141
  已知且**明确接受**的残余风险见 [`docs/m1/01-residual-risks.md`](docs/m1/01-residual-risks.md)(R-1 … R-11)
80
- 与 [`docs/m2/01-residual-risks.md`](docs/m2/01-residual-risks.md)(R-12 … R-16),
142
+ 与 [`docs/m2/01-residual-risks.md`](docs/m2/01-residual-risks.md)(R-12 … R-21),
81
143
  M0 正文的勘误见 [`docs/m0/ERRATA.md`](docs/m0/ERRATA.md)(E-1 … E-8)。
82
144
 
83
- M2 交出了什么、**明确没做到什么**、以及三条待拍板项,见
84
- [`docs/m2/00-delivery.md`](docs/m2/00-delivery.md)。
145
+ M2 交出了什么、**明确没做到什么**,见
146
+ [`docs/m2/00-delivery.md`](docs/m2/00-delivery.md)(当时的三条待拍板项现已全部闭合)。
85
147
 
86
148
  ## 从哪读起
87
149
 
@@ -94,12 +156,26 @@ M2 交出了什么、**明确没做到什么**、以及三条待拍板项,见
94
156
 
95
157
  ## 已经能跑的
96
158
 
159
+ 装好之后(安装见上):
160
+
161
+ ```sh
162
+ skills-hub install <spec> --clients claude # 装
163
+ skills-hub update [<spec>…] | --all # 重解析 root:展示 diff、确认后应用
164
+ skills-hub remove <name> # 减引用;🔴 引用归零才删目录
165
+ skills-hub list --installed # 看装了什么
166
+ skills-hub check # 字节对不对 + 现在还该不该用
167
+ skills-hub why <name> # 这东西是谁请求装的
168
+ skills-hub recover # 装到一半崩了之后收拾现场
169
+ skills-hub stats # 本地埋点文本报表
170
+ skills-hub telemetry status # 埋点/上报开关
171
+ ```
172
+
173
+ 从源码开发:
174
+
97
175
  ```sh
98
176
  node bin/skills-hub.mjs --help
99
- node bin/skills-hub.mjs stats # 本地埋点文本报表
100
- node bin/skills-hub.mjs telemetry status # 埋点/上报开关
101
- npm test # 60 个测试
102
- npm run test:matrix # 在 Node 22.13 / 24.19 上各跑一遍
177
+ npm test # 1386 个测试
178
+ npm run test:matrix # 在 Node 22.13 / 24.19 上各跑一遍
103
179
  ```
104
180
 
105
181
  基础模块:`canonical-json`、`atomic-fs`、`safe-fs`、`tree-digest`/`tx-digest`、
@@ -21,11 +21,22 @@ process.emit = function (name, data, ...rest) {
21
21
  for (const k of Object.keys(process.env)) {
22
22
  if (k.startsWith('GEOLY_FAULT')) delete process.env[k];
23
23
  }
24
- // 🔴 认 HTTP_PROXY / HTTPS_PROXY / NO_PROXY —— 只能靠**启动前**就带上变量。
24
+ // 🔴 认 HTTP_PROXY / HTTPS_PROXY / NO_PROXY —— **只认这个,不去猜别的**。
25
25
  //
26
- // Node 的内建 fetch(undici)默认**不认**代理环境变量,而 npm / git / curl 都认。
27
- // 后果不是「慢一点」:在企业代理后面 `install` 报 `UND_ERR_CONNECT_TIMEOUT`,
26
+ // Node 的内建 fetch(undici)默认不认代理环境变量,而 npm / git / curl 都认。
27
+ // 后果不是「慢一点」:在代理后面 `install` 报 `UND_ERR_CONNECT_TIMEOUT`,
28
28
  // 而同一台机器上 `curl` 同一个地址是通的 —— 看起来像我们的 registry 挂了。
29
+ // 我们做的只是**把用户已经表达过的意图变成生效的**。
30
+ //
31
+ // 🔴 **不读系统代理,不替用户决定他的网络怎么走。**(2026-09-04 用户拍板。)
32
+ // 我一度加了「没有环境变量就去读 macOS 的 scutil --proxy」——
33
+ // 它确实能让「什么都不设也能装」,但那是**替用户选了一条他没选的路**:
34
+ // 设了环境变量 = 他说「这次走这儿」;系统设置只是「这台机器平时怎样」,
35
+ // 不等于他要让这个工具走那儿。
36
+ // ⚠️ 更实际的一层:静默把请求送进一个代理,是**改变了流量的去向**,
37
+ // 而用户没有要求过。要用代理就设变量,不要就不设 —— 他说了算。
38
+ // 连不上的时候**明确告诉他怎么设**(见 `src/download.mjs` 的错误提示),
39
+ // 这是帮忙;替他设上,是越界。
29
40
  //
30
41
  // ⚠️ **`process.env.NODE_USE_ENV_PROXY = '1'` 在进程内设置是无效的**(实测)。
31
42
  // Node 在**启动时**读它,之后再改不算数。
@@ -35,16 +46,27 @@ for (const k of Object.keys(process.env)) {
35
46
  // ② 进程内设置 → UND_ERR_CONNECT_TIMEOUT ← 无效
36
47
  // ③ 启动前外部设置 → 302
37
48
  // **在一个本来就会过的窗口里验证一道闸,等于没验。**
38
- //
39
- // 所以:检测到代理配置但变量没带上时,**带着变量把自己重启一次**。
40
- // 🔴 三个前提缺一不可,否则不重启:
41
- // · 用户没有显式表态(`NODE_USE_ENV_PROXY` 未设 —— 设成 '0' 也是表态)
42
- // · 环境里确实配了代理(没配就重启纯属白费一个进程)
43
- // · 不是已经重启过的那一次(防无限自我重启)
49
+ // 所以只能**带着变量把自己重启一次**。
50
+
51
+ // 🔴 **只有会出网的命令才值得为代理重启一次进程。**
52
+ // `list` / `why` / `stats` 这些是纯本地的 —— 给它们重启等于每次多起一个进程。
53
+ // 出网的只有两处:`install`(preheat + 收尾的自动上报)与 `telemetry flush`。
54
+ // ⚠️ 判据取**第一个非 flag 参数**,不是 `argv[2]` —— 全局 flag 可以写在命令前面。
55
+ // ⚠️ 这里故意**不做真正的参数解析**:解析是 `cli.mjs` 的事,
56
+ // 在两个地方各写一套 argv 语义,迟早会分叉。这里只要一个保守的近似:
57
+ // 多重启一次不算错,漏了才算 —— 所以拿不准(比如 `--help`)就当作要出网。
58
+ const firstArg = process.argv.slice(2).find((a) => !a.startsWith('-'));
59
+ const mayGoOnline = firstArg === undefined || firstArg === 'install' || firstArg === 'telemetry';
60
+
61
+ // 三个前提缺一不可,否则不重启:
62
+ // · 用户没有显式表态(`NODE_USE_ENV_PROXY` 已设 —— 哪怕设成 '0' —— 一律尊重)
63
+ // · 环境里确实配了代理(没配就重启纯属白费一个进程)
64
+ // · 不是已经重启过的那一次(防无限自我重启)
44
65
  if (
45
- process.env.NODE_USE_ENV_PROXY === undefined &&
46
- process.env.GEOLY_PROXY_REEXEC === undefined &&
47
- ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'].some((k) => process.env[k])
66
+ mayGoOnline
67
+ && process.env.NODE_USE_ENV_PROXY === undefined
68
+ && process.env.GEOLY_PROXY_REEXEC === undefined
69
+ && ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'].some((k) => process.env[k])
48
70
  ) {
49
71
  const { spawnSync } = await import('node:child_process');
50
72
  const r = spawnSync(process.execPath, [...process.execArgv, ...process.argv.slice(1)], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geoly-ai/skills-hub",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "geoly-ai 的 skill 分发中心 —— 安装、校验、审计",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -27,6 +27,8 @@ const HELP = `skills-hub —— geoly skill 分发(M1 + M2 的命令面)
27
27
  [--reset-generation N] [--resume-cleanup]
28
28
  vendor <pack-spec> --out <dir> [--layout flat]
29
29
  把 pack 与全部成员物化成一棵目录树(不走安装账本)
30
+ update [<spec>…] | --all 重解析账本里的 root:展示 diff、确认后在一个事务里应用
31
+ remove <name> 减引用;🔴 **引用归零才删目录**(why <name> 看谁在要它)
30
32
 
31
33
  stats [--json] [--export <file>] 本地埋点报表
32
34
  telemetry <status|flush> 埋点/上报开关与队列
@@ -71,6 +73,8 @@ const COMMANDS = {
71
73
  check: () => import('./commands/check.mjs').then((m) => m.cmdCheck),
72
74
  'sync-lock': () => import('./commands/sync-lock.mjs').then((m) => m.cmdSyncLock),
73
75
  recover: () => import('./commands/recover.mjs').then((m) => m.cmdRecover),
76
+ remove: () => import('./commands/remove.mjs').then((m) => m.cmdRemove),
77
+ update: () => import('./commands/update.mjs').then((m) => m.cmdUpdate),
74
78
  vendor: () => import('./commands/vendor.mjs').then((m) => m.cmdVendor),
75
79
  };
76
80
 
@@ -42,7 +42,7 @@ import { makeLockfileHook } from './sync-lock.mjs';
42
42
  *
43
43
  * 多个制品要**嵌套**:任意一层抛错,它自己和外层的隔离目录都会被清掉。
44
44
  */
45
- function withVerifiedArtifacts(items, parent, fn, acc = []) {
45
+ export function withVerifiedArtifacts(items, parent, fn, acc = []) {
46
46
  if (items.length === 0) return fn(acc);
47
47
  const [head, ...tail] = items;
48
48
  return withVerifiedArtifact({ bytes: head.bytes, record: head.record, parent }, (art) =>
@@ -0,0 +1,116 @@
1
+ // 项目级 lockfile 的**预热 + 钩子** —— M4(`update` / `remove`)专用的接线。
2
+ //
3
+ // 背景(R-11 第四条,sync-lock.mjs 里已经如实写着):`onLedgerChanged` 钩子是在
4
+ // `runCleanup()` 的**最后**才调的 —— 那时 tx 与 journal 都清掉了,账本已经提交。
5
+ // 钩子这时抛错的话,事务已经生效、项目 lockfile 却还是旧的,而 `recover` 已经
6
+ // 没有 journal 可重试。🔴 **这是已知缺口,不是被闭合了。**
7
+ //
8
+ // 钩子最容易抛的那一格是**可以提前挡掉的**:重算需要每个 entry 的 `asset_sha256`,
9
+ // 而账本里没有这个字段,只能回**历史快照**取(sync-lock.mjs 的 `assetSha256For`)。
10
+ // 缓存里少一张快照 → 取不到 → 抛。这一格与「事务本身」毫无关系,
11
+ // 完全可以在**还没动手**的时候就发现。
12
+ //
13
+ // 所以本模块做两件事:
14
+ // ① `prewarmLockfileInputs()`:在取锁与提交**之前**,把 post-state 会用到的
15
+ // 每一张历史快照都取回来、**逐份独立验签**、并确认目标 record 真的在里面;
16
+ // ② 返回一个共用**同一份 memo** 的 `onLedgerChanged` 钩子,于是收尾那一刻
17
+ // 不再需要任何新的 I/O 决策。
18
+ //
19
+ // ⚠️ **诚实边界**:这把「缓存里根本没有那张快照」搬到了免费的时刻,
20
+ // 但**不是**原子性修复。磁盘在两者之间坏掉、别的进程清了缓存,仍然会落回
21
+ // 那个已知缺口(兜底仍是 `check` 报「lockfile 过时」+ 用户跑 `sync-lock`)。
22
+
23
+ import { existsSync } from 'node:fs';
24
+ import { planTargets, assertPlanOk } from '../adapters/index.mjs';
25
+ import { layout, readLedger } from '../ledger.mjs';
26
+ import { historicalReader, isDegradable } from './snapshot-access.mjs';
27
+ import { recalcLockfile, assertNoUnrecoveredTx } from './sync-lock.mjs';
28
+ import { assertLedgerGraphUsable } from './refgraph.mjs';
29
+ import { NetworkError } from '../exit-codes.mjs';
30
+
31
+ /**
32
+ * @param {object} o
33
+ * @param {Function} o.verifier
34
+ * @param {object|null} [o.current] 当前快照(有就先在它里面找,省一次历史读取)
35
+ * @param {Array<{artifact:string, snapshot:number}>} [o.needs]
36
+ * 本次事务**写入之后**会存在的 entry。与「账本里现有的」取并集。
37
+ * @param {string[]} [o.ours]
38
+ * 本次命令**会自己处理(含 `recover(auto)` 续做)**的 target。
39
+ * 它们的未完成事务不在这里查 —— 那会把一次正常运行拒掉。
40
+ * @returns {Function|undefined} `onLedgerChanged` 钩子;非项目级时 `undefined`
41
+ */
42
+ export async function prewarmLockfileInputs(
43
+ ctx, { verifier, current = null, needs = [], ours = [], out } = {},
44
+ ) {
45
+ if (ctx.scope !== 'project') return undefined;
46
+ const readHistorical = historicalReader(ctx, verifier);
47
+
48
+ // ① 并集:本次要写入的 + **全部**项目级 target 账本里现有的。
49
+ // 🔴 后者不能省:`recalcLockfile()` 投影的是所有项目级 target,
50
+ // 不是只有我们这次动过的那几个 —— 只预热自己那份,别人那份照样会在收尾时炸。
51
+ const pairs = new Map();
52
+ for (const n of needs) pairs.set(`${n.artifact}\u0000${n.snapshot}`, n);
53
+ const tplan = planTargets({
54
+ clients: ctx.clients, scope: 'project', home: ctx.home, env: ctx.env, projectRoot: ctx.projectRoot,
55
+ });
56
+ assertPlanOk(tplan);
57
+ for (const t of tplan.selected) {
58
+ const P = layout(t.target);
59
+ if (!existsSync(P.ledger)) continue;
60
+ // 🔴 **本次不会去恢复的那些 target,未完成事务也要现在就查**
61
+ // (Codex 2026-09-04 复评 P1-1)。`recalcLockfile()` 对它们会调
62
+ // `assertNoUnrecoveredTx()`;而那发生在收尾钩子里 —— 那时**我们自己**这个
63
+ // target 已经提交、journal 已经清掉,lockfile 却停在旧版本。
64
+ // ⚠️ 只查「不是我们要处理的」那些:我们自己的 target 上,
65
+ // `cleanup_pending` 这类残留会被入口的 `recover(auto)` 正常续做完,
66
+ // 在这里查会把一次完全正常的运行拒掉。
67
+ if (!ours.includes(t.target)) assertNoUnrecoveredTx(t.target);
68
+ const other = readLedger(P.ledger);
69
+ // 🔴 **别的项目 target 的账本也要过闭合门**(Codex 2026-09-04 P1-2):
70
+ // `recalcLockfile()` 投影的是**全部**项目级 target。别人那份有一条悬挂
71
+ // `requested_by` 时,本次事务会照常提交、journal 照常清掉,然后收尾的
72
+ // 钩子才失败 —— lockfile 停在旧版本且没有 journal 可重试。
73
+ // ⚠️ **诚实边界**:这仍不是完整的 dry-run(没有真的构造一次 post-state
74
+ // 的 `projectLockfile()`),只覆盖「图不闭合」与「历史快照取不到」两格。
75
+ assertLedgerGraphUsable(other, `${t.target}/.geoly/ledger.json`);
76
+ for (const e of Object.values(other.entries ?? {})) {
77
+ pairs.set(`${e.artifact}\u0000${e.snapshot}`, { artifact: e.artifact, snapshot: e.snapshot });
78
+ }
79
+ }
80
+
81
+ // ② 逐条证明「那张快照取得回来、验得过签、里面确实有这个 record」。
82
+ // 🔴 **不 try/catch 吞掉**:取不到就是现在失败,而不是提交之后失败。
83
+ const missing = [];
84
+ for (const { artifact, snapshot } of pairs.values()) {
85
+ if (current?.artifacts.some((r) => r.id === artifact)) continue;
86
+ let snap;
87
+ try { snap = readHistorical(snapshot); } catch (e) {
88
+ // 🔴 **只有「取不到」(退出码 6)才算 missing**(Codex 2026-09-04 P1-3)。
89
+ // 一律 catch 成 `NetworkError` 会把**验签失败 / 摘要不符 / 快照解析失败**
90
+ // 从 2(完整性)降成 6(网络)—— 那正是 `snapshot-access.isDegradable`
91
+ // 那条注释在防的事:把三类不同的失败吞成同一句「网络不好」。
92
+ if (!isDegradable(e)) throw e;
93
+ missing.push(`${artifact}(快照 ${snapshot} 取不回来:${e.message.split('\n')[0]})`);
94
+ continue;
95
+ }
96
+ if (!snap.artifacts.some((r) => r.id === artifact)) {
97
+ missing.push(`${artifact}(快照 ${snapshot} 里没有这条 record)`);
98
+ }
99
+ }
100
+ if (missing.length) {
101
+ throw new NetworkError(
102
+ '项目级 lockfile 重算需要每个 entry 的 asset_sha256(账本里没有这个字段,只能回历史快照取),'
103
+ + '下面这些取不到:\n' + missing.map((m) => ` ${m}`).join('\n') + '\n'
104
+ + ' 🔴 现在拒绝,是为了不在**事务已经提交之后**才发现 —— 那时 lockfile 会停在旧版本,'
105
+ + '而 recover 已经没有 journal 可重试。\n'
106
+ + ` 出路:联网跑一次(去掉 --offline)把快照热进缓存,或先 \`skills-hub sync-lock\`。`,
107
+ { telemetryReason: 'not-found' },
108
+ );
109
+ }
110
+ out?.note?.(`项目级 lockfile:已预热 ${pairs.size} 条 entry 需要的历史快照`);
111
+
112
+ // ③ 钩子与预热**共用同一份 memo**:收尾那一刻不再有任何新的 I/O 决策。
113
+ return function onLedgerChanged(inFlightTarget = null) {
114
+ recalcLockfile(ctx, { current, inFlightTarget, readHistorical });
115
+ };
116
+ }