@geoly-ai/skills-hub 0.3.2 → 0.3.4

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.
@@ -0,0 +1,835 @@
1
+ // `update [<spec>…] | --all` —— 09-cli.md §1:「**受控地改 lockfile 并应用**」,
2
+ // 04-install.md §8:「`update` 是受控地改 lockfile:**展示 diff、要求确认**」。
3
+ //
4
+ // 🔴 这条命令改的是**账本里的 root**,不是「命令行说了什么」。
5
+ // `install` 的语义是「往图里**加**一条边」(planEntryRefs 刻意保留旧边);
6
+ // `update` 的语义是「把某几条 root **换掉**」——
7
+ // ⚠️ 直接复用 `planEntryRefs()` 会把 `pack:P@0.3` 更新到 `pack:P@0.4` 之后
8
+ // 留下 `[P@0.3, P@0.4]` 两条边(成员没变时「制品相同 ⇒ 合并旧边」那一支),
9
+ // 于是旧 root 永远删不掉、lockfile 里多出一条假记录(Codex 2026-09-04 P0-1)。
10
+ // 所以本模块自己算完整的二部图:**先把选中 root 的全部边摘掉,再叠加新解析结果。**
11
+ //
12
+ // 时序(`installOneTarget` 是同步的、在锁里;出网只能发生在取锁之前):
13
+ // ① 锁外:解析快照 → 读各 target 账本 → 重解析选中 root → 算 diff → 展示 → 确认
14
+ // → 预热要用到的资产与历史快照;
15
+ // ② 锁内:`recover(auto)` → 预检 → **重读账本重算** → 比**语义指纹**
16
+ // → 验签解包 → `derivePlan` → `runTransaction`。
17
+ // 指纹比的是「用户看到并同意的那件事」(图、退役集、root 写/删),
18
+ // 不是整份 plan —— generation / 时间戳 / stage 路径本来就会变。
19
+
20
+ import { existsSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { planTargets, assertPlanOk, STATE_DIR } from '../adapters/index.mjs';
23
+ import { precheckTarget, assertPrecheckOk } from '../target.mjs';
24
+ import { withVerifiedArtifact } from '../artifact.mjs';
25
+ import { validatePackManifest, resolvePackInstall, addRequestedBy, conflictMatches, parseRootKey } from '../pack.mjs';
26
+ import { withPackErrors } from './pack-errors.mjs';
27
+ import { layout, readLedger, nextGeneration, ensureGenerationWatermark } from '../ledger.mjs';
28
+ import { derivePlan } from '../plan.mjs';
29
+ import { runTransaction, nowUtc } from '../install.mjs';
30
+ import { recover } from '../recover.mjs';
31
+ import { withOrderedLocks } from './locks.mjs';
32
+ import { parseSpec, resolveSpec } from './resolve.mjs';
33
+ import { annotations, annotationSuffix } from './output.mjs';
34
+ import { stringify } from '../canonical-json.mjs';
35
+ import { UsageError, ConflictError, EXIT, classify } from '../exit-codes.mjs';
36
+ import { resolveSnapshotForCommand } from './snapshot-access.mjs';
37
+ import { preheatForInstall, preheatAssetsFor } from './preheat-run.mjs';
38
+ import { prewarmLockfileInputs } from './project-lockfile.mjs';
39
+ import {
40
+ allInstallable, assertDiskSpace, confirmAll, detectShadowed, treeBytes, withVerifiedArtifacts,
41
+ } from './install.mjs';
42
+ import {
43
+ assertEntryTreeIntact, assertLedgerGraphUsable, confirmYes, entryStillMatches,
44
+ graphFingerprint, orphanRootsOf, readTargetLedger,
45
+ } from './refgraph.mjs';
46
+
47
+ const ALL_PREFIX = 'all@snapshot:';
48
+
49
+ // ════════════════════════════════════════════════════════════════════════════
50
+ // 选 root
51
+ // ════════════════════════════════════════════════════════════════════════════
52
+
53
+ /**
54
+ * 把一条 spec 编译成「匹配 root」的谓词。
55
+ *
56
+ * · `[skill:|pack:][<ns>/]<name>[@<ver>]` —— 匹配 `direct:` 与 `pack:` root;
57
+ * `@<ver>` 是**目标版本**(可用来降级),不是筛选条件。
58
+ * · `all@snapshot:<N>` 原文 —— 只匹配那一条 all root。
59
+ * 🔴 不接受裸 `all`:`all` 是一个合法的 skill name,两种读法都说得通就不猜。
60
+ */
61
+ export function compileSelector(spec) {
62
+ if (spec.startsWith(ALL_PREFIX)) {
63
+ parseRootKey(spec, `update 的 spec ${spec}`); // grammar 由 parseRootKey 把关
64
+ return { kind: 'all-root', key: spec, raw: spec, match: (key) => key === spec, version: null };
65
+ }
66
+ const q = parseSpec(spec);
67
+ return {
68
+ kind: 'artifact',
69
+ match: (key, rec) => {
70
+ if (rec.kind === 'all') return false;
71
+ const a = parseRootKey(key).artifact;
72
+ if (q.kind !== null && a.kind !== q.kind) return false;
73
+ if (q.namespace !== null && a.namespace !== q.namespace) return false;
74
+ return a.name === q.name;
75
+ },
76
+ query: q,
77
+ raw: spec,
78
+ version: q.version,
79
+ };
80
+ }
81
+
82
+ /** 选中的 root:`{key, record, targetVersion}`。🔴 一条 spec 命中多个 root 是错误,不猜。 */
83
+ export function selectRoots(L, selectors, { all }) {
84
+ const keys = Object.keys(L.roots ?? {}).sort();
85
+ if (all) return keys.map((key) => ({ key, record: L.roots[key], targetVersion: null }));
86
+ const out = new Map();
87
+ for (const s of selectors) {
88
+ const hit = keys.filter((k) => s.match(k, L.roots[k]));
89
+ if (hit.length > 1) {
90
+ throw new UsageError(
91
+ `spec ${s.raw} 在这个 target 的账本里命中了多条 root:\n${hit.map((k) => ` ${k}`).join('\n')}\n`
92
+ + ' 不猜 —— 请用更完整的写法(带 namespace / kind 前缀)点名其中一条。',
93
+ );
94
+ }
95
+ for (const k of hit) out.set(k, { key: k, record: L.roots[k], targetVersion: s.version });
96
+ }
97
+ return [...out.values()].sort((a, b) => (a.key < b.key ? -1 : 1));
98
+ }
99
+
100
+ /**
101
+ * 账本里记的 `intent`(snake)→ `resolvePackInstall` 要的(camel)。
102
+ *
103
+ * 🔴 两件事都要做对:
104
+ * ① **字段名要转**(Codex 2026-09-04)——直接把 `no_bundled` 传下去,
105
+ * `resolvePackInstall` 读到的是 `undefined`,于是 `--no-bundled` 那次安装的
106
+ * 意图在 update 时**静默失效**,bundled 成员会被重新拉回来;
107
+ * ② **与本次运行的 flag 取并**,并把并集写回新 root 的 intent ——
108
+ * 账本记的是本机历史,历史必须是真的。
109
+ */
110
+ export function effectiveIntent(stored, ctx) {
111
+ const noBundled = Boolean(stored?.no_bundled) || Boolean(ctx.noBundled);
112
+ const pre = Boolean(stored?.pre) || Boolean(ctx.pre);
113
+ const allowYanked = Boolean(stored?.allow_yanked) || Boolean(ctx.allowYanked);
114
+ const snake = { no_bundled: noBundled, pre };
115
+ if (allowYanked) snake.allow_yanked = true;
116
+ return { camel: { allowYanked, noBundled, pre }, snake };
117
+ }
118
+
119
+ // ════════════════════════════════════════════════════════════════════════════
120
+ // 重解析 + 建图
121
+ // ════════════════════════════════════════════════════════════════════════════
122
+
123
+ /**
124
+ * 一个 target 的事务后二部图。
125
+ *
126
+ * @returns {{postEdges:Map, artifacts:Map, records:Map, retire:string[],
127
+ * writeRoots:object, removeRoots:string[], changed:string[], rootDiff:Array}}
128
+ */
129
+ export function buildPostGraph({ L, selected, resolutions }) {
130
+ const selectedOld = new Set(selected.map((s) => s.key));
131
+
132
+ // ① 先把选中 root 的**全部**边摘掉(这正是 update 与 install 的分水岭)
133
+ const postEdges = new Map();
134
+ const artifacts = new Map();
135
+ for (const [n, e] of Object.entries(L.entries ?? {})) {
136
+ const rest = (e.requested_by ?? []).filter((k) => !selectedOld.has(k));
137
+ if (rest.length === 0) continue; // 这条 entry 只由选中 root 撑着
138
+ postEdges.set(n, rest);
139
+ artifacts.set(n, e.artifact);
140
+ }
141
+
142
+ // ② 叠加新解析结果
143
+ const records = new Map();
144
+ const writeRoots = {};
145
+ const rootDiff = [];
146
+ for (const r of resolutions) {
147
+ writeRoots[r.newKey] = r.rootRecord;
148
+ // 🔴 「有没有变」不能只看 key(Codex 2026-09-04 P1-1):同一条 root、同一个制品、
149
+ // 同一份成员图,只有 `intent`(no_bundled / pre / allow_yanked)变了时
150
+ // `changed` 与 `rootDiff` 都是空的 —— diff 会显示「无变化」并提前返回,
151
+ // 于是 `effectiveIntent` 算出来的新意图**永远写不进账本**。
152
+ const before = L.roots?.[r.oldKey] ?? null;
153
+ const intentChanged = r.oldKey === r.newKey
154
+ && before !== null
155
+ && stringify(before.intent ?? null) !== stringify(r.rootRecord.intent ?? null);
156
+ rootDiff.push({
157
+ from: r.oldKey,
158
+ intent_changed: intentChanged,
159
+ kind: r.rootRecord.kind,
160
+ to: r.newKey,
161
+ });
162
+ for (const unit of r.units) {
163
+ const name = unit.record.name;
164
+ const already = artifacts.get(name);
165
+ if (already !== undefined && already !== unit.record.id) {
166
+ // 🔴 判据是「**任意**还留着的 root」,不是「只有 pack root 才算」
167
+ // (Codex 2026-09-04 P0-2)。一条未被选中的 direct / all root 同样在
168
+ // 要求那个旧制品;把目录换掉要么等于悄悄删掉那条 root,要么让账本说谎。
169
+ const holders = (postEdges.get(name) ?? []).join(', ');
170
+ throw new ConflictError(
171
+ `${name} 换不动:${r.newKey} 现在要 ${unit.record.id},`
172
+ + `但账本里**没有被本次选中**的 root 仍然锁着 ${already}(${holders})。\n`
173
+ + ' 同一个目录名不能同时是两个制品 —— 出路是把那些 root 一起 update(加进 spec 或用 --all),\n'
174
+ + ' 或者先把它们处理掉。🔴 没有泛化的 --force。',
175
+ { telemetryReason: 'version-conflict' },
176
+ );
177
+ }
178
+ const prevRec = records.get(name);
179
+ if (prevRec !== undefined && prevRec.id !== unit.record.id) {
180
+ throw new ConflictError(
181
+ `目录名 ${name} 被本次两条 root 请求成不同的制品:${prevRec.id} 与 ${unit.record.id}。\n`
182
+ + ' 它们要落到同一个目录上,谁覆盖谁没有正确答案。',
183
+ { telemetryReason: 'version-conflict' },
184
+ );
185
+ }
186
+ records.set(name, unit.record);
187
+ artifacts.set(name, unit.record.id);
188
+ postEdges.set(name, addRequestedBy(postEdges.get(name) ?? [], r.newKey));
189
+ }
190
+ }
191
+
192
+ // ③ 退役:账本里有、事务后一条边都没有
193
+ const retire = Object.keys(L.entries ?? {}).filter((n) => !postEdges.has(n)).sort();
194
+
195
+ // ④ 要重写的 entry:制品变了、边变了、或者是新的
196
+ const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
197
+ const changed = [...postEdges.keys()].filter((n) => {
198
+ const old = L.entries?.[n];
199
+ if (!old) return true;
200
+ if (old.artifact !== artifacts.get(n)) return true;
201
+ return !same(old.requested_by ?? [], postEdges.get(n));
202
+ }).sort();
203
+
204
+ const removeRoots = orphanRootsOf(L, postEdges, Object.keys(writeRoots))
205
+ .filter((k) => !Object.hasOwn(writeRoots, k));
206
+ return { artifacts, changed, postEdges, records, removeRoots, retire, rootDiff, writeRoots };
207
+ }
208
+
209
+ /**
210
+ * §4 第 4 步的 `conflicts`,判据是**事务后的全景**。
211
+ *
212
+ * 🔴 参与判定的 pack **不只是本次换掉的那些**:一条没被选中的 pack root 照样声明着
213
+ * conflicts,而 update 可能给图里加进它不许共存的东西(Codex 2026-09-04 P1)。
214
+ * 所以调用方必须把 post-state 里**全部** pack root 的 manifest 都交进来。
215
+ *
216
+ * 🔴 update **没有 `--replace` 出路**:要退掉的那一条是用户先前明确装过的,
217
+ * 在一次「升级」里顺手删掉它不是用户表达过的意思。
218
+ */
219
+ export function assertNoConflicts(packInfos, artifacts, target) {
220
+ const blocked = [];
221
+ for (const p of packInfos) {
222
+ for (const pat of p.manifest.conflicts) {
223
+ for (const q of packInfos) {
224
+ if (q.record.id === p.record.id) continue;
225
+ if (conflictMatches(pat, q.record.id)) {
226
+ blocked.push({ artifact: q.record.id, name: q.record.name, pack: p.record.id, pattern: pat.raw });
227
+ }
228
+ }
229
+ for (const [name, artifact] of artifacts) {
230
+ if (conflictMatches(pat, artifact)) blocked.push({ artifact, name, pack: p.record.id, pattern: pat.raw });
231
+ }
232
+ }
233
+ }
234
+ if (blocked.length === 0) return;
235
+ throw new ConflictError(
236
+ `${target}:更新之后的状态会违反 pack 声明的 conflicts(03-packs.md §4 第 4 步):\n`
237
+ + blocked.map((b) => ` ${b.name}(${b.artifact})命中 ${b.pack} 的 conflicts: ${b.pattern}`).join('\n')
238
+ + '\n 🔴 update 不提供 --replace 出路(在一次升级里顺手删掉你先前装过的东西,'
239
+ + '不是你表达过的意思)——\n 请先 `remove` 掉其中一边,或换一个不冲突的版本。',
240
+ { telemetryReason: 'version-conflict' },
241
+ );
242
+ }
243
+
244
+ // ════════════════════════════════════════════════════════════════════════════
245
+ // 命令
246
+ // ════════════════════════════════════════════════════════════════════════════
247
+
248
+ export async function cmdUpdate(ctx, argv, out) {
249
+ const specs = [];
250
+ let isAll = false;
251
+ for (const a of argv) {
252
+ if (a === '--all') { isAll = true; continue; }
253
+ if (a.startsWith('-')) throw new UsageError(`update 不认得 flag ${a}`);
254
+ specs.push(a);
255
+ }
256
+ if (isAll && specs.length) {
257
+ throw new UsageError(`--all 不与显式 spec 混用(多给了:${specs.join(' ')})。--all 已经含账本里全部 root。`);
258
+ }
259
+ // 09-cli.md §1 写的是 `update [<spec>] | --all` —— 方括号允许省略,所以
260
+ // 「不给 spec」= 更新账本里全部 root。它照样要过 diff 与确认,不是无声全改。
261
+ const all = isAll || specs.length === 0;
262
+ const selectors = specs.map(compileSelector);
263
+
264
+ // 🔴 `--snapshot` 在 update 上**拒绝**,不给「合理默认」。
265
+ // 钉一张历史快照能决定「解析到哪个版本」,但决定不了 §4「现在还该不该用」——
266
+ // 那必须查**当前** timestamp 指向的快照(05-lifecycle §4:yanked 阻断更新)。
267
+ // 两者怎么组合规范没写;在这里假装它有意义,会让一条命令看起来过了状态门而其实没过。
268
+ if (ctx.snapshot !== null) {
269
+ throw new UsageError(
270
+ 'update 不接受 --snapshot:钉快照决定的是「解析到哪个版本」,'
271
+ + '而 update 还必须过**当前**快照的状态门(yanked 阻断更新,05-lifecycle.md §4)。\n'
272
+ + ' 两者怎么组合规范没写 —— 不给「合理默认」。\n'
273
+ + ' 要复现某一版请用 `install <name>@<version>`;要回退请用 `recover --from-generation <N>`。',
274
+ );
275
+ }
276
+
277
+ // ── 目标解析。🔴 **不传 createMissing**:update 不为没有账本的 target 建目录 ──
278
+ const tplan = planTargets({
279
+ clients: ctx.clients, scope: ctx.scope, home: ctx.home, env: ctx.env, projectRoot: ctx.projectRoot,
280
+ });
281
+ for (const w of tplan.warnings) out.warn(w);
282
+ assertPlanOk(tplan);
283
+ if (ctx.createMissing) out.warn('--create-missing 在 update 上不生效(没有账本的 target 只会被跳过)。');
284
+ if (ctx.replace.length) out.warn('--replace 在 update 上不生效:update 的冲突没有「点名替换」出路(见冲突报错里的说明)。');
285
+ for (const s of tplan.skipped) out.note(`跳过 ${s.client}/${s.scope}:${s.reason} —— ${s.message}`);
286
+ const skippedRows = [...tplan.skipped];
287
+
288
+ // ── 联网刷新 + 解析当前快照(🔴 出网只发生在这一段)─────────────────────
289
+ await preheatForInstall(ctx, out);
290
+ const { snapshot: snap, stale, floor, verifier } = await resolveSnapshotForCommand(ctx);
291
+ if (stale) out.warn('timestamp 已过期:本次输出全部按 stale 处理(--allow-stale 已给)');
292
+
293
+ // ── 逐 target 读账本、选 root ────────────────────────────────────────────
294
+ const work = [];
295
+ for (const t of tplan.selected) {
296
+ if (!existsSync(t.target)) {
297
+ skippedRows.push({ client: t.client, message: `${t.target} 不存在`, reason: 'no-target', scope: t.scope });
298
+ continue;
299
+ }
300
+ const L = readTargetLedger(t.target);
301
+ if (L === null || Object.keys(L.roots ?? {}).length === 0) {
302
+ skippedRows.push({ client: t.client, message: `${t.target} 没有可更新的 root`, reason: 'no-ledger', scope: t.scope });
303
+ continue;
304
+ }
305
+ assertLedgerGraphUsable(L, `${t.target}/.geoly/ledger.json`);
306
+ const selected = selectRoots(L, selectors, { all });
307
+ if (selected.length === 0) {
308
+ skippedRows.push({ client: t.client, message: `没有 root 匹配 ${specs.join(' ')}`, reason: 'no-match', scope: t.scope });
309
+ out.note(`跳过 ${t.client}/${t.scope}:没有 root 匹配 ${specs.join(' ')}`);
310
+ continue;
311
+ }
312
+ work.push({ L, selected, t });
313
+ }
314
+ if (specs.length && work.length === 0) {
315
+ throw new UsageError(
316
+ `没有任何 target 的账本里有匹配 ${specs.join(' ')} 的 root。\n`
317
+ + ' `skills-hub list --installed` 能看到装了什么;`why <name>` 能看到 root 是哪些。',
318
+ { telemetryReason: 'not-found' },
319
+ );
320
+ }
321
+ if (work.length === 0) {
322
+ out.line('update:没有可更新的 target。');
323
+ return out.emit('update', {
324
+ skipped: skippedRows.map((s) => ({ client: s.client, reason: s.reason, scope: s.scope })),
325
+ snapshot: snap.snapshot, targets: [],
326
+ }, EXIT.OK);
327
+ }
328
+
329
+ // ── 第一波:pack 本体的字节(🔴 必须在算图之前 —— 成员集合在它的载荷里)──
330
+ // 收的是 **post-state 里全部 pack root**:选中的(新版本)+ 未选中的(现版本)。
331
+ // 未选中的那批只用于 conflicts 门 —— 少收它们,那道门就只对本次换掉的 pack 生效。
332
+ const byId = new Map(snap.artifacts.map((r) => [r.id, r]));
333
+ const packWanted = new Map(); // id → record
334
+ const newPackFor = new Map(); // `${target}\0${oldKey}` → record
335
+ for (const { L, selected, t } of work) {
336
+ for (const s of selected) {
337
+ const rk = parseRootKey(s.key);
338
+ if (rk.kind !== 'pack') continue;
339
+ const { camel } = effectiveIntent(s.record.intent, ctx);
340
+ const rec = resolveSpec(snap, {
341
+ kind: 'pack', name: rk.artifact.name, namespace: rk.artifact.namespace,
342
+ raw: s.key, version: s.targetVersion,
343
+ }, { allowYanked: camel.allowYanked, pre: camel.pre });
344
+ packWanted.set(rec.id, rec);
345
+ newPackFor.set(`${t.target}\u0000${s.key}`, rec);
346
+ }
347
+ // 未选中的 pack root:拿它**当前**那一版的 record(conflicts 门要它的 manifest)
348
+ for (const key of Object.keys(L.roots)) {
349
+ if (selected.some((s) => s.key === key)) continue;
350
+ if (parseRootKey(key).kind !== 'pack') continue;
351
+ const rec = byId.get(L.roots[key].artifact);
352
+ if (rec === undefined) {
353
+ throw new ConflictError(
354
+ `${t.target}:账本里的 pack root ${key} 不在当前快照 ${snap.snapshot} 里,`
355
+ + '因此拿不到它的 conflicts 声明 —— 无法证明更新之后的状态不违反它。\n'
356
+ + ' 🔴 拒绝,不做「尽力而为」。出路:把它也一起 update(`--all`),或先 remove 掉。',
357
+ { telemetryReason: 'not-found' },
358
+ );
359
+ }
360
+ packWanted.set(rec.id, rec);
361
+ }
362
+ }
363
+ await preheatAssetsFor(ctx, [...packWanted.values()], snap);
364
+ const packManifests = new Map();
365
+ for (const rec of packWanted.values()) {
366
+ const bytes = ctx.registry.fetchAsset(rec);
367
+ packManifests.set(rec.id, withPackErrors(() => withVerifiedArtifact(
368
+ { bytes, record: rec }, (art) => validatePackManifest(art.manifest),
369
+ )));
370
+ }
371
+
372
+ // ── 建图(逐 target)─────────────────────────────────────────────────────
373
+ const at = nowUtc(ctx.now());
374
+ const previews = [];
375
+ for (const w of work) {
376
+ const { L, selected, t } = w;
377
+ const resolutions = [];
378
+ for (const s of selected) {
379
+ const rk = parseRootKey(s.key);
380
+ const { camel, snake } = effectiveIntent(s.record.intent, ctx);
381
+ if (rk.kind === 'all') {
382
+ const recs = allInstallable(snap, t.client);
383
+ const newKey = `${ALL_PREFIX}${snap.snapshot}`;
384
+ resolutions.push({
385
+ isAllRoot: true,
386
+ newKey,
387
+ oldKey: s.key,
388
+ // 🔴 `all` root 不带 artifact / tree_digest(04-install.md §4 的 root-key grammar),
389
+ // 且它的 intent 里那两个 flag 在 `--all` 上本就不生效,一律写 false。
390
+ rootRecord: {
391
+ intent: { no_bundled: false, pre: false }, kind: 'all',
392
+ requested_at: newKey === s.key ? s.record.requested_at : at, snapshot: snap.snapshot,
393
+ },
394
+ units: recs.map((record) => ({ record })),
395
+ });
396
+ continue;
397
+ }
398
+ // (`direct:` root 只能指向 skill 这一条,已由 `assertLedgerGraphUsable`
399
+ // 的顶点标签闭合门在**消费之前**挡掉,并落在正确的 2 上 —— 这里不再重复判,
400
+ // 免得同一条规则有两处实现、两处可以分叉。)
401
+ const rec = rk.kind === 'pack'
402
+ ? newPackFor.get(`${t.target}\u0000${s.key}`)
403
+ : resolveSpec(snap, {
404
+ kind: rk.artifact.kind, name: rk.artifact.name, namespace: rk.artifact.namespace,
405
+ raw: s.key, version: s.targetVersion,
406
+ }, { allowYanked: camel.allowYanked, pre: camel.pre });
407
+ if (rec.status === 'yanked') out.warn(`🔴 ${rec.id} 已被 yank,仍按 --allow-yanked 更新(会写进账本)`);
408
+ if (rec.status === 'deprecated') out.warn(`${rec.id} 的状态是 deprecated`);
409
+ // §4 第 3 步的 client 门:点名的东西装不上是**硬错误**
410
+ if (rec.clients.length > 0 && !rec.clients.includes(t.client)) {
411
+ throw new ConflictError(
412
+ `${rec.id} 未声明支持 client=${t.client}(声明的是 ${rec.clients.join(', ') || '(空)'})`,
413
+ { telemetryReason: 'unsupported-client' },
414
+ );
415
+ }
416
+ const newKey = rk.kind === 'pack' ? rec.id : `direct:${rec.id}`;
417
+ const units = rk.kind === 'pack'
418
+ ? withPackErrors(() => resolvePackInstall({
419
+ client: null, intent: camel, lookup: (id) => byId.get(id),
420
+ manifest: packManifests.get(rec.id), packRecord: rec,
421
+ })).install.map((m) => ({ record: m.record }))
422
+ : [{ record: rec }];
423
+ resolutions.push({
424
+ newKey,
425
+ oldKey: s.key,
426
+ rootRecord: {
427
+ artifact: rec.id, intent: snake, kind: rk.kind,
428
+ requested_at: newKey === s.key ? s.record.requested_at : at,
429
+ snapshot: snap.snapshot, tree_digest: rec.tree_digest,
430
+ },
431
+ units,
432
+ });
433
+ }
434
+
435
+ const g = buildPostGraph({ L, resolutions, selected });
436
+
437
+ // conflicts:post-state 里全部 pack root 的 manifest 一起判
438
+ const postPackIds = new Set(Object.entries(g.writeRoots)
439
+ .filter(([k]) => parseRootKey(k).kind === 'pack').map(([, r]) => r.artifact));
440
+ for (const [key, r] of Object.entries(L.roots)) {
441
+ if (g.removeRoots.includes(key) || Object.hasOwn(g.writeRoots, key)) continue;
442
+ if (parseRootKey(key).kind === 'pack') postPackIds.add(r.artifact);
443
+ }
444
+ assertNoConflicts(
445
+ [...postPackIds].sort().map((id) => ({ manifest: packManifests.get(id), record: packWanted.get(id) })),
446
+ g.artifacts, t.target,
447
+ );
448
+
449
+ // 🔴 要退役(= 会被删掉)的目录必须**逐字节**还是账本声称的那棵树。
450
+ // 只查「在不在」的话,`derivePlan` 的 retire-only 会对当前磁盘内容重算
451
+ // `old_digest`,于是被外部改过的目录照样归档删除、命令还返回 0
452
+ // (Codex 2026-09-04 P0)。顺带把「目录缺席」落成 2 而不是内核的 5。
453
+ for (const n of g.retire) assertEntryTreeIntact(t.target, n, L.entries[n].tree_digest, t.target);
454
+
455
+ previews.push({ ...w, fingerprint: fingerprintOf(g), g, resolutions });
456
+ }
457
+
458
+ // ── §8.2 遮蔽:项目级下**新增**的名字才查(既有的在装它的时候查过了)──────
459
+ const shadowInfo = new Map();
460
+ if (ctx.scope === 'project') {
461
+ for (const { g, L, t } of previews) {
462
+ const fresh = g.changed.filter((n) => !Object.hasOwn(L.entries, n));
463
+ const d = detectShadowed(fresh, { client: t.client, env: ctx.env, home: ctx.home });
464
+ shadowInfo.set(t.client, d);
465
+ if (d.shadowed.length && !ctx.shadowGlobal) {
466
+ throw new ConflictError(
467
+ `全局已存在同名 skill:${d.shadowed.join(', ')}(在 ${d.globalTarget})。\n`
468
+ + ' 本次 update 会在项目级**新增**它们,而项目级/全局的优先级未知(04-install.md §8.2 / D4)。\n'
469
+ + ' 明确接受这个歧义请给 --shadow-global。',
470
+ { telemetryReason: 'version-conflict' },
471
+ );
472
+ }
473
+ }
474
+ }
475
+
476
+ // ── diff + 确认 ─────────────────────────────────────────────────────────
477
+ const diffs = previews.map(({ g, L, t }) => ({ diff: describeDiff(g, L), g, t }));
478
+ const lines = [`update(快照 ${snap.snapshot}):`];
479
+ for (const { diff, t } of diffs) {
480
+ lines.push(` ${t.client}/${t.scope} ${t.target}`);
481
+ if (diff.rows.length === 0) lines.push(' (无变化)');
482
+ for (const r of diff.rows) lines.push(` ${r}`);
483
+ }
484
+ const touched = diffs.filter((d) => d.diff.rows.length > 0);
485
+ if (touched.length === 0) {
486
+ for (const l of lines) out.line(l);
487
+ out.line('update:全部 root 都已经是当前快照解析出来的结果,什么都不用做。');
488
+ return out.emit('update', {
489
+ skipped: skippedRows.map((s) => ({ client: s.client, reason: s.reason, scope: s.scope })),
490
+ snapshot: snap.snapshot,
491
+ targets: diffs.map(({ diff, t }) => ({
492
+ annotations: annotations({ offline: ctx.offline, stale }),
493
+ changed: false, client: t.client, diff, ok: true, scope: t.scope, target: t.target,
494
+ })),
495
+ }, EXIT.OK);
496
+ }
497
+
498
+ // 🔴 `all@snapshot` root 推到当前快照 = **当前全量集合**,必须过 09-cli.md §3
499
+ // 的强确认(非交互下 `--yes-i-really-want-everything`,`--yes` 不够)。
500
+ // 旧 root 绑的是**那张旧快照**里的名单,不是「对未来新增的任意 skill 的永久授权」
501
+ // (Codex 2026-09-04 P1)。
502
+ // 🔴 判据是「这条 all root **真的动了**」,不是「这个 target 上有 all root」
503
+ // (Codex 2026-09-04 P2-2)。快照没变时它解析出来的名单逐字节相同、
504
+ // 不可能扩张 —— 那时再要一次全量确认只是白让人多敲一遍。
505
+ const allRootTargets = previews.filter(
506
+ (p) => p.resolutions.some((r) => r.isAllRoot && r.newKey !== r.oldKey));
507
+ if (allRootTargets.length) {
508
+ const union = new Map();
509
+ for (const p of allRootTargets) {
510
+ for (const r of p.resolutions) if (r.isAllRoot) for (const u of r.units) union.set(u.record.id, u.record);
511
+ }
512
+ const list = [...union.values()].sort((a, b) => Buffer.compare(Buffer.from(a.id), Buffer.from(b.id)));
513
+ out.warn(`本次会把 ${allRootTargets.map((p) => p.t.client).join(', ')} 的 \`all@snapshot\` root `
514
+ + `推到快照 ${snap.snapshot} 的**当前全量集合** —— 走 09-cli.md §3 的全量确认。`);
515
+ await confirmAll(ctx, out, list);
516
+ }
517
+ await confirmYes(ctx, out, { lines, question: `确认应用以上更新(${touched.length} 个 target)?` });
518
+
519
+ // ── 第二波:真正会落盘的字节。🔴 也必须在取锁之前 ────────────────────────
520
+ // 「需要字节」= 目录缺席,或磁盘上那棵树已经不是目标 `tree_digest`。
521
+ // 完全相同的那些走 `adopt_assertions`(ledger-only),一个字节都不用取。
522
+ for (const p of previews) p.needBytes = namesNeedingBytes(p.t.target, p.g);
523
+ const unitRecords = new Map();
524
+ for (const p of previews) for (const n of p.needBytes) unitRecords.set(p.g.records.get(n).id, p.g.records.get(n));
525
+ await preheatAssetsFor(ctx, [...unitRecords.values()], snap);
526
+
527
+ // ── 项目级 lockfile:提交之前先把要用到的历史快照读回来并验签 ─────────────
528
+ let hook;
529
+ if (ctx.scope === 'project') {
530
+ const needs = [];
531
+ for (const p of previews) {
532
+ for (const n of p.g.changed) {
533
+ needs.push({ artifact: p.g.artifacts.get(n), snapshot: p.g.records.has(n) ? snap.snapshot : p.L.entries[n].snapshot });
534
+ }
535
+ }
536
+ hook = await prewarmLockfileInputs(ctx, {
537
+ current: snap, needs, ours: previews.map((p2) => p2.t.target), out, verifier,
538
+ });
539
+ }
540
+
541
+ // ── 取锁:repo → target(全序)──────────────────────────────────────────
542
+ const results = [];
543
+ const t0 = Date.now();
544
+ withOrderedLocks(
545
+ {
546
+ baseFor: (path) => tplan.selected.find((t) => t.target === path)?.base ?? null,
547
+ projectRoot: ctx.scope === 'project' ? ctx.projectRoot : null,
548
+ targets: previews.map((p) => p.t.target),
549
+ },
550
+ ({ targets: ordered }) => {
551
+ const byPath = new Map(previews.map((p) => [p.t.target, p]));
552
+ for (const o of ordered) {
553
+ const p = byPath.get(o.path);
554
+ const started = Date.now();
555
+ try {
556
+ const r = updateOneTarget(ctx, p, { at, floor, hook, out, snap });
557
+ results.push({ ...r, client: p.t.client, ms: Date.now() - started, ok: true, scope: p.t.scope, target: p.t.target });
558
+ } catch (err) {
559
+ const cls = classify(err);
560
+ results.push({
561
+ _err: err, client: p.t.client, error: err.message, exit_code: cls.code,
562
+ ms: Date.now() - started, ok: false, reason: cls.reason, scope: p.t.scope, target: p.t.target,
563
+ });
564
+ }
565
+ }
566
+ },
567
+ );
568
+
569
+ emitTelemetry(ctx, results, previews);
570
+
571
+ // ── §7:逐 target 结果表 ─────────────────────────────────────────────────
572
+ out.line(`update 结果(快照 ${snap.snapshot},共 ${results.length} 个 target):`);
573
+ for (const r of results) {
574
+ const p = previews.find((x) => x.t.target === r.target);
575
+ // 🔴 `degraded` **要真的算**,不能写死 false(§7:stale / offline / yanked /
576
+ // degraded / shadowed 必须在每一次相关输出里重复标注)。写死等于「这道标注
577
+ // 看起来在,实际上永远不亮」。
578
+ // 覆盖面是 post-state 的**两类**:物化下来的 entry(记录在 g.records),
579
+ // 以及 root artifact —— 🔴 `degraded` 是 **pack** 的状态,而 pack 根本不
580
+ // 作为目录物化,只看 entry 的话恰好把它漏光(04-install.md §8.1「当前状态门」
581
+ // 的覆盖面是同一条道理)。
582
+ const a = annotations({
583
+ degraded: p ? statusHit(p, byId, 'degraded') : false,
584
+ offline: ctx.offline,
585
+ shadowed: (shadowInfo.get(r.client)?.shadowed.length ?? 0) > 0,
586
+ stale,
587
+ yanked: p ? statusHit(p, byId, 'yanked') : false,
588
+ });
589
+ r.annotations = a;
590
+ out.line(` ${r.ok ? 'ok ' : 'failed '}${r.client}/${r.scope} ${r.target}${annotationSuffix(a)}`);
591
+ if (!r.ok) out.line(` ${r.error.split('\n')[0]}`);
592
+ else out.line(` 第 ${r.generation} 代,改了 ${r.changed.join(', ') || '(无)'};退役 ${r.retired.join(', ') || '(无)'}`);
593
+ }
594
+
595
+ const failed = results.filter((r) => !r.ok);
596
+ let exit = EXIT.OK;
597
+ if (failed.length === results.length && failed.length > 0) exit = classify(failed[0]._err).code;
598
+ else if (failed.length > 0) exit = EXIT.PARTIAL;
599
+
600
+ const body = {
601
+ duration_ms: Date.now() - t0,
602
+ skipped: skippedRows.map((s) => ({ client: s.client, reason: s.reason, scope: s.scope })),
603
+ snapshot: snap.snapshot,
604
+ targets: results.map((r) => {
605
+ const p = previews.find((x) => x.t.target === r.target);
606
+ return {
607
+ annotations: r.annotations,
608
+ changed: r.ok ? r.changed : undefined,
609
+ client: r.client,
610
+ diff: p ? describeDiff(p.g, p.L) : undefined,
611
+ error: r.ok ? undefined : r.error,
612
+ exit_code: r.ok ? 0 : r.exit_code,
613
+ generation: r.ok ? r.generation : undefined,
614
+ ok: r.ok,
615
+ retired: r.ok ? r.retired : undefined,
616
+ scope: r.scope,
617
+ target: r.target,
618
+ };
619
+ }),
620
+ };
621
+ if (exit !== EXIT.OK && exit !== EXIT.PARTIAL) {
622
+ return out.emitError('update', classify(failed[0]._err), failed[0]._err, body);
623
+ }
624
+ return out.emit('update', body, exit);
625
+ }
626
+
627
+ // ════════════════════════════════════════════════════════════════════════════
628
+ // 单 target
629
+ // ════════════════════════════════════════════════════════════════════════════
630
+
631
+ function fingerprintOf(g) {
632
+ return graphFingerprint({
633
+ artifacts: g.artifacts, postEdges: g.postEdges, removeRoots: g.removeRoots,
634
+ retire: g.retire, writeRoots: g.writeRoots,
635
+ });
636
+ }
637
+
638
+ /** 哪些名字**真的**需要新字节:目录缺席,或磁盘上那棵树已经不是目标 `tree_digest`。 */
639
+ export function namesNeedingBytes(target, g) {
640
+ return g.changed.filter((n) => {
641
+ const rec = g.records.get(n);
642
+ if (rec === undefined) return false; // 只改了边,没换制品
643
+ return !entryStillMatches(target, n, rec.tree_digest).ok;
644
+ }).sort();
645
+ }
646
+
647
+ /** 单个 target 的第 2–10 步。🔴 全同步 —— 它在锁与事务里面,不能 await。 */
648
+ function updateOneTarget(ctx, p, { at, floor, hook, out, snap }) {
649
+ const target = p.t.target;
650
+ const P0 = layout(target);
651
+
652
+ // ── 第 2 步:残留事务分流 ────────────────────────────────────────────────
653
+ const rec = recover(target, { keepGenerations: ctx.keepGenerations, mode: 'auto', onLedgerChanged: hook });
654
+ if (rec.outcome !== 'nothing') out.note(`${p.t.client}:入口分流 —— ${rec.outcome}`);
655
+
656
+ // ── 第 3 步:预检 ───────────────────────────────────────────────────────
657
+ assertPrecheckOk(precheckTarget(target, { base: p.t.base, targetSet: [target] }));
658
+
659
+ // 🔴 **锁内重读重算,比语义指纹**。预览是在没有任何锁的时候读的;
660
+ // 从那时到现在,另一个进程(乃至上面那次 recover)完全可以改掉这张图。
661
+ const L = readLedger(P0.ledger);
662
+ assertLedgerGraphUsable(L, `${target}/.geoly/ledger.json`);
663
+ // 🔴 **不能把「已经不在账本里的 root」过滤掉再继续**(自查时抓到的缝):
664
+ // 那样 `selectedOld` 变小 → 它的边本来就已经没了 → postEdges 不变,
665
+ // 而 `resolutions` 仍然带着那条 root 的解析结果 → `writeRoots` 把它**重新建回来**,
666
+ // 指纹却**恰好相等**。净效果是:别人刚 remove 掉的东西被这次 update 悄悄装回去。
667
+ // 判据必须是「本次选中的每一条 root 现在都还在」,缺一条就中止。
668
+ const gone = p.selected.filter((s) => !Object.hasOwn(L.roots, s.key)).map((s) => s.key);
669
+ if (gone.length) {
670
+ throw new UsageError(
671
+ `${target}:本次要更新的 root 在确认之后已经不在账本里了(${gone.join(', ')})。`
672
+ + '什么都没做 —— 请重跑 update。',
673
+ { telemetryReason: 'version-conflict' },
674
+ );
675
+ }
676
+ const g = buildPostGraph({ L, resolutions: p.resolutions, selected: p.selected });
677
+ if (fingerprintOf(g) !== p.fingerprint) {
678
+ throw new UsageError(
679
+ `${target}:账本在确认之后被改动了(引用图与你看到的那份不一致)。什么都没做 —— 请重跑 update。`,
680
+ { telemetryReason: 'version-conflict' },
681
+ );
682
+ }
683
+
684
+ // 🔴 没准备字节的那些,必须**现在**仍然与目标摘要严格相符。
685
+ // 不查的话,磁盘在确认期间被改过时 `derivePlan` 会构造一个物理 swap,
686
+ // 然后 `stageTrees` 报「没有可 stage 的源目录」—— 一条看不出所以然的内核错。
687
+ const need = new Set(p.needBytes);
688
+ for (const n of g.changed) {
689
+ if (need.has(n)) continue;
690
+ const digest = g.records.has(n) ? g.records.get(n).tree_digest : L.entries[n].tree_digest;
691
+ const m = entryStillMatches(target, n, digest);
692
+ if (!m.ok) {
693
+ throw new UsageError(
694
+ `${target}/${n} 在确认之后变了(${m.why}):本次没有为它准备字节,无法继续。请重跑 update。`,
695
+ { telemetryReason: 'digest-mismatch' },
696
+ );
697
+ }
698
+ }
699
+ for (const n of g.retire) assertEntryTreeIntact(target, n, L.entries[n].tree_digest, target);
700
+
701
+ // 🔴 §4.2:新增的名字撞上一个**未被账本认领**的同名目录。
702
+ // 不先判的话 `derivePlan` 会 `bad()` → `Corrupt` → 落**退出码 5(需要 recover)**,
703
+ // 而这在语义上是**冲突未解决(3)**:没有任何残留事务,recover 无事可做。
704
+ // `commands/install.mjs` 在同一格做的是同一件事(也是同一条理由)。
705
+ // ⚠️ update **不接** `--replace`:那条 flag 的语义是「点名替换未认领目录」,
706
+ // 而一次升级里冒出来的新成员并不是用户点名过的东西。
707
+ for (const n of g.changed) {
708
+ if (Object.hasOwn(L.entries, n)) continue; // 已认领的不在这一格
709
+ if (!existsSync(join(target, n))) continue;
710
+ throw new ConflictError(
711
+ `${join(target, n)} 是一个**未被账本认领**的同名目录,而本次 update 要往那里装 `
712
+ + `${g.artifacts.get(n)}:默认阻断(04-install.md §4.2)。\n`
713
+ + ' 🔴 update 不提供 --replace 出路(那条 flag 是「点名替换」,'
714
+ + '而这个目录是升级过程中冒出来的新成员撞上的,你并没有点过它的名)。\n'
715
+ + ' 请自行把它移走,或先 `install` 那个制品并用 `--replace` 明确点名。',
716
+ { telemetryReason: 'version-conflict' },
717
+ );
718
+ }
719
+
720
+ // ── 第 4 步:取字节 → 验签/验资产/解包/manifest 绑定 ────────────────────
721
+ const items = p.needBytes.map((n) => ({ name: n, record: g.records.get(n) }))
722
+ .map((x) => ({ ...x, bytes: ctx.registry.fetchAsset(x.record) }));
723
+
724
+ return withVerifiedArtifacts(items, join(target, STATE_DIR), (verified) => {
725
+ assertDiskSpace(target, verified.reduce((n, v) => n + treeBytes(v.art.dir), 0));
726
+ const srcOf = new Map(verified.map((v) => [v.name, v.art.dir]));
727
+
728
+ ensureGenerationWatermark(P0);
729
+ const generation = nextGeneration(P0);
730
+
731
+ const install = g.changed.map((n) => {
732
+ const record = g.records.get(n);
733
+ const old = L.entries[n];
734
+ return {
735
+ artifact: g.artifacts.get(n),
736
+ installed_at: at,
737
+ name: n,
738
+ requested_by: g.postEdges.get(n),
739
+ // 制品换了 ⇒ 它是从**当前**快照解析出来的;只改了边 ⇒ 沿用原来的解析出处
740
+ snapshot: record ? snap.snapshot : old.snapshot,
741
+ srcDir: srcOf.get(n),
742
+ tree_digest: record ? record.tree_digest : old.tree_digest,
743
+ };
744
+ });
745
+
746
+ const plan = derivePlan({
747
+ generation,
748
+ install,
749
+ ledger: L,
750
+ ledgerExisted: true,
751
+ removeRoots: g.removeRoots,
752
+ replace: new Set(),
753
+ retire: g.retire,
754
+ roots: g.writeRoots,
755
+ target,
756
+ });
757
+
758
+ // 🔴 R-9:提交点之前的最后一刻复验 trust floor。`floor` 必须**显式**给。
759
+ runTransaction(target, plan, {
760
+ floor: floor === null ? null : { expected: floor, stateDir: ctx.stateDir },
761
+ keepGenerations: ctx.keepGenerations,
762
+ now: at,
763
+ onLedgerChanged: hook,
764
+ });
765
+ return { changed: g.changed, generation, retired: g.retire, rootDiff: g.rootDiff };
766
+ });
767
+ }
768
+
769
+ // ════════════════════════════════════════════════════════════════════════════
770
+ // diff 的人类/机器表示
771
+ // ════════════════════════════════════════════════════════════════════════════
772
+
773
+ /**
774
+ * post-state 里有没有落在某个状态上的制品。
775
+ *
776
+ * 🔴 两类都要看:**entry**(会物化成目录)与 **root artifact**(pack 不物化,
777
+ * 而 `degraded` 恰恰只属于 pack)。只看其中一类,那道标注就永远不亮。
778
+ */
779
+ export function statusHit(p, byId, status) {
780
+ for (const rec of p.g.records.values()) if (rec.status === status) return true;
781
+ for (const r of Object.values(p.g.writeRoots)) {
782
+ if (r.artifact && byId.get(r.artifact)?.status === status) return true;
783
+ }
784
+ for (const [key, r] of Object.entries(p.L.roots ?? {})) {
785
+ if (p.g.removeRoots.includes(key) || Object.hasOwn(p.g.writeRoots, key)) continue;
786
+ if (r.artifact && byId.get(r.artifact)?.status === status) return true;
787
+ }
788
+ return false;
789
+ }
790
+
791
+ export function describeDiff(g, L) {
792
+ const added = [], changed = [], edges = [], removed = [];
793
+ for (const n of g.changed) {
794
+ const old = L.entries?.[n];
795
+ if (!old) { added.push({ artifact: g.artifacts.get(n), name: n }); continue; }
796
+ if (old.artifact !== g.artifacts.get(n)) {
797
+ changed.push({ from: old.artifact, name: n, to: g.artifacts.get(n) });
798
+ continue;
799
+ }
800
+ edges.push({ from: old.requested_by, name: n, to: g.postEdges.get(n) });
801
+ }
802
+ for (const n of g.retire) removed.push({ artifact: L.entries[n].artifact, name: n });
803
+
804
+ const rows = [];
805
+ for (const r of g.rootDiff) {
806
+ if (r.from !== r.to) rows.push(`root ${r.from} → ${r.to}`);
807
+ else if (r.intent_changed) rows.push(`intent ${r.from}(root 不变,安装意图变了)`);
808
+ }
809
+ for (const x of changed) rows.push(`changed ${x.name.padEnd(28)}${x.from} → ${x.to}`);
810
+ for (const x of added) rows.push(`added ${x.name.padEnd(28)}${x.artifact}`);
811
+ for (const x of removed) rows.push(`removed ${x.name.padEnd(28)}${x.artifact}(目录会被退役,旧树进 attic)`);
812
+ for (const x of edges) rows.push(`edges ${x.name.padEnd(28)}${x.from.join(',')} → ${x.to.join(',')}`);
813
+ for (const k of g.removeRoots) rows.push(`root-gone ${k}(事务后没有任何 entry 指向它)`);
814
+ return { added, changed, edges, removed, root_changes: g.rootDiff, rows };
815
+ }
816
+
817
+ function emitTelemetry(ctx, results, previews) {
818
+ const rec = ctx.record;
819
+ if (!rec) return;
820
+ for (const r of results) {
821
+ const p = previews.find((x) => x.t.target === r.target);
822
+ const recs = [...(p?.g.records.values() ?? [])];
823
+ if (recs.length === 0) {
824
+ rec({ client: r.client, kind: 'update', ms: r.ms, reason: r.ok ? undefined : r.reason, result: r.ok ? 'ok' : 'failed', scope: r.scope });
825
+ continue;
826
+ }
827
+ for (const a of recs) {
828
+ rec({
829
+ artifact: a.id, client: r.client, kind: 'update', ms: r.ms,
830
+ reason: r.ok ? undefined : r.reason, result: r.ok ? 'ok' : 'failed',
831
+ scope: r.scope, version: a.version,
832
+ });
833
+ }
834
+ }
835
+ }