@geoly-ai/skills-hub 0.1.0

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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/bin/skills-hub.mjs +26 -0
  4. package/package.json +44 -0
  5. package/src/adapters/index.mjs +832 -0
  6. package/src/artifact.mjs +376 -0
  7. package/src/atomic-fs.mjs +166 -0
  8. package/src/attestation.mjs +136 -0
  9. package/src/canonical-json.mjs +147 -0
  10. package/src/cli.mjs +208 -0
  11. package/src/commands/check.mjs +295 -0
  12. package/src/commands/context.mjs +235 -0
  13. package/src/commands/install.mjs +430 -0
  14. package/src/commands/locks.mjs +197 -0
  15. package/src/commands/output.mjs +127 -0
  16. package/src/commands/query.mjs +266 -0
  17. package/src/commands/recover.mjs +438 -0
  18. package/src/commands/registry.mjs +123 -0
  19. package/src/commands/resolve.mjs +171 -0
  20. package/src/commands/snapshot-access.mjs +91 -0
  21. package/src/commands/sync-lock.mjs +189 -0
  22. package/src/crc32c.mjs +27 -0
  23. package/src/exit-codes.mjs +265 -0
  24. package/src/fault-inject.mjs +379 -0
  25. package/src/install.mjs +732 -0
  26. package/src/journal.mjs +435 -0
  27. package/src/ledger.mjs +671 -0
  28. package/src/lock.mjs +98 -0
  29. package/src/lockfile.mjs +0 -0
  30. package/src/pack.mjs +792 -0
  31. package/src/packer.mjs +351 -0
  32. package/src/plan.mjs +519 -0
  33. package/src/recover.mjs +1345 -0
  34. package/src/safe-fs.mjs +252 -0
  35. package/src/sigstore.mjs +480 -0
  36. package/src/snapshot.mjs +528 -0
  37. package/src/stats.mjs +59 -0
  38. package/src/target.mjs +738 -0
  39. package/src/telemetry.mjs +393 -0
  40. package/src/tree-digest.mjs +103 -0
  41. package/src/trust-roots/README.md +31 -0
  42. package/src/trust-roots/sigstore-public-good.json +126 -0
  43. package/src/trust.mjs +563 -0
  44. package/src/untar.mjs +570 -0
  45. package/src/upload.mjs +268 -0
  46. package/src/vendor.mjs +465 -0
package/src/packer.mjs ADDED
@@ -0,0 +1,351 @@
1
+ // packer —— 从一棵目录树产出 canonical 制品字节(tar + gzip)与 asset.sha256。
2
+ // 规范:01-artifacts.md §4(路径 grammar)、§5(载荷规则与上限)、§6(树摘要)、
3
+ // 02-registry.md §4.1(canonical tar.gz)、ERRATA E-3 / E-5 / E-6。
4
+ //
5
+ // 🔴 **自己写字节,绝不 shell out 到系统 `tar`**(ERRATA E-6):
6
+ // macOS 的 bsdtar 会为携带 xattr 注入 AppleDouble(`._*`)成员,而 §5 拒绝 xattr ——
7
+ // 于是用系统 tar 打的包会被**我们自己的校验器**拒掉,而打包的人从 `tar -tvf`
8
+ // 完全看不出异常(那个成员根本不会被列出来)。GNU tar 还会把尾部补到 20 个零块,
9
+ // 与 E-5 的「恰好两个零块」冲突。两条都指向同一个结论:字节必须由我们自己生成。
10
+ //
11
+ // ── 🔴 本模块最要紧的一条设计 ────────────────────────────────────────────────
12
+ //
13
+ // **packer 的接受集合按构造等于 parser 的接受集合。**
14
+ //
15
+ // R-11 反复出现的形状是「写入端与读取端各自合理但接受集合不同」。这里的具体风险是:
16
+ // `install.mjs` 的 `writeCanonicalTar()` 是一个**纯编码器**,它只校验路径 grammar、
17
+ // ustar 可切分性、mode 与重复路径;它**不查**
18
+ // · 大小写折叠冲突(parseTar 的 E_CASE_COLLIDE)
19
+ // · 同名既文件又目录(E_PATH_FILE_DIR_COLLIDE)
20
+ // · 目录名之间的大小写冲突
21
+ // · 单文件 / 总量 / 文件数上限(E_FILE_SIZE / E_TOTAL_SIZE / E_FILE_COUNT)
22
+ // 也就是说 `writeCanonicalTar` 能写出 `parseTar` 读不回来的归档。
23
+ //
24
+ // 消除办法**不是**在 packer 里再写一份校验器 —— 那正是「再造一次分叉」,
25
+ // 也正是 devmajor 与 `../x` 两次踩过的坑。办法是:
26
+ // **packer 把自己的产出立刻交给 `parseTar` / `gunzipCanonical` 反解,逐条比对;
27
+ // 反解不过或对不上就失败。** 于是「packer 接受某个输入」在定义上蕴含
28
+ // 「parser 接受它的产出」,两个集合相等是构造性的,不依赖两份代码保持同步。
29
+ //
30
+ // 代价是每次打包多解析一遍(≤16 MiB,实测微秒级到毫秒级),换来的是这一类 bug
31
+ // 结构上不可能再出现。
32
+ //
33
+ // 🔴 **这个办法证明了什么、没证明什么**(Codex 第一轮明确点出,别夸大):
34
+ // ✅ 证明「packer 产出的字节 ∈ parser 的接受集」——「写出自己读不回来的东西」这一类
35
+ // bug 结构上消失。
36
+ // ❌ **没有**证明两个集合相等:parser 仍可能接受 packer 永远不会生成的字节
37
+ // (那一侧由 `test/untar.test.mjs` 的恶意样例覆盖,不归本模块)。
38
+ // ❌ **没有**覆盖「两边同错」:writer 与 parser 共用 `assertArtifactPath` /
39
+ // `canonicalUstarSplit`,共用部分若有 bug,往返照样通过。
40
+ // 所以 `test/packer.test.mjs` 里另有**独立的 golden 字节向量**与**逐字节突变测试**
41
+ // (突变任意一个字节,parser 必须拒绝),它们不依赖往返。
42
+ // ❌ **没有**覆盖制品语义:一个没有 `skill.json` 的目录能打成合法 tar.gz。
43
+ // 那一层由 `packArtifact()` 强制,`packEntries()` 只承诺 tar/gzip 形状。
44
+ //
45
+ // ⚠️ **gzip 字节的跨版本一致性不在承诺内**:zlib 的输出跨 Node / 架构不保证逐字节
46
+ // 相同(`untar.mjs` 的 CANON_SLACK 注释里记着同一件事)。因此**同一棵源码树在两台
47
+ // 机器上打出的 `asset.sha256` 可能不同**。制品的身份是**发布时那一串字节**,
48
+ // 发布链必须传递已构建的资产、不得让下游重新打包再比对摘要。
49
+ import { createHash } from 'node:crypto';
50
+ import { lstatSync, readdirSync, readFileSync, rmSync } from 'node:fs';
51
+ import { tmpdir } from 'node:os';
52
+ import { relative, join, sep } from 'node:path';
53
+ import { deflateRawSync, constants as zlibConstants } from 'node:zlib';
54
+
55
+ import { writeCanonicalTar } from './install.mjs';
56
+ import { createIsolatedDir, writeEntries, assertManifestBinding } from './artifact.mjs';
57
+ import {
58
+ parseTar, gunzipCanonical, crc32, assertArtifactPath, canonicalUstarSplit,
59
+ MAX_FILE_BYTES, MAX_TOTAL_BYTES, MAX_FILES,
60
+ } from './untar.mjs';
61
+ import { treeDigest } from './tree-digest.mjs';
62
+
63
+ /** packer 侧的失败。`code` 与 untar 的 `violation` 分属两个命名空间,别混用。 */
64
+ export class PackError extends Error {
65
+ constructor(code, message, cause) {
66
+ super(message, cause === undefined ? undefined : { cause });
67
+ this.name = 'PackError';
68
+ this.code = code;
69
+ }
70
+ }
71
+ const bad = (code, msg, cause) => { throw new PackError(code, msg, cause); };
72
+
73
+ // 🔴 canonical deflate 参数必须与 `untar.gunzipCanonical` 用来核算分母的那一组**完全一致**。
74
+ // untar 没有导出它(那是它的内部常量),所以这里重复写一遍是不可避免的;
75
+ // 但 `packEntries()` 的自反解会把这份重复变成**可证伪的**:参数一旦不一致,
76
+ // canonical 重压缩比对(E_GZIP_NONCANONICAL)当场就红。
77
+ // windowBits/memLevel/strategy 全部显式钉死 —— 任一走默认,换个 Node 就可能算出别的长度。
78
+ const CANON_DEFLATE = Object.freeze({
79
+ level: 9,
80
+ windowBits: 15,
81
+ memLevel: 8,
82
+ strategy: zlibConstants.Z_DEFAULT_STRATEGY,
83
+ });
84
+
85
+ const ALLOWED_MODES = new Set([0o644, 0o755]);
86
+
87
+ // ── gzip 封套 ───────────────────────────────────────────────────────────────
88
+
89
+ /**
90
+ * canonical gzip(02-registry.md §4.1):`CM=8`、`FLG=0`、`MTIME=0`、`XFL=2`、`OS=255`,
91
+ * 单 member,尾部 CRC32 + ISIZE,之后什么都没有。
92
+ *
93
+ * 🔴 `FLG` 整字节 0 —— 不只是没有 FNAME/FCOMMENT,连 FTEXT / FEXTRA / FHCRC 与三个
94
+ * 保留位都不许置。它们都是「规范没定义的组合」,读取端一律拒。
95
+ */
96
+ export function gzipCanonical(tar) {
97
+ const body = deflateRawSync(tar, CANON_DEFLATE);
98
+ const head = Buffer.from([
99
+ 0x1f, 0x8b, // magic
100
+ 0x08, // CM = deflate
101
+ 0x00, // FLG = 0(整字节)
102
+ 0x00, 0x00, 0x00, 0x00, // MTIME = 0
103
+ 0x02, // XFL = 2(level 9 的取值)
104
+ 0xff, // OS = 255(unknown)
105
+ ]);
106
+ const tail = Buffer.alloc(8);
107
+ tail.writeUInt32LE(crc32(tar), 0); // 🔴 复用读取端那一份 crc32,不另写
108
+ tail.writeUInt32LE(tar.length >>> 0, 4); // ISIZE(载荷 ≤16 MiB,不会绕回)
109
+ return Buffer.concat([head, body, tail]);
110
+ }
111
+
112
+ // ── 打包 ────────────────────────────────────────────────────────────────────
113
+
114
+ function normalizeEntries(entries) {
115
+ if (!Array.isArray(entries)) bad('E_PACK_INPUT', 'entries 必须是数组');
116
+ if (entries.length === 0) {
117
+ // 空归档在 parseTar 里是 E_NO_EOF... 实际上「只有两个零块」会被判 E_TRUNCATED 之外的
118
+ // 分支放过去,但一个没有任何文件的制品在 §5 下没有意义(连 SKILL.md / pack.json
119
+ // 都没有)。早报早清楚。
120
+ bad('E_PACK_EMPTY', '载荷为空:制品至少要有一个文件(§5.1/§5.2 要求根上有 manifest)');
121
+ }
122
+ return entries.map((e, i) => {
123
+ const w = `entries[${i}]`;
124
+ if (e === null || typeof e !== 'object') bad('E_PACK_INPUT', `${w} 必须是对象`);
125
+ if (typeof e.path !== 'string') bad('E_PACK_INPUT', `${w}.path 必须是字符串`);
126
+ if (!ALLOWED_MODES.has(e.mode)) {
127
+ bad('E_PACK_MODE', `${w}.mode 只允许 0644 / 0755,得到 ${JSON.stringify(e.mode)}(§5)`);
128
+ }
129
+ if (!Buffer.isBuffer(e.data)) bad('E_PACK_INPUT', `${w}.data 必须是 Buffer`);
130
+ // 早报:路径 grammar 与 ustar 可切分性。**这两条不是权威判定** ——
131
+ // 权威判定是下面的自反解。放在这里只是为了让错误信息指向具体那一条,
132
+ // 而不是等到自反解时报一个「我写出来的东西自己读不回来」。
133
+ assertArtifactPath(e.path, w);
134
+ if (canonicalUstarSplit(e.path) === null) {
135
+ bad('E_PACK_USTAR', `${w}.path 无法被 ustar 的 prefix(155)+name(100) 切分:${e.path}(§4.3)`);
136
+ }
137
+ if (e.data.length > MAX_FILE_BYTES) {
138
+ bad('E_PACK_FILE_SIZE', `${w} 单文件 ${e.data.length} 字节超过 ${MAX_FILE_BYTES}(§5):${e.path}`);
139
+ }
140
+ return { path: e.path, mode: e.mode, data: e.data };
141
+ });
142
+ }
143
+
144
+ /**
145
+ * entries → canonical tar.gz。
146
+ *
147
+ * 🔴 产出**立刻被自己的解析器反解并逐条比对**(见文件头的设计说明)。
148
+ * 反解不过的输入就不是可打包的输入 —— packer 的接受集合按构造等于 parser 的。
149
+ *
150
+ * @returns {{tar: Buffer, bytes: Buffer, sha256: string, size: number,
151
+ * entries: Array<{path:string,mode:number,data:Buffer}>}}
152
+ * `bytes` 是最终制品(gzip);`sha256` 就是快照里的 `asset.sha256`。
153
+ */
154
+ export function packEntries(entries) {
155
+ const norm = normalizeEntries(entries);
156
+ if (norm.length > MAX_FILES) {
157
+ bad('E_PACK_FILE_COUNT', `文件数 ${norm.length} 超过 ${MAX_FILES}(§5)`);
158
+ }
159
+ const total = norm.reduce((n, e) => n + e.data.length, 0);
160
+ if (total > MAX_TOTAL_BYTES) {
161
+ bad('E_PACK_TOTAL_SIZE', `解压后总计 ${total} 字节超过 ${MAX_TOTAL_BYTES}(§5)`);
162
+ }
163
+
164
+ const tar = writeCanonicalTar(norm); // 🔴 复用既有写入端,不另写一个 tar writer
165
+
166
+ // ── 自反解 ① tar ─────────────────────────────────────────────────────────
167
+ // parseTar 抛出的 TarViolation 原样向上传 —— 它的 violation 码(E_CASE_COLLIDE、
168
+ // E_PATH_FILE_DIR_COLLIDE、E_ORDER…)**就是**对输入的准确诊断,包一层反而丢信息。
169
+ const back = parseTar(tar);
170
+ const want = [...norm].sort((a, b) =>
171
+ Buffer.compare(Buffer.from(a.path, 'utf8'), Buffer.from(b.path, 'utf8')));
172
+ if (back.entries.length !== want.length) {
173
+ bad('E_PACK_ROUNDTRIP',
174
+ `自反解条目数不符:写入 ${want.length},读回 ${back.entries.length}`);
175
+ }
176
+ for (let i = 0; i < want.length; i++) {
177
+ const a = want[i], b = back.entries[i];
178
+ if (a.path !== b.path || a.mode !== b.mode || !a.data.equals(b.data)) {
179
+ bad('E_PACK_ROUNDTRIP',
180
+ `自反解第 ${i} 项不符:写入 ${JSON.stringify(a.path)}/0${a.mode.toString(8)}/${a.data.length}B,`
181
+ + `读回 ${JSON.stringify(b.path)}/0${b.mode.toString(8)}/${b.data.length}B`);
182
+ }
183
+ }
184
+
185
+ // ── 自反解 ② gzip ────────────────────────────────────────────────────────
186
+ const bytes = gzipCanonical(tar);
187
+ const backTar = gunzipCanonical(bytes);
188
+ if (!backTar.equals(tar)) {
189
+ bad('E_PACK_ROUNDTRIP', `gzip 自反解字节不符(${backTar.length} vs ${tar.length})`);
190
+ }
191
+
192
+ return {
193
+ tar,
194
+ bytes,
195
+ size: bytes.length,
196
+ sha256: 'sha256:' + createHash('sha256').update(bytes).digest('hex'),
197
+ entries: want,
198
+ };
199
+ }
200
+
201
+ // ── 目录树 → entries ────────────────────────────────────────────────────────
202
+
203
+ /**
204
+ * 无跟随遍历一棵载荷目录,产出 entries。
205
+ *
206
+ * 拒绝:symlink、hardlink、非普通文件、mode 非 0644/0755、**空目录**(§5:不可表示)、
207
+ * 以及 §4 路径 grammar 的一切违规(含 AppleDouble 的专门违规码)。
208
+ *
209
+ * 🔴 空目录必须**拒绝而不是静默忽略**:忽略等于「打出来的包与源目录不是同一棵树」,
210
+ * 而打包的人无从知道。同 E-6 的教训 —— 诊断必须针对字节。
211
+ */
212
+ export function collectTree(root) {
213
+ // 🔴 根本身也要查:「遍历时拒绝 symlink」不覆盖根路径 —— 把 root 指到一个
214
+ // symlink 上,第一次 readdir 就已经跟随过去了(Codex 第一轮 #4)。
215
+ const rootSt = lstatSync(root);
216
+ if (rootSt.isSymbolicLink()) bad('E_PACK_SYMLINK', `载荷根 ${root} 是 symlink,拒绝跟随`);
217
+ if (!rootSt.isDirectory()) bad('E_PACK_INPUT', `载荷根 ${root} 不是目录`);
218
+ const out = [];
219
+ (function rec(dir) {
220
+ const names = readdirSync(dir);
221
+ let files = 0;
222
+ for (const name of names.sort()) {
223
+ const abs = join(dir, name);
224
+ const st = lstatSync(abs);
225
+ const rel = relative(root, abs).split(sep).join('/').normalize('NFC');
226
+ if (st.isSymbolicLink()) bad('E_PACK_SYMLINK', `拒绝 symlink:${rel}(§5)`);
227
+ if (st.isDirectory()) {
228
+ // 目录名同样要过路径 grammar —— 否则一个非法目录名要等到它下面的文件
229
+ // 才被报出来,错误信息指向的是文件而不是真正的病灶。
230
+ assertArtifactPath(rel, `dir:${rel}`);
231
+ const n = rec(abs);
232
+ if (n === 0) {
233
+ bad('E_PACK_EMPTY_DIR',
234
+ `空目录不可表示,制品禁止空目录(§5):${rel}。`
235
+ + `它不会进树摘要,也不会进归档 —— 静默丢掉会让制品与源目录不是同一棵树`);
236
+ }
237
+ files += n;
238
+ continue;
239
+ }
240
+ if (!st.isFile()) bad('E_PACK_NOT_REGULAR', `拒绝非普通文件(FIFO/socket/设备):${rel}(§5)`);
241
+ if (st.nlink !== 1) bad('E_PACK_HARDLINK', `拒绝 hardlink(nlink=${st.nlink}):${rel}(§5)`);
242
+ // 🔴 掩码取 0o7777 而不是 0o777:`& 0o777` 会把 setuid/setgid/sticky **静默抹掉**,
243
+ // 于是 `chmod 4755 SKILL.md` 被当成一个普通的 0755 文件收下(Codex 第二轮 #3)。
244
+ // §5 说的是「mode 只允许 0644 或 0755」——**恰好等于**,不是「低九位等于」。
245
+ // 截断是最坏的处理方式:源树里那个特权位既没被拒绝、也没被保留,
246
+ // 打包的人不会知道它消失了。
247
+ const mode = st.mode & 0o7777;
248
+ if (!ALLOWED_MODES.has(mode)) {
249
+ bad('E_PACK_MODE',
250
+ `文件 mode 只允许 0644 / 0755,${rel} 是 0${mode.toString(8)}(§5)`
251
+ + ((st.mode & 0o7000) ? ';它带了 setuid/setgid/sticky 位' : ''));
252
+ }
253
+ assertArtifactPath(rel, `file:${rel}`);
254
+ out.push({ path: rel, mode, data: readFileSync(abs) });
255
+ files++;
256
+ }
257
+ return files;
258
+ })(root);
259
+ if (out.length === 0) bad('E_PACK_EMPTY', `载荷目录没有任何文件:${root}`);
260
+ return out;
261
+ }
262
+
263
+ /**
264
+ * 一棵目录树 → 完整的制品身份三件套。
265
+ *
266
+ * 🔴 `tree_digest` 走**共享的** `tree-digest.mjs`(§6.3:CLI 与 CI 一份实现,
267
+ * 两份必然分叉、而分叉点正好是绕过点)。packer 不自己算摘要。
268
+ *
269
+ * 🔴 **摘要算的是「归档里的那棵树」,不是源目录**(Codex 第一轮 #4):
270
+ * 先把打好的 entries 写进一个隔离临时目录,再对它算摘要。
271
+ * 直接 `treeDigest(root)` 是**第二次读源目录** —— 打包与算摘要之间源文件被改,
272
+ * 产出的 `asset.sha256` 与 `tree_digest` 就会指向两棵不同的树,
273
+ * 而安装端要到解包后重算摘要时才发现(那时已经发布出去了)。
274
+ * 这与 R-1 是同一形状:判据必须落在**我们手上的那份字节**上。
275
+ *
276
+ * @returns {{entries, tar, bytes, size, sha256, tree_digest}}
277
+ */
278
+ export function packDirectory(root, { parent = tmpdir() } = {}) {
279
+ const entries = collectTree(root);
280
+ const packed = packEntries(entries);
281
+ const dir = createIsolatedDir(parent);
282
+ try {
283
+ writeEntries(dir, packed.entries);
284
+ return { ...packed, tree_digest: treeDigest(dir) };
285
+ } finally {
286
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* 尽力而为 */ }
287
+ }
288
+ }
289
+
290
+ /**
291
+ * 🔴 **最终 packer 入口** —— 在 `packDirectory()` 之上强制 §5.1/§5.2 的载荷规则。
292
+ *
293
+ * Codex 第一轮 #4 指出的口子:`packEntries()` 只保证「产出能被 `parseTar` 接受」,
294
+ * 而**制品的接受集合比 tar 的窄** —— 一个没有 `skill.json` 的目录照样能打成
295
+ * 合法 tar.gz,然后在 `verifyArtifact()` 那里被拒。打包的人要等到安装才知道。
296
+ * 所以「打一个制品」这个动作必须在这里就把 manifest 必需项判掉。
297
+ *
298
+ * `packEntries` / `packDirectory` 保留为**底层 API**,前置条件写在这里:
299
+ * 它们只承诺 tar/gzip 形状,不承诺制品语义。
300
+ *
301
+ * @param {'skill'|'pack'} kind
302
+ */
303
+ export function packArtifact({ root, kind, record = null, parent = tmpdir() }) {
304
+ if (kind !== 'skill' && kind !== 'pack') bad('E_PACK_INPUT', `kind 只能是 skill / pack,得到 ${JSON.stringify(kind)}`);
305
+ const entries = collectTree(root);
306
+ const packed = packEntries(entries);
307
+ const have = new Set(packed.entries.map(e => e.path));
308
+ const need = kind === 'skill' ? ['SKILL.md', 'skill.json'] : ['pack.json'];
309
+ for (const f of need) {
310
+ if (!have.has(f)) bad('E_PACK_MANIFEST_MISSING', `${kind} 载荷根缺少 ${f}(01-artifacts.md §5.1/§5.2)`);
311
+ }
312
+ // 反向:skill 载荷不得带 pack.json,反之亦然 —— 两个 manifest 并存时
313
+ // 「这是什么制品」有两个答案,而 assertManifestBinding 只读其中一个。
314
+ // ⚠️ 这一条**比 `verifyArtifact()` 严**(它不查 foreign manifest)。方向是安全的
315
+ // (生产侧更严),但两侧接受集合确实不等,已写进交付汇报。
316
+ const foreign = kind === 'skill' ? 'pack.json' : 'skill.json';
317
+ if (have.has(foreign)) bad('E_PACK_MANIFEST_MISSING', `${kind} 载荷根不得同时带 ${foreign}`);
318
+
319
+ const dir = createIsolatedDir(parent);
320
+ try {
321
+ writeEntries(dir, packed.entries);
322
+ // 🔴 给了 record 就跑**真正的**绑定校验(§5.3 的六/七项全等)。
323
+ // 只查「manifest 文件在不在」是不够的 —— `skill.json = {}` 也能过存在性检查,
324
+ // 然后在安装端被 verifyArtifact 拒掉(Codex 第二轮 #3)。
325
+ // 这里刻意调 artifact.mjs 那一份,**不另写一个校验器**:
326
+ // 打包端与安装端判的必须是同一件事。
327
+ if (record !== null) assertManifestBinding(record, dir);
328
+ return { ...packed, kind, tree_digest: treeDigest(dir), boundTo: record?.id ?? null };
329
+ } finally {
330
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* 尽力而为 */ }
331
+ }
332
+ }
333
+
334
+ /**
335
+ * 05-lifecycle.md §6.1 / 08-matrix-migration.md §3.1 的**上游侧**摘要。
336
+ *
337
+ * vendored 制品在导入时必然新增 `skill.json`,所以它的 `tree_digest` 与上游那棵树
338
+ * 的摘要**不可能相等**(v1 承诺两者一致,那是错的)。这个函数算的是上游那一份,
339
+ * 即 `provenance.origin_tree_digest` 的**内容**。
340
+ *
341
+ * ⚠️ **书写形式有冲突,调用方必须知道**:本函数返回 `geoly-tree-v1:sha256:<hex>`
342
+ * (§6.1 规定算法标识进入每一个摘要值本身);而 `snapshot.mjs` 对
343
+ * `provenance.origin_tree_digest` 用的是 `assertAssetDigest`,只接受 `sha256:<hex>`
344
+ * ——两者不兼容。见交付汇报「迁移视角」。这里**不做静默转换**:
345
+ * 静默截掉算法前缀正是 E-3 要消灭的「一个逻辑值多种书写」。
346
+ */
347
+ export function payloadTreeDigest(root) {
348
+ return treeDigest(root);
349
+ }
350
+
351
+ export { MAX_FILE_BYTES, MAX_TOTAL_BYTES, MAX_FILES };