@yottameta/yotta-skills-plugin 0.25.1 → 0.27.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.
@@ -21,6 +21,20 @@ const STATE_FILE = '.yotta-hub.json';
21
21
  const LINKS_FILE = '.yotta-links.json';
22
22
  const AUDIT_FILE = '.yotta-hub-audit.jsonl';
23
23
  const SKILL_FILE = 'SKILL.md';
24
+ const TRASH_RETENTION_DAYS = 7;
25
+
26
+ /**
27
+ * Hub 家族范围 = 全家清单(skills.json,27)+ 特殊家族(5)。
28
+ * 特殊家族不进一次性安装清单;仅参与 Hub 安装 / 更新 / 收敛 / 体检,
29
+ * 版本跟随各自 npm latest(由安装管线在运行时解析)。
30
+ */
31
+ const HUB_FAMILY_EXTRAS = [
32
+ { slug: 'yotta-dev-mcp', name: '元开', pkg: '@yottameta/yotta-dev-mcp', version: 'latest' },
33
+ { slug: 'yotta-partner', name: '元伴', pkg: '@yottameta/yotta-partner', version: 'latest' },
34
+ { slug: 'yotta-present', name: '元呈', pkg: '@yottameta/yotta-present', version: 'latest' },
35
+ { slug: 'yotta-skills', name: '元阁', pkg: '@yottameta/yotta-skills', version: 'latest' },
36
+ { slug: 'yotta-verify-mcp', name: '元信MCP', pkg: '@yottameta/yotta-verify-mcp', version: 'latest' },
37
+ ];
24
38
 
25
39
  const HASH_SKIP = new Set([
26
40
  '.git', 'node_modules', '__pycache__', '.pytest_cache', '.mypy_cache',
@@ -226,10 +240,141 @@ function manifestSlugs(manifest) {
226
240
  return set;
227
241
  }
228
242
 
243
+ /** 家族 slug 集合:接受清单数组 / Set / {skills} 结构,并始终并入特殊家族 5 个。 */
244
+ function familySlugSet(manifest) {
245
+ const set = new Set();
246
+ if (manifest instanceof Set) {
247
+ for (const slug of manifest) set.add(String(slug));
248
+ } else {
249
+ const list = Array.isArray(manifest) ? manifest : (manifest && manifest.skills) || [];
250
+ for (const item of list) {
251
+ if (typeof item === 'string') set.add(item);
252
+ else if (item && item.slug) set.add(item.slug);
253
+ }
254
+ }
255
+ for (const item of HUB_FAMILY_EXTRAS) set.add(item.slug);
256
+ return set;
257
+ }
258
+
259
+ function compareVersions(left, right) {
260
+ return scanLib.compareSemver(left, right);
261
+ }
262
+
263
+ function readSkillMeta(dir) {
264
+ try {
265
+ const text = fs.readFileSync(path.join(dir, SKILL_FILE), 'utf8');
266
+ const fm = scanLib.parseFrontmatter(text) || {};
267
+ return {
268
+ name: String(fm.name || '').trim(),
269
+ version: fm.version ? String(fm.version).trim() : '',
270
+ };
271
+ } catch (_) {
272
+ return null;
273
+ }
274
+ }
275
+
276
+ function resolveTrashRoot(hubDir, opts) {
277
+ const options = opts || {};
278
+ const explicit = options.trashRoot || options.trashDir || process.env.YOTTA_SKILLS_TRASH;
279
+ if (explicit) return path.resolve(explicit);
280
+ return path.join(path.dirname(path.resolve(hubDir)), 'trash');
281
+ }
282
+
283
+ function trashRunStamp(now) {
284
+ const d = now instanceof Date ? now : new Date();
285
+ const pad = (value) => String(value).padStart(2, '0');
286
+ return d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + '-' +
287
+ pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds()) + '-' + process.pid;
288
+ }
289
+
290
+ function safeHostName(value) {
291
+ const cleaned = String(value || '')
292
+ .replace(/[\\/:*?"<>|\s]+/g, '-')
293
+ .replace(/^-+|-+$/g, '');
294
+ return cleaned || 'host';
295
+ }
296
+
297
+ /** 清理回收站中超过保留期的条目(best-effort;返回清理数量)。 */
298
+ function pruneTrash(trashRoot, options) {
299
+ const opts = options || {};
300
+ const days = Number(opts.days) > 0 ? Number(opts.days) : TRASH_RETENTION_DAYS;
301
+ const now = opts.now instanceof Date ? opts.now.getTime() : Date.now();
302
+ let pruned = 0;
303
+ let entries;
304
+ try {
305
+ entries = fs.readdirSync(trashRoot, { withFileTypes: true });
306
+ } catch (_) {
307
+ return 0;
308
+ }
309
+ for (const entry of entries) {
310
+ if (!entry.isDirectory()) continue;
311
+ const full = path.join(trashRoot, entry.name);
312
+ let stat;
313
+ try {
314
+ stat = fs.statSync(full);
315
+ } catch (_) {
316
+ continue;
317
+ }
318
+ if (now - stat.mtimeMs > days * 24 * 60 * 60 * 1000) {
319
+ try {
320
+ fs.rmSync(full, { recursive: true, force: true });
321
+ pruned += 1;
322
+ } catch (_) { /* keep for next run */ }
323
+ }
324
+ }
325
+ return pruned;
326
+ }
327
+
328
+ /**
329
+ * 列出宿主目录中某个家族技能的副本:
330
+ * - 精确同名(slug)的真目录 / 链接;
331
+ * - `slug__*` 前缀命名、frontmatter name === slug 的重命名副本。
332
+ */
333
+ function listFamilyCopies(targetDir, slug) {
334
+ const copies = [];
335
+ let entries;
336
+ try {
337
+ entries = fs.readdirSync(targetDir, { withFileTypes: true });
338
+ } catch (_) {
339
+ return copies;
340
+ }
341
+ for (const entry of entries) {
342
+ if (entry.name.startsWith('.')) continue;
343
+ const isExact = entry.name === slug;
344
+ const isRenamed = entry.name.startsWith(slug + '__');
345
+ if (!isExact && !isRenamed) continue;
346
+ const full = path.join(targetDir, entry.name);
347
+ let stat;
348
+ try {
349
+ stat = fs.lstatSync(full);
350
+ } catch (_) {
351
+ continue;
352
+ }
353
+ if (stat.isSymbolicLink()) {
354
+ copies.push({ name: entry.name, dir: full, kind: 'link', version: '' });
355
+ continue;
356
+ }
357
+ if (!stat.isDirectory()) {
358
+ if (isExact) copies.push({ name: entry.name, dir: full, kind: 'file', version: '' });
359
+ continue;
360
+ }
361
+ const meta = readSkillMeta(full);
362
+ if (isRenamed && (!meta || meta.name !== slug)) continue;
363
+ copies.push({
364
+ name: entry.name,
365
+ dir: full,
366
+ kind: 'directory',
367
+ version: meta ? (meta.version || '') : '',
368
+ metaName: meta ? meta.name : '',
369
+ });
370
+ }
371
+ return copies;
372
+ }
373
+
229
374
  function syncHubState(hubDir, options) {
230
375
  const opts = options || {};
231
376
  const previous = readHubState(hubDir);
232
- const family = manifestSlugs(opts.manifest);
377
+ const family = familySlugSet(opts.manifest);
233
378
  const scanned = scanHubSkills(hubDir);
234
379
  const upstream = skillsCliLock.readLock({ homeDir: opts.homeDir, env: opts.env });
235
380
  const now = nowIso();
@@ -300,11 +445,30 @@ function classifyTarget(target, hubDir) {
300
445
  return { kind: 'missing' };
301
446
  }
302
447
  if (stat.isSymbolicLink()) {
303
- const real = safeRealpath(target);
448
+ let real = null;
449
+ let targetExists = false;
450
+ try {
451
+ real = fs.realpathSync.native ? fs.realpathSync.native(target) : fs.realpathSync(target);
452
+ targetExists = true;
453
+ } catch (_) {
454
+ try {
455
+ real = fs.realpathSync(target);
456
+ targetExists = true;
457
+ } catch (_) { /* broken link: fall back to the recorded link value */ }
458
+ }
459
+ if (!real) {
460
+ // A broken junction / symlink no longer resolves. Read the link value so
461
+ // an unlink can still tell whether it points into the hub (fail-closed
462
+ // cleanup path) without treating it as an arbitrary directory.
463
+ let linkValue = null;
464
+ try { linkValue = fs.readlinkSync(target); } catch (_) { linkValue = null; }
465
+ real = linkValue ? path.resolve(path.dirname(path.resolve(target)), linkValue) : path.resolve(target);
466
+ }
304
467
  return {
305
468
  kind: 'link',
306
469
  target: real,
307
470
  inHub: isInside(hubDir, real),
471
+ targetExists,
308
472
  };
309
473
  }
310
474
  if (stat.isDirectory()) return { kind: 'directory' };
@@ -321,8 +485,20 @@ function linkSkills(options) {
321
485
  ? opts.slugs
322
486
  : scanHubSkills(hubDir).map((item) => item.slug);
323
487
  const hubBySlug = new Map(scanHubSkills(hubDir).map((item) => [item.slug, item]));
488
+ const family = familySlugSet(opts.familySlugs || opts.manifest);
489
+ const familyFallback = !opts.familySlugs && !opts.manifest;
324
490
  const links = readLinkState(hubDir).links.slice();
325
491
  const results = [];
492
+ const trashRoot = resolveTrashRoot(hubDir, opts);
493
+ const makeLink = typeof opts.createLink === 'function' ? opts.createLink : createDirLink;
494
+ const stamp = trashRunStamp(opts.now);
495
+ const hostName = safeHostName(opts.label || path.basename(targetDir));
496
+ let prunedTrash = 0;
497
+ if (!dryRun) {
498
+ try {
499
+ prunedTrash = pruneTrash(trashRoot, { days: opts.trashRetentionDays });
500
+ } catch (_) { prunedTrash = 0; }
501
+ }
326
502
 
327
503
  for (const slug of slugs) {
328
504
  const skill = hubBySlug.get(slug);
@@ -332,8 +508,200 @@ function linkSkills(options) {
332
508
  }
333
509
  const target = path.join(targetDir, slug);
334
510
  const current = classifyTarget(target, hubDir);
511
+ const isFamily = family.has(slug) || (familyFallback && slug.startsWith('yotta-'));
512
+
513
+ // ── 家族技能:链接时唯一性收敛(产品口径) ──────────────────────────
514
+ if (isFamily) {
515
+ const copies = listFamilyCopies(targetDir, slug);
516
+ const exactCopy = copies.find((copy) => copy.name === slug) || null;
517
+ const renamedCopies = copies.filter((copy) => copy.name !== slug);
518
+ const exactHealthy = current.kind === 'link' && current.inHub &&
519
+ current.targetExists !== false && samePath(current.target, skill.dir);
520
+ const moves = [];
521
+ const skipped = [];
522
+ let block = null;
523
+
524
+ const evaluate = (copy, isExact) => {
525
+ if (!copy) return;
526
+ const cls = classifyTarget(copy.dir, hubDir);
527
+ if (cls.kind === 'link') {
528
+ const pointsAtHub = cls.inHub && cls.targetExists !== false && samePath(cls.target, skill.dir);
529
+ if (pointsAtHub && isExact) return; // 已是目标链接,保留
530
+ if (cls.inHub) {
531
+ moves.push({ name: copy.name, dir: copy.dir, version: copy.version || '' });
532
+ return;
533
+ }
534
+ if (isExact) {
535
+ block = { status: 'conflict', note: '目标已存在指向 Hub 之外的链接(默认跳过;确认后可加 --force 替换)' };
536
+ } else {
537
+ skipped.push(copy.name + '(外部链接,保留)');
538
+ }
539
+ return;
540
+ }
541
+ if (cls.kind === 'directory') {
542
+ const meta = readSkillMeta(copy.dir);
543
+ if (!meta || meta.name !== slug) {
544
+ if (isExact) block = { status: 'conflict', note: '目标已存在真目录(frontmatter name 与技能不符,拒绝收敛)' };
545
+ else skipped.push(copy.name + '(name 不符,保留)');
546
+ return;
547
+ }
548
+ const version = meta.version || '';
549
+ if (!skill.version) {
550
+ block = { status: 'skipped', note: 'Hub 副本版本无法解析;跳过收敛(fail-safe)' };
551
+ return;
552
+ }
553
+ if (!version) {
554
+ block = { status: 'skipped', note: copy.name + ' 版本无法解析;跳过收敛(fail-safe)' };
555
+ return;
556
+ }
557
+ if (compareVersions(version, skill.version) > 0) {
558
+ block = {
559
+ status: 'skipped',
560
+ note: copy.name + ' v' + version + ' 高于 Hub v' + skill.version + ';先运行 yotta-skills hub update 再链接',
561
+ };
562
+ return;
563
+ }
564
+ moves.push({ name: copy.name, dir: copy.dir, version });
565
+ return;
566
+ }
567
+ if (cls.kind === 'file') {
568
+ if (isExact) block = { status: 'conflict', note: '目标已存在普通文件(拒绝覆盖)' };
569
+ }
570
+ };
571
+ evaluate(exactCopy, true);
572
+ for (const copy of renamedCopies) evaluate(copy, false);
573
+
574
+ if (block) {
575
+ if (exactHealthy) {
576
+ results.push({ slug, status: 'linked', target, note: '已链接到 hub;仍有未收敛副本:' + block.note });
577
+ } else {
578
+ results.push({ slug, status: block.status, target, note: block.note });
579
+ }
580
+ continue;
581
+ }
582
+ if (moves.length === 0 && exactHealthy) {
583
+ results.push({
584
+ slug,
585
+ status: 'linked',
586
+ target,
587
+ note: '已链接到 hub' + (skipped.length ? '(保留:' + skipped.join('、') + ')' : ''),
588
+ });
589
+ continue;
590
+ }
591
+ if (moves.length > 0 && dryRun) {
592
+ results.push({
593
+ slug,
594
+ status: 'would-converge',
595
+ target,
596
+ note: '将收敛 ' + moves.length + ' 份旧副本(移入回收站,保留 ' + TRASH_RETENTION_DAYS + ' 天)后建立链接:' +
597
+ moves.map((item) => item.name).join('、'),
598
+ moves: moves.map((item) => ({ from: item.dir, name: item.name, version: item.version || null })),
599
+ trashDir: trashRoot,
600
+ });
601
+ continue;
602
+ }
603
+ if (moves.length === 0 && dryRun) {
604
+ results.push({ slug, status: 'would-link', target, note: '将建立链接' });
605
+ continue;
606
+ }
607
+ if (moves.length === 0) {
608
+ try {
609
+ makeLink(skill.dir, target);
610
+ const index = links.findIndex((item) => item.slug === slug && samePath(item.dir || '', targetDir));
611
+ const record = {
612
+ slug,
613
+ agent: opts.agentId || null,
614
+ label: opts.label || null,
615
+ dir: targetDir,
616
+ target,
617
+ hubDir: skill.dir,
618
+ linkedAt: nowIso(),
619
+ backup: null,
620
+ };
621
+ if (index >= 0) links[index] = record;
622
+ else links.push(record);
623
+ results.push({
624
+ slug,
625
+ status: 'linked',
626
+ target,
627
+ note: '已建立链接' + (skipped.length ? '(保留:' + skipped.join('、') + ')' : ''),
628
+ });
629
+ appendAudit(hubDir, { event: 'link', slug, target, hubDir: skill.dir, backup: null });
630
+ } catch (error) {
631
+ results.push({
632
+ slug,
633
+ status: 'error',
634
+ target,
635
+ note: error.message + '(未静默降级为复制;请检查目录权限或改用 --dir 指定可写目录)',
636
+ });
637
+ }
638
+ continue;
639
+ }
640
+ const moved = [];
641
+ try {
642
+ for (const item of moves) {
643
+ const dest = path.join(trashRoot, stamp, hostName, item.name);
644
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
645
+ fs.renameSync(item.dir, dest);
646
+ moved.push({ from: item.dir, to: dest, name: item.name, version: item.version || null });
647
+ }
648
+ if (!exactHealthy) makeLink(skill.dir, target);
649
+ const index = links.findIndex((item) => item.slug === slug && samePath(item.dir || '', targetDir));
650
+ const record = {
651
+ slug,
652
+ agent: opts.agentId || null,
653
+ label: opts.label || null,
654
+ dir: targetDir,
655
+ target,
656
+ hubDir: skill.dir,
657
+ linkedAt: nowIso(),
658
+ backup: null,
659
+ converged: moved.map((item) => item.to),
660
+ };
661
+ if (index >= 0) links[index] = record;
662
+ else links.push(record);
663
+ results.push({
664
+ slug,
665
+ status: 'linked',
666
+ target,
667
+ note: '已收敛 ' + moved.length + ' 份旧副本(回收站保留 ' + TRASH_RETENTION_DAYS + ' 天)并建立链接' +
668
+ (skipped.length ? ';保留:' + skipped.join('、') : ''),
669
+ moved,
670
+ trashDir: trashRoot,
671
+ });
672
+ appendAudit(hubDir, {
673
+ event: 'converge',
674
+ slug,
675
+ target,
676
+ hubDir: skill.dir,
677
+ hubVersion: skill.version,
678
+ moved,
679
+ trashDir: trashRoot,
680
+ });
681
+ } catch (error) {
682
+ let restored = true;
683
+ for (const item of moved.slice().reverse()) {
684
+ try {
685
+ fs.mkdirSync(path.dirname(item.from), { recursive: true });
686
+ fs.renameSync(item.to, item.from);
687
+ } catch (_) { restored = false; }
688
+ }
689
+ results.push({
690
+ slug,
691
+ status: 'error',
692
+ target,
693
+ restored,
694
+ note: '收敛失败:' + error.message +
695
+ (restored ? '(已恢复原目录)' : '(恢复失败,请查看回收站:' + trashRoot + ')'),
696
+ trashDir: trashRoot,
697
+ });
698
+ }
699
+ continue;
700
+ }
701
+
702
+ // ── 非家族技能:保持原行为(同名冲突默认跳过,不自动删) ────────────
335
703
  let backup = null;
336
- if (current.kind === 'link' && current.inHub && samePath(current.target, skill.dir)) {
704
+ if (current.kind === 'link' && current.inHub && current.targetExists !== false && samePath(current.target, skill.dir)) {
337
705
  results.push({ slug, status: 'linked', target, note: '已链接到 hub' });
338
706
  continue;
339
707
  }
@@ -374,7 +742,7 @@ function linkSkills(options) {
374
742
  continue;
375
743
  }
376
744
  try {
377
- createDirLink(skill.dir, target);
745
+ makeLink(skill.dir, target);
378
746
  const index = links.findIndex((item) => item.slug === slug && samePath(item.dir || '', targetDir));
379
747
  const record = {
380
748
  slug,
@@ -402,10 +770,10 @@ function linkSkills(options) {
402
770
  if (!dryRun) {
403
771
  writeLinkState(hubDir, { links });
404
772
  try {
405
- syncHubState(hubDir, { homeDir: opts.homeDir, env: opts.env });
773
+ syncHubState(hubDir, { manifest: opts.manifest, homeDir: opts.homeDir, env: opts.env });
406
774
  } catch (_) { /* link state is already durable; state refresh is best-effort */ }
407
775
  }
408
- return { hubDir, targetDir, dryRun, results };
776
+ return { hubDir, targetDir, dryRun, results, trashRoot, prunedTrash };
409
777
  }
410
778
 
411
779
  function unlinkSkills(options) {
@@ -462,9 +830,9 @@ function linkStatus(hubDir) {
462
830
  const current = classifyTarget(item.target, hubDir);
463
831
  return {
464
832
  ...item,
465
- status: current.kind === 'link' && current.inHub ? 'ok'
466
- : current.kind === 'missing' ? 'broken'
467
- : 'drift',
833
+ status: current.kind === 'link'
834
+ ? (!current.inHub ? 'drift' : (current.targetExists === false ? 'broken' : 'ok'))
835
+ : current.kind === 'missing' ? 'broken' : 'drift',
468
836
  actual: current,
469
837
  };
470
838
  });
@@ -495,6 +863,71 @@ function status(options) {
495
863
  };
496
864
  }
497
865
 
866
+ /**
867
+ * doctor 只读检查:宿主目录中的家族技能副本是否唯一 / 版本是否落后。
868
+ * 只报告不修改;真正的收敛发生在 hub link 执行时。
869
+ */
870
+ function singleSourceChecks(opts, add) {
871
+ const hubDir = path.resolve(opts.hubDir);
872
+ if (!fs.existsSync(hubDir)) return;
873
+ const family = familySlugSet(opts.manifest);
874
+ let discovery = opts.discovery;
875
+ if (!discovery) {
876
+ try {
877
+ discovery = require('./agent-discovery').discoverHosts({ homeDir: opts.homeDir, env: opts.env });
878
+ } catch (_) {
879
+ return;
880
+ }
881
+ }
882
+ const hubSkills = new Map(scanHubSkills(hubDir).map((item) => [item.slug, item]));
883
+ for (const host of discovery.hosts || []) {
884
+ if (!host || !host.exists) continue;
885
+ const groups = new Map();
886
+ let entries;
887
+ try {
888
+ entries = fs.readdirSync(host.dir, { withFileTypes: true });
889
+ } catch (_) {
890
+ continue;
891
+ }
892
+ for (const entry of entries) {
893
+ if (entry.name.startsWith('.')) continue;
894
+ if (!entry.name.startsWith('yotta-')) continue; // 家族 slug 统一前缀,避免读外部技能
895
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
896
+ const dir = path.join(host.dir, entry.name);
897
+ const meta = readSkillMeta(dir);
898
+ if (!meta || !meta.name || !family.has(meta.name)) continue;
899
+ if (!groups.has(meta.name)) groups.set(meta.name, []);
900
+ groups.get(meta.name).push({
901
+ name: entry.name,
902
+ dir,
903
+ version: meta.version || '',
904
+ kind: entry.isSymbolicLink() ? 'link' : 'directory',
905
+ });
906
+ }
907
+ for (const [slug, copies] of groups) {
908
+ if (copies.length > 1) {
909
+ add('single_source:' + (host.agentId || host.label) + ':' + slug, false, 'warning',
910
+ host.label + ' 中 ' + slug + ' 存在 ' + copies.length + ' 份副本(按 name 引用有歧义):' +
911
+ copies.map((copy) => copy.name).join('、'),
912
+ '运行 yotta-skills hub link --agent ' + (host.agentId || '<id>') + ' 收敛(旧副本移入回收站,保留 ' + TRASH_RETENTION_DAYS + ' 天)');
913
+ }
914
+ const hubSkill = hubSkills.get(slug);
915
+ if (!hubSkill || !hubSkill.version) continue;
916
+ const single = copies.length === 1 ? copies[0] : null;
917
+ if (!single || single.kind !== 'directory' || !single.version) continue;
918
+ const diff = compareVersions(single.version, hubSkill.version);
919
+ if (diff > 0) {
920
+ add('single_source_version:' + (host.agentId || host.label) + ':' + slug, false, 'warning',
921
+ host.label + ' 的 ' + slug + ' v' + single.version + ' 高于 Hub v' + hubSkill.version,
922
+ '先运行 yotta-skills hub update(或该技能自身更新)再链接');
923
+ } else if (diff < 0) {
924
+ add('single_source_version:' + (host.agentId || host.label) + ':' + slug, true, 'info',
925
+ host.label + ' 的 ' + slug + ' v' + single.version + ' 低于 Hub v' + hubSkill.version + '(链接时自动收敛)');
926
+ }
927
+ }
928
+ }
929
+ }
930
+
498
931
  function doctor(options) {
499
932
  const opts = options || {};
500
933
  const hubDir = path.resolve(opts.hubDir);
@@ -543,6 +976,10 @@ function doctor(options) {
543
976
  link.status === 'ok' ? null : '运行 hub link --all 重建,或 hub unlink 清理断链');
544
977
  }
545
978
 
979
+ try {
980
+ singleSourceChecks(opts, add);
981
+ } catch (_) { /* 只读检查失败不影响 doctor 主流程 */ }
982
+
546
983
  const errors = checks.filter((check) => !check.ok && check.severity === 'error');
547
984
  const warnings = checks.filter((check) => !check.ok && check.severity === 'warning');
548
985
  return {
@@ -562,6 +999,8 @@ module.exports = {
562
999
  STATE_FILE,
563
1000
  LINKS_FILE,
564
1001
  AUDIT_FILE,
1002
+ TRASH_RETENTION_DAYS,
1003
+ HUB_FAMILY_EXTRAS,
565
1004
  resolveHubDir,
566
1005
  hubPaths,
567
1006
  readHubState,
@@ -572,6 +1011,12 @@ module.exports = {
572
1011
  hashTree,
573
1012
  scanHubSkills,
574
1013
  syncHubState,
1014
+ familySlugSet,
1015
+ compareVersions,
1016
+ readSkillMeta,
1017
+ resolveTrashRoot,
1018
+ pruneTrash,
1019
+ listFamilyCopies,
575
1020
  createDirLink,
576
1021
  removeDirLink,
577
1022
  classifyTarget,
@@ -80,10 +80,10 @@ function readVersion(root) {
80
80
  function validateSnapshot(snapshot) {
81
81
  const root = path.resolve(snapshot || '');
82
82
  if (!snapshot || !fs.existsSync(root)) {
83
- return { ok: false, version: null, digest: null, legacy: false, reason: '快照目录不存在' };
83
+ return { ok: false, version: null, digest: null, legacy: false, reason: '快照目录不存在', source: null, createdAt: null };
84
84
  }
85
85
  if (!fs.statSync(root).isDirectory()) {
86
- return { ok: false, version: null, digest: null, legacy: false, reason: '快照不是目录' };
86
+ return { ok: false, version: null, digest: null, legacy: false, reason: '快照不是目录', source: null, createdAt: null };
87
87
  }
88
88
  const metaFile = root + '.meta.json';
89
89
  if (fs.existsSync(metaFile)) {
@@ -91,13 +91,21 @@ function validateSnapshot(snapshot) {
91
91
  try {
92
92
  metadata = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
93
93
  } catch (error) {
94
- return { ok: false, version: null, digest: null, legacy: false, reason: '快照元数据无法解析: ' + error.message };
94
+ return { ok: false, version: null, digest: null, legacy: false, reason: '快照元数据无法解析: ' + error.message, source: null, createdAt: null };
95
95
  }
96
96
  let current;
97
97
  try {
98
98
  current = computeTreeDigest(root);
99
99
  } catch (error) {
100
- return { ok: false, version: metadata.version || null, digest: metadata.digest || null, legacy: false, reason: '快照不可读: ' + error.message };
100
+ return {
101
+ ok: false,
102
+ version: metadata.version || null,
103
+ digest: metadata.digest || null,
104
+ legacy: false,
105
+ reason: '快照不可读: ' + error.message,
106
+ source: metadata.source || null,
107
+ createdAt: metadata.created_at || null,
108
+ };
101
109
  }
102
110
  if (!metadata.digest || current.digest !== metadata.digest) {
103
111
  return {
@@ -106,6 +114,8 @@ function validateSnapshot(snapshot) {
106
114
  digest: metadata.digest || null,
107
115
  legacy: false,
108
116
  reason: '快照摘要不一致,可能已被修改',
117
+ source: metadata.source || null,
118
+ createdAt: metadata.created_at || null,
109
119
  };
110
120
  }
111
121
  return {
@@ -114,14 +124,16 @@ function validateSnapshot(snapshot) {
114
124
  digest: metadata.digest,
115
125
  legacy: false,
116
126
  reason: null,
127
+ source: metadata.source || null,
128
+ createdAt: metadata.created_at || null,
117
129
  };
118
130
  }
119
131
 
120
132
  const version = readVersion(root);
121
133
  if (!fs.existsSync(path.join(root, 'SKILL.md'))) {
122
- return { ok: false, version, digest: null, legacy: true, reason: '旧快照缺少 SKILL.md' };
134
+ return { ok: false, version, digest: null, legacy: true, reason: '旧快照缺少 SKILL.md', source: null, createdAt: null };
123
135
  }
124
- return { ok: true, version, digest: null, legacy: true, reason: null };
136
+ return { ok: true, version, digest: null, legacy: true, reason: null, source: null, createdAt: null };
125
137
  }
126
138
 
127
139
  function createSnapshot(target, options) {
@@ -191,6 +203,8 @@ function listSnapshots(homeDir, slug) {
191
203
  version: validation.version,
192
204
  legacy: validation.legacy,
193
205
  reason: validation.reason,
206
+ source: validation.source || null,
207
+ createdAt: validation.createdAt || null,
194
208
  mtime_ms: mtimeMs,
195
209
  });
196
210
  }
@@ -277,6 +277,13 @@ async function fetchPackument(pkg, opts) {
277
277
  /** pin=true 取精确版本;pin=false 过滤同 major 取最高正式版本。 */
278
278
  function resolveVersion(packument, skill, pin) {
279
279
  const versions = (packument && packument.versions) || {};
280
+ if (pin && String(skill.version) === 'latest') {
281
+ const latest = (packument && packument['dist-tags'] && packument['dist-tags'].latest) || null;
282
+ if (!latest || !versions[latest]) {
283
+ return { error: 'registry 无 dist-tags.latest(' + skill.pkg + ')' };
284
+ }
285
+ return { version: String(latest), dist: versions[latest].dist || {} };
286
+ }
280
287
  if (pin) {
281
288
  const exact = versions[String(skill.version)];
282
289
  if (!exact) {