@yottameta/yotta-skills-plugin 0.7.2 → 0.19.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.
@@ -9,7 +9,10 @@
9
9
  * npx -y @yottameta/yotta-skills install <skill> --dir <path> # 装单个技能
10
10
  * npx -y @yottameta/yotta-skills update --agent <name> # 增量更新已装技能(补齐缺失/版本不一致)
11
11
  * npx -y @yottameta/yotta-skills update --check # 只读检查更新(联网对 npm 最新,不改动;退出码 0/3/1)
12
+ * npx -y @yottameta/yotta-skills update --check --scheduled # 后台周检(未到期不联网,到期单次检查并写缓存)
12
13
  * npx -y @yottameta/yotta-skills update --auto # 检查到家族更新后自动更新(仅 yotta-* 家族,含装前扫描)
14
+ * npx -y @yottameta/yotta-skills hook capabilities # 查看宿主六事件能力矩阵
15
+ * npx -y @yottameta/yotta-skills hook evaluate --event before_send --manifest <file> # 评估并留证
13
16
  * npx -y @yottameta/yotta-skills --dry-run # 预览将安装清单(不联网、不改动)
14
17
  *
15
18
  * 版本策略:清单锁定 `major.x`(不锁死 patch,维护性更新随最新);--pin 锁死精确版本。
@@ -24,9 +27,17 @@ const os = require('os');
24
27
  const { spawnSync } = require('child_process');
25
28
  const http = require('http');
26
29
  const https = require('https');
30
+ const evidenceLib = require('../lib/install-evidence');
31
+ const gateLib = require('../lib/verify-gate');
32
+ const healthLib = require('../lib/install-health');
33
+ const lifecycleLib = require('../lib/install-lifecycle');
34
+ const snapshotLib = require('../lib/install-snapshot');
35
+ const updateCheckLib = require('../lib/update-check');
36
+ const hookAdapterLib = require('../lib/hook-adapter');
37
+ const { createInstaller, isSafeTarEntry } = require('../lib/install-pipeline');
27
38
 
28
39
  const PKG_ROOT = path.join(__dirname, '..');
29
- let VERSION = '0.2.1';
40
+ let VERSION = '0.19.0';
30
41
  try { VERSION = require(path.join(PKG_ROOT, 'package.json')).version; } catch (_) { /* keep fallback */ }
31
42
 
32
43
  function loadManifest() {
@@ -137,7 +148,8 @@ function parseArgs(argv) {
137
148
  help: false, version: false, agent: null, dir: null, npm: null,
138
149
  python: null, verify: null, command: null, skill: null, rest: [],
139
150
  inventory: false, reindex: false, noReindex: false, json: false, project: false, route: null,
140
- check: false, auto: false, registry: null,
151
+ check: false, auto: false, scheduled: false, registry: null, slug: null,
152
+ host: null, event: null, manifest: null, context: null,
141
153
  };
142
154
  const positionals = [];
143
155
  for (let i = 0; i < argv.length; i++) {
@@ -166,15 +178,27 @@ function parseArgs(argv) {
166
178
  else if (a === '--npm') opts.npm = take('--npm');
167
179
  else if (a === '--python') opts.python = take('--python');
168
180
  else if (a === '--verify') opts.verify = take('--verify');
181
+ else if (a === '--slug') {
182
+ const value = take('--slug').toLowerCase();
183
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(value)) {
184
+ die('--slug 格式非法', 2, '只允许小写字母、数字和连字符,例如 yotta-memory。');
185
+ }
186
+ opts.slug = value;
187
+ }
169
188
  else if (a === '--check') opts.check = true;
170
189
  else if (a === '--auto') opts.auto = true;
190
+ else if (a === '--scheduled') opts.scheduled = true;
171
191
  else if (a === '--registry') opts.registry = take('--registry');
192
+ else if (a === '--host') opts.host = take('--host').toLowerCase();
193
+ else if (a === '--event') opts.event = take('--event').toLowerCase();
194
+ else if (a === '--manifest') opts.manifest = take('--manifest');
195
+ else if (a === '--context') opts.context = take('--context');
172
196
  else if (a.startsWith('-')) die('未知参数: ' + a, 2, '可用 --help 查看支持的选项。');
173
197
  else positionals.push(a);
174
198
  }
175
- // 命令解析:install / update,其余位置参数 = 技能 slug(可多个)
199
+ // 命令解析:install / update / doctor / rollback,其余位置参数 = 技能 slug(可多个)
176
200
  for (const p of positionals) {
177
- if (p === 'install' || p === 'update') {
201
+ if (p === 'install' || p === 'update' || p === 'doctor' || p === 'rollback' || p === 'hook') {
178
202
  if (opts.command && opts.command !== p) die('命令冲突:' + opts.command + ' 与 ' + p);
179
203
  opts.command = p;
180
204
  } else {
@@ -184,6 +208,9 @@ function parseArgs(argv) {
184
208
  // 直接给 slug 且无命令 → 视为 install 单个/多个
185
209
  if (!opts.command && opts.rest.length > 0) opts.command = 'install';
186
210
  opts.skills = opts.rest.map(s => s.toLowerCase());
211
+ if (opts.scheduled && (opts.command !== 'update' || !opts.check || opts.auto)) {
212
+ die('--scheduled 只能与 update --check 一起使用', 2, '请使用 update --check --scheduled;自动更新不使用后台调度入口。');
213
+ }
187
214
  return opts;
188
215
  }
189
216
 
@@ -284,15 +311,69 @@ async function runUpdateCheck(opts, dest) {
284
311
  var lines = [];
285
312
  var quiet = !!opts.json;
286
313
  function say(s) { if (!quiet) lines.push(s); }
287
- say('yotta-skills(元阁)v' + VERSION + ' —— 检查更新(只读,不改动) -> ' + dest);
288
- say('版本源: ' + (opts.registry || process.env.YOTTA_SKILLS_REGISTRY || 'https://registry.npmjs.org/'));
289
- say('');
314
+ var scheduled = !!opts.scheduled;
315
+ var cache = updateCheckLib.readCache();
316
+ var target = cache.targets[updateCheckLib.targetKey(dest)] || null;
317
+ var due = updateCheckLib.isDue(target, Date.now());
318
+ if (!scheduled) {
319
+ say('yotta-skills(元阁)v' + VERSION + ' —— 检查更新(只读,不改动) -> ' + dest);
320
+ say('版本源: ' + (opts.registry || process.env.YOTTA_SKILLS_REGISTRY || 'https://registry.npmjs.org/'));
321
+ say('');
322
+ }
323
+ if (scheduled && !due) {
324
+ var cachedRows = (target && target.last_result && target.last_result.rows) || [];
325
+ var cachedPayload = {
326
+ dest: dest,
327
+ scheduled: true,
328
+ due: false,
329
+ checked: false,
330
+ updatable: cachedRows.filter(function (x) { return x.hasUpdate; }),
331
+ updates: (target && target.last_result && target.last_result.updates) || 0,
332
+ latest: (target && target.last_result && target.last_result.latest) || 0,
333
+ failed: 0,
334
+ nonFamily: (target && target.last_result && target.last_result.nonFamily) || 0,
335
+ cache: target,
336
+ error: null,
337
+ };
338
+ if (opts.json) out(JSON.stringify(cachedPayload, null, 2));
339
+ return {
340
+ code: 0,
341
+ scheduled: true,
342
+ due: false,
343
+ checked: false,
344
+ rows: cachedRows,
345
+ updates: cachedPayload.updates,
346
+ latest: cachedPayload.latest,
347
+ failed: 0,
348
+ nonFamily: cachedPayload.nonFamily,
349
+ };
350
+ }
290
351
  var installed = scanInstalledSlugs(dest);
291
352
  if (installed.length === 0) {
292
- say('(' + dest + ' 下未发现已装技能目录)');
293
- return { code: 0, rows: [], updates: 0, latest: 0, failed: 0, nonFamily: 0 };
353
+ var emptyResult = { updates: 0, latest: 0, failed: 0, nonFamily: 0, rows: [], errors: [] };
354
+ var emptyCacheRecord = null;
355
+ try {
356
+ emptyCacheRecord = updateCheckLib.recordCheck(os.homedir(), dest, { result: emptyResult, error: null });
357
+ } catch (_) { /* 缓存是后台优化的 best-effort,不阻断检查结果 */ }
358
+ if (!scheduled) {
359
+ say('(' + dest + ' 下未发现已装技能目录)');
360
+ for (var emptyIndex = 0; emptyIndex < lines.length; emptyIndex++) out(lines[emptyIndex]);
361
+ }
362
+ return {
363
+ code: 0,
364
+ scheduled: scheduled,
365
+ due: true,
366
+ checked: true,
367
+ rows: [],
368
+ updates: 0,
369
+ latest: 0,
370
+ failed: 0,
371
+ nonFamily: 0,
372
+ cache: emptyCacheRecord,
373
+ };
294
374
  }
295
375
  var rows = [];
376
+ var failures = [];
296
377
  var updates = 0, latest = 0, failed = 0, nonFamily = 0;
297
378
  for (var i = 0; i < installed.length; i++) {
298
379
  var it = installed[i];
@@ -305,6 +386,7 @@ async function runUpdateCheck(opts, dest) {
305
386
  var res = await fetchRegistryLatest(fam.pkg, opts);
306
387
  if (!res.ok) {
307
388
  failed++;
389
+ failures.push({ slug: it.slug, error: res.error });
308
390
  say(' ✘ ' + it.slug.padEnd(22) + '检查失败: ' + res.error);
309
391
  continue;
310
392
  }
@@ -323,11 +405,61 @@ async function runUpdateCheck(opts, dest) {
323
405
  }
324
406
  rows.push({ slug: it.slug, installed: cur, latest: current, pkg: fam.pkg, hasUpdate: !isUp, family: fam });
325
407
  }
326
- say('');
327
- say('汇总: 有更新 ' + updates + ' / 已最新 ' + latest + ' / 检查失败 ' + failed + ' / 非家族跳过 ' + nonFamily);
328
- for (var j = 0; j < lines.length; j++) out(lines[j]);
408
+ var result = {
409
+ updates: updates,
410
+ latest: latest,
411
+ failed: failed,
412
+ nonFamily: nonFamily,
413
+ rows: rows,
414
+ errors: failures,
415
+ };
416
+ var lastError = failures.length > 0
417
+ ? failures.map(function (x) { return x.slug + ': ' + x.error; }).join('; ')
418
+ : null;
419
+ var cacheRecord = null;
420
+ try {
421
+ cacheRecord = updateCheckLib.recordCheck(os.homedir(), dest, { result: result, error: lastError });
422
+ } catch (_) { /* 缓存是后台优化的 best-effort,不阻断检查结果 */ }
329
423
  var code = (failed > 0) ? 1 : (updates > 0 ? 3 : 0);
330
- return { code: code, rows: rows, updates: updates, latest: latest, failed: failed, nonFamily: nonFamily };
424
+ var updatable = rows.filter(function (x) { return x.hasUpdate; });
425
+ if (scheduled) {
426
+ if (updates > 0) {
427
+ for (var j = 0; j < updatable.length; j++) {
428
+ out(' ' + updatable[j].slug + ':本地 v' + (updatable[j].installed || '未知') + ' -> 最新 v' + updatable[j].latest);
429
+ }
430
+ }
431
+ if (opts.json) {
432
+ out(JSON.stringify({
433
+ dest: dest,
434
+ scheduled: true,
435
+ due: true,
436
+ checked: true,
437
+ updatable: updatable,
438
+ updates: updates,
439
+ latest: latest,
440
+ failed: failed,
441
+ nonFamily: nonFamily,
442
+ cache: cacheRecord,
443
+ error: lastError,
444
+ }, null, 2));
445
+ }
446
+ return { code: 0, scheduled: true, due: true, checked: true, rows: rows, updates: updates, latest: latest, failed: failed, nonFamily: nonFamily };
447
+ }
448
+ say('汇总: 有更新 ' + updates + ' / 已最新 ' + latest + ' / 检查失败 ' + failed + ' / 非家族跳过 ' + nonFamily);
449
+ for (var lineIndex = 0; lineIndex < lines.length; lineIndex++) out(lines[lineIndex]);
450
+ if (opts.json) {
451
+ out(JSON.stringify({
452
+ dest: dest,
453
+ updatable: updatable,
454
+ updates: updates,
455
+ latest: latest,
456
+ failed: failed,
457
+ nonFamily: nonFamily,
458
+ cache: cacheRecord,
459
+ error: lastError,
460
+ }, null, 2));
461
+ }
462
+ return { code: code, scheduled: false, due: true, checked: true, rows: rows, updates: updates, latest: latest, failed: failed, nonFamily: nonFamily };
331
463
  }
332
464
 
333
465
  async function runUpdateAuto(opts, dest) {
@@ -343,19 +475,27 @@ async function runUpdateAuto(opts, dest) {
343
475
  }
344
476
  out('');
345
477
  out('检测到 ' + r.updates + ' 个家族技能可更新,开始自动更新(安装管线,含装前安全扫描):');
346
- var ok = 0, failed2 = 0;
478
+ var ok = 0, failed2 = 0, autoExitCode = 0;
347
479
  for (var i = 0; i < r.rows.length; i++) {
348
480
  var row = r.rows[i];
349
481
  if (!row.hasUpdate) continue;
350
- var res = installOne(Object.assign({}, row.family, { version: row.latest }), dest, Object.assign({}, opts, { force: true, pin: true }));
482
+ var res = installOne(
483
+ Object.assign({}, row.family, { version: row.latest }),
484
+ dest,
485
+ Object.assign({}, opts, { force: true, pin: true, skipScan: false }),
486
+ );
351
487
  if (res.status === 'ok') ok++;
352
488
  else if (res.status === 'skip') ok++;
353
- else failed2++;
489
+ else {
490
+ failed2++;
491
+ autoExitCode = Math.max(autoExitCode, res.exitCode || 1);
492
+ out(' ✘ ' + row.slug + ' 失败: ' + res.note);
493
+ }
354
494
  }
355
495
  out('');
356
496
  out('自动更新汇总: 成功 ' + ok + ' / 失败 ' + failed2);
357
- maybeAutoReindex(opts, dest);
358
- return { code: failed2 > 0 ? 1 : 0 };
497
+ if (failed2 === 0) maybeAutoReindex(opts, dest);
498
+ return { code: failed2 > 0 ? autoExitCode : 0 };
359
499
  }
360
500
 
361
501
  function shouldSkip(name, isFile) {
@@ -399,17 +539,12 @@ function detectProjectDir() {
399
539
  return null;
400
540
  }
401
541
 
402
- // ── 元信 scan(若可用)─────────────────────────────────────────────────────
542
+ // ── 元信 scan 与安装门禁 ──────────────────────────────────────────────────
403
543
  function findVerifyEngine(dest, opts) {
404
- const cands = [];
405
- if (opts.verify) cands.push(path.resolve(opts.verify));
406
- if (process.env.YOTTA_SKILLS_VERIFY) cands.push(path.resolve(process.env.YOTTA_SKILLS_VERIFY));
407
- if (dest) cands.push(path.join(dest, 'yotta-verify', 'scripts', 'yotta_verify.py'));
408
- for (const c of cands) {
409
- try { if (fs.statSync(c).isFile()) return c; } catch (_) { /* next */ }
410
- }
411
- return null;
544
+ const registry = require('../lib/skills-scan').readRegistry();
545
+ return gateLib.findVerifier({ dest, opts, registry });
412
546
  }
547
+
413
548
  function findPython(opts) {
414
549
  const cands = [];
415
550
  if (opts.python) cands.push(opts.python);
@@ -424,19 +559,29 @@ function findPython(opts) {
424
559
  }
425
560
  return null;
426
561
  }
562
+
427
563
  function runScan(engine, skillDir) {
428
564
  const python = findPython({});
429
- if (!python) return { ok: false, note: '未找到 python(元信 scan 需要 Python 3.8+)' };
430
- // -B:禁止 Python 写 __pycache__(否则会在引擎所在目录(可能是已装技能)留下 .pyc 污染)
431
- const r = spawnSync(python, ['-B', engine, 'scan', skillDir, '--json'], { encoding: 'utf8', timeout: 60000 });
432
- if (r.status === null) return { ok: false, note: '元信 scan 执行失败' };
433
- try {
434
- const j = JSON.parse(r.stdout);
435
- const c = j.counts || {};
436
- return { ok: true, verdict: j.verdict, counts: c };
437
- } catch (_) {
438
- return { ok: false, note: '元信 scan 输出无法解析' };
565
+ if (!python) return { ok: false, error: '未找到 Python(元信 scan 需要 Python 3.8+)' };
566
+ return gateLib.runVerifier(engine, skillDir, { python, spawnSync });
567
+ }
568
+
569
+ function scanTarget(engine, skillDir) {
570
+ const scan = runScan(engine, skillDir);
571
+ if (scan.ok) {
572
+ const counts = scan.counts || {};
573
+ const line = 'critical ' + (counts.critical || 0) + ' / high ' + (counts.high || 0) +
574
+ ' / medium ' + (counts.medium || 0) + ' / low ' + (counts.low || 0) +
575
+ ' / info ' + (counts.info || 0);
576
+ out(' 元信 scan: ' + scan.verdict + '(' + line + ')');
577
+ if (scan.verdict === gateLib.BLOCK) out(' ⚠ 元信 verdict 为 DO NOT INSTALL,已阻断安装。');
578
+ else if (scan.verdict === gateLib.CAUTION || scan.verdict === gateLib.REVIEW) {
579
+ out(' ⚠ 元信 verdict 为 ' + scan.verdict + ',继续安装并保留风险证据。');
580
+ }
581
+ } else {
582
+ out(' 元信 scan: ' + scan.error);
439
583
  }
584
+ return scan;
440
585
  }
441
586
 
442
587
  // ── 安装 ───────────────────────────────────────────────────────────────────
@@ -468,6 +613,13 @@ function runNpmPack(skill, opts, packDir) {
468
613
  }
469
614
 
470
615
  function extractTarball(tarball, extractDir) {
616
+ const listed = spawnSync(tarBin(), ['-tzf', tarball], { encoding: 'utf8', timeout: 120000, maxBuffer: 16 * 1024 * 1024 });
617
+ if (listed.status !== 0) {
618
+ return { error: (listed.stderr || listed.stdout || 'tar 列表读取失败').trim().split('\n').pop() };
619
+ }
620
+ const entries = String(listed.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
621
+ const unsafe = entries.find((entry) => !isSafeTarEntry(entry));
622
+ if (unsafe) return { error: '压缩包包含不安全路径: ' + unsafe };
471
623
  const r = spawnSync(tarBin(), ['-xzf', tarball, '-C', extractDir], { encoding: 'utf8', timeout: 120000 });
472
624
  if (r.status !== 0) return { error: (r.stderr || r.stdout || 'tar 解压失败').trim().split('\n').pop() };
473
625
  const pkgDir = path.join(extractDir, 'package');
@@ -477,72 +629,94 @@ function extractTarball(tarball, extractDir) {
477
629
 
478
630
  const COPY_SKIP = new Set(['package.json', 'bin', 'node_modules', '.git', '__pycache__']);
479
631
 
480
- function installOne(skill, dest, opts) {
481
- const target = path.join(dest, skill.slug);
482
- const existing = readInstalledVersion(target);
483
- if (!opts.force && existing === skill.version) {
484
- return { skill, status: 'skip', version: existing, note: '已是最新' };
632
+ function ensureGate(context) {
633
+ const { skill, extracted, dest, opts } = context;
634
+ const current = findVerifyEngine(dest, opts);
635
+ if (current) {
636
+ return {
637
+ ok: true,
638
+ engine: current,
639
+ mode: opts.bootstrap ? 'trusted-bootstrap' : 'installed',
640
+ };
641
+ }
642
+ if (skill.slug === 'yotta-verify') {
643
+ const engine = path.join(extracted.pkgDir, 'scripts', 'yotta_verify.py');
644
+ if (!fs.existsSync(engine)) return { ok: false, error: '元信包内缺少 scripts/yotta_verify.py' };
645
+ return { ok: true, engine, mode: 'trusted-bootstrap' };
485
646
  }
647
+
648
+ const verifier = findSkill('yotta-verify');
649
+ if (!verifier) return { ok: false, error: 'skills.json 缺少 yotta-verify' };
486
650
  let tmp;
487
651
  try {
488
- tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'yotta-skills-'));
652
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'yotta-verify-bootstrap-'));
489
653
  const packDir = path.join(tmp, 'pack');
490
654
  fs.mkdirSync(packDir, { recursive: true });
491
- const packed = runNpmPack(skill, opts, packDir);
492
- if (packed.error) {
493
- if (packed.detail) out(' ' + packed.detail.split(/\r?\n/).filter(Boolean).slice(-12).join('\n '));
494
- return { skill, status: 'fail', version: null, note: packed.error };
495
- }
655
+ const packed = runNpmPack(verifier, opts, packDir);
656
+ if (packed.error) return { ok: false, error: '元信自举下载失败: ' + packed.error };
496
657
  const extractDir = path.join(tmp, 'extract');
497
658
  fs.mkdirSync(extractDir, { recursive: true });
498
- const extracted = extractTarball(packed.tarball, extractDir);
499
- if (extracted.error) return { skill, status: 'fail', version: packed.resolved, note: extracted.error };
500
-
501
- // 元信 scan(若可用):装前摘要,不拦截
502
- if (!opts.skipScan) {
503
- const engine = findVerifyEngine(dest, opts);
504
- if (engine) {
505
- const scan = runScan(engine, extracted.pkgDir);
506
- if (scan.ok) {
507
- const c = scan.counts;
508
- const line = 'critical ' + (c.critical || 0) + ' / high ' + (c.high || 0) + ' / medium ' + (c.medium || 0) +
509
- ' / low ' + (c.low || 0) + ' / info ' + (c.info || 0);
510
- out(' 元信 scan: ' + scan.verdict + '(' + line + ')');
511
- if (scan.verdict && /DO NOT INSTALL/.test(scan.verdict)) out(' ⚠ 元信 verdict 为 DO NOT INSTALL,请人工复核后再使用。');
512
- } else {
513
- out(' 元信 scan: ' + scan.note);
514
- }
515
- }
516
- }
517
- fs.rmSync(target, { recursive: true, force: true });
518
- fs.mkdirSync(target, { recursive: true });
519
- copyDir(extracted.pkgDir, target, COPY_SKIP);
520
- return { skill, status: 'ok', version: packed.resolved || skill.version, note: null };
521
- } catch (e) {
522
- return { skill, status: 'fail', version: null, note: e.message };
659
+ const extractedVerifier = extractTarball(packed.tarball, extractDir);
660
+ if (extractedVerifier.error) return { ok: false, error: '元信自举解包失败: ' + extractedVerifier.error };
661
+ const engine = path.join(extractedVerifier.pkgDir, 'scripts', 'yotta_verify.py');
662
+ if (!fs.existsSync(engine)) return { ok: false, error: '元信包内缺少 scripts/yotta_verify.py' };
663
+
664
+ out(' 元信未安装,使用可信源包自举并先自扫。');
665
+ const result = installOne(verifier, dest, {
666
+ ...opts,
667
+ force: true,
668
+ skipScan: false,
669
+ bootstrap: true,
670
+ verify: engine,
671
+ });
672
+ if (result.status !== 'ok') return { ok: false, error: '元信自举失败: ' + result.note };
673
+ const installedEngine = path.join(dest, 'yotta-verify', 'scripts', 'yotta_verify.py');
674
+ if (!fs.existsSync(installedEngine)) return { ok: false, error: '元信自举后未找到安装引擎' };
675
+ return { ok: true, engine: installedEngine, mode: 'trusted-bootstrap' };
523
676
  } finally {
524
677
  if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
525
678
  }
526
679
  }
527
680
 
681
+ const installOne = createInstaller({
682
+ runNpmPack,
683
+ extractTarball,
684
+ copyDir: (src, dst) => copyDir(src, dst, COPY_SKIP),
685
+ readInstalledVersion,
686
+ ensureGate,
687
+ scanTarget,
688
+ appendEvidence: evidenceLib.appendEvidence,
689
+ });
690
+
528
691
  function runInstall(opts, dest) {
529
692
  const skills = selectSkills(opts);
530
693
  out('yotta-skills(元阁)v' + VERSION + ' —— 安装 ' + skills.length + ' 个技能 -> ' + dest);
531
694
  out('版本策略: ' + (opts.pin ? 'pin(锁死清单精确版本)' : 'range(' + skillRange(skills[0]) + ',跟随最新 patch;--pin 锁死)'));
532
695
  const results = [];
533
696
  let failed = 0;
697
+ let exitCode = 0;
534
698
  for (const s of skills) {
535
699
  const r = installOne(s, dest, opts);
536
700
  results.push(r);
537
- if (r.status === 'ok') out(' ✔ ' + s.slug.padEnd(22) + s.name + ' -> ' + (r.version || '?'));
701
+ if (r.status === 'ok') {
702
+ if (r.gate && r.gate.mode === 'explicit-unverified') {
703
+ out(' ⚠ ' + s.slug.padEnd(22) + '未执行装前扫描(explicit-unverified)');
704
+ }
705
+ out(' ✔ ' + s.slug.padEnd(22) + s.name + ' -> ' + (r.version || '?'));
706
+ }
538
707
  else if (r.status === 'skip') out(' - ' + s.slug.padEnd(22) + s.name + ' (' + r.note + ',v' + r.version + ')');
539
- else { failed++; out(' ✘ ' + s.slug.padEnd(22) + s.name + ' 失败: ' + r.note); }
708
+ else {
709
+ failed++;
710
+ exitCode = Math.max(exitCode, r.exitCode || 1);
711
+ out(' ✘ ' + s.slug.padEnd(22) + s.name + ' 失败: ' + r.note);
712
+ }
540
713
  }
541
714
  const ok = results.filter(r => r.status === 'ok').length;
542
715
  const skip = results.filter(r => r.status === 'skip').length;
543
716
  out('');
544
717
  out('汇总: 成功 ' + ok + ' / 跳过(已是最新) ' + skip + ' / 失败 ' + failed + '(共 ' + results.length + ')');
545
- if (failed > 0) process.exitCode = 1;
718
+ if (failed > 0) process.exitCode = exitCode || 1;
719
+ return { failed, exitCode };
546
720
  }
547
721
 
548
722
  function runUpdate(opts, dest) {
@@ -550,6 +724,7 @@ function runUpdate(opts, dest) {
550
724
  out('yotta-skills(元阁)v' + VERSION + ' —— 增量更新 -> ' + dest);
551
725
  const results = [];
552
726
  let failed = 0;
727
+ let exitCode = 0;
553
728
  for (const s of skills) {
554
729
  const existing = readInstalledVersion(path.join(dest, s.slug));
555
730
  if (existing === s.version) {
@@ -559,15 +734,315 @@ function runUpdate(opts, dest) {
559
734
  }
560
735
  const r = installOne(s, dest, opts);
561
736
  results.push(r);
562
- if (r.status === 'ok') out(' ✔ ' + s.slug.padEnd(22) + s.name + ' -> ' + (r.version || '?') + (existing ? '(原 v' + existing + ')' : '(新装)'));
737
+ if (r.status === 'ok') {
738
+ if (r.gate && r.gate.mode === 'explicit-unverified') {
739
+ out(' ⚠ ' + s.slug.padEnd(22) + '未执行装前扫描(explicit-unverified)');
740
+ }
741
+ out(' ✔ ' + s.slug.padEnd(22) + s.name + ' -> ' + (r.version || '?') + (existing ? '(原 v' + existing + ')' : '(新装)'));
742
+ }
563
743
  else if (r.status === 'skip') out(' - ' + s.slug.padEnd(22) + s.name + ' (' + r.note + ')');
564
- else { failed++; out(' ✘ ' + s.slug.padEnd(22) + s.name + ' 失败: ' + r.note); }
744
+ else {
745
+ failed++;
746
+ exitCode = Math.max(exitCode, r.exitCode || 1);
747
+ out(' ✘ ' + s.slug.padEnd(22) + s.name + ' 失败: ' + r.note);
748
+ }
565
749
  }
566
750
  const ok = results.filter(r => r.status === 'ok').length;
567
751
  const skip = results.filter(r => r.status === 'skip').length;
568
752
  out('');
569
753
  out('汇总: 更新 ' + ok + ' / 已是最新 ' + skip + ' / 失败 ' + failed);
570
- if (failed > 0) process.exitCode = 1;
754
+ if (failed > 0) process.exitCode = exitCode || 1;
755
+ return { failed, exitCode };
756
+ }
757
+
758
+ // ── doctor / rollback ─────────────────────────────────────────────────────
759
+ function readInstalledManifest(target) {
760
+ const file = path.join(target, 'skill-manifest.json');
761
+ try {
762
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
763
+ } catch (_) {
764
+ return null;
765
+ }
766
+ }
767
+
768
+ function runCustomDoctor(skillDir, dest) {
769
+ const manifest = readInstalledManifest(skillDir);
770
+ if (!manifest || !manifest.install || !manifest.install.doctor) {
771
+ return { ok: true, skipped: true, error: null, result: null };
772
+ }
773
+ if (manifest.trust !== 'yottameta') {
774
+ return {
775
+ ok: false,
776
+ skipped: false,
777
+ error: 'manifest trust 不是 yottameta,拒绝执行自定义 doctor',
778
+ result: null,
779
+ };
780
+ }
781
+ return lifecycleLib.runPhase(skillDir, manifest, 'doctor', {
782
+ skillDir,
783
+ packageDir: skillDir,
784
+ dest,
785
+ });
786
+ }
787
+
788
+ function doctorTargets(opts, dest) {
789
+ if (opts.slug) {
790
+ const skill = familySkillFor(opts.slug);
791
+ return skill ? [skill] : [];
792
+ }
793
+ if (opts.skills.length) {
794
+ return opts.skills.map((slug) => familySkillFor(slug)).filter(Boolean);
795
+ }
796
+ const found = [];
797
+ let entries;
798
+ try { entries = fs.readdirSync(dest, { withFileTypes: true }); } catch (_) { return found; }
799
+ for (const entry of entries) {
800
+ if (!entry.isDirectory() || !/^yotta-/.test(entry.name)) continue;
801
+ const skill = familySkillFor(entry.name);
802
+ if (skill) found.push(skill);
803
+ }
804
+ return found;
805
+ }
806
+
807
+ function doctorExitCode(payload) {
808
+ if (payload.ok) return 0;
809
+ const manifestFailure = payload.results.some((result) =>
810
+ (result.checks || []).some((check) =>
811
+ (check.id === 'manifest' || check.id === 'manifest_trust') && !check.ok));
812
+ return manifestFailure ? 6 : 1;
813
+ }
814
+
815
+ function runDoctor(opts, dest) {
816
+ const scan = require('../lib/skills-scan');
817
+ const registry = scan.readRegistry();
818
+ const targets = doctorTargets(opts, dest);
819
+ const results = [];
820
+ const errors = [];
821
+ const warnings = [];
822
+ const fixes = [];
823
+
824
+ if (targets.length === 0) {
825
+ errors.push(opts.slug ? '未找到技能: ' + opts.slug : '目标目录下没有可检查的元阁家族技能');
826
+ }
827
+
828
+ for (const skill of targets) {
829
+ const target = path.join(dest, skill.slug);
830
+ const result = healthLib.checkInstalledSkill({
831
+ slug: skill.slug,
832
+ target,
833
+ expectedVersion: skill.version || null,
834
+ expectedPackage: skill.pkg || null,
835
+ registry,
836
+ });
837
+ const custom = runCustomDoctor(target, dest);
838
+ result.custom_doctor = {
839
+ ok: !!custom.ok,
840
+ skipped: !!custom.skipped,
841
+ error: custom.error || null,
842
+ result: custom.result || null,
843
+ };
844
+ if (!custom.ok) {
845
+ result.ok = false;
846
+ result.errors.push('自定义 doctor 失败: ' + (custom.error || '未知错误'));
847
+ result.checks.push({
848
+ id: 'custom_doctor',
849
+ ok: false,
850
+ severity: 'error',
851
+ message: '自定义 doctor 失败: ' + (custom.error || '未知错误'),
852
+ hint: '修复技能包内的 doctor 脚本后重试',
853
+ });
854
+ } else if (!custom.skipped) {
855
+ result.checks.push({
856
+ id: 'custom_doctor',
857
+ ok: true,
858
+ severity: 'info',
859
+ message: '自定义 doctor 通过',
860
+ hint: null,
861
+ });
862
+ }
863
+ results.push(result);
864
+ errors.push.apply(errors, result.errors);
865
+ warnings.push.apply(warnings, result.warnings);
866
+ fixes.push.apply(fixes, result.fixes);
867
+ }
868
+
869
+ const payload = {
870
+ ok: errors.length === 0,
871
+ dir: dest,
872
+ checked: results.length,
873
+ results,
874
+ errors,
875
+ warnings,
876
+ fixes: Array.from(new Set(fixes)),
877
+ };
878
+ const code = doctorExitCode(payload);
879
+ if (opts.json) {
880
+ out(JSON.stringify(payload, null, 2));
881
+ } else {
882
+ out('yotta-skills(元阁)v' + VERSION + ' —— doctor');
883
+ out('目标: ' + dest);
884
+ for (const result of results) {
885
+ out('');
886
+ out((result.ok ? '✔ ' : '✘ ') + result.slug + ' v' + (result.version || '未知'));
887
+ for (const check of result.checks) {
888
+ out(' ' + (check.ok ? '✔' : '✘') + ' ' + check.message + (check.hint ? '(修复: ' + check.hint + ')' : ''));
889
+ }
890
+ }
891
+ for (const item of warnings) out('[警告] ' + item);
892
+ for (const item of fixes) out('[建议] ' + item);
893
+ if (!payload.ok) out('doctor 未通过:请按上面的修复建议处理后重试。');
894
+ }
895
+ process.exitCode = code;
896
+ return payload;
897
+ }
898
+
899
+ function latestRollbackTarget(home) {
900
+ const file = path.join(home, '.yottaskills', 'install-log.jsonl');
901
+ let lines;
902
+ try { lines = fs.readFileSync(file, 'utf8').split(/\r?\n/).filter(Boolean); } catch (_) { return null; }
903
+ for (let i = lines.length - 1; i >= 0; i--) {
904
+ let entry;
905
+ try { entry = JSON.parse(lines[i]); } catch (_) { continue; }
906
+ if (!entry.skill || !entry.snapshot) continue;
907
+ if (!fs.existsSync(entry.snapshot)) continue;
908
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(entry.skill)) continue;
909
+ const expectedRoot = path.resolve(snapshotLib.snapshotRoot(home, entry.skill)) + path.sep;
910
+ if (!path.resolve(entry.snapshot).startsWith(expectedRoot)) continue;
911
+ const validation = snapshotLib.validateSnapshot(entry.snapshot);
912
+ if (validation.ok) return { slug: entry.skill, snapshot: entry.snapshot };
913
+ }
914
+ return null;
915
+ }
916
+
917
+ function runRollback(opts, dest) {
918
+ const home = os.homedir();
919
+ const requestedSlug = opts.slug || (opts.skills.length ? opts.skills[0] : null);
920
+
921
+ if (opts.list) {
922
+ const rows = snapshotLib.listSnapshots(home, requestedSlug);
923
+ const payload = {
924
+ ok: true,
925
+ dir: dest || null,
926
+ slug: requestedSlug || null,
927
+ count: rows.filter((row) => row.valid).length,
928
+ snapshots: rows,
929
+ };
930
+ if (opts.json) out(JSON.stringify(payload, null, 2));
931
+ else {
932
+ out('yotta-skills(元阁)v' + VERSION + ' —— rollback 快照');
933
+ for (const row of rows) {
934
+ out(' ' + (row.valid ? '✔' : '✘') + ' ' + row.slug + ' v' + (row.version || '未知') + ' ' + row.path + (row.reason ? '(' + row.reason + ')' : ''));
935
+ }
936
+ out('可用快照: ' + payload.count + ' / ' + rows.length);
937
+ }
938
+ return payload;
939
+ }
940
+
941
+ let slug = requestedSlug;
942
+ let selected = null;
943
+ if (slug) {
944
+ const rows = snapshotLib.listSnapshots(home, slug);
945
+ selected = rows.find((row) => row.valid) || rows[0] || null;
946
+ } else {
947
+ const latest = latestRollbackTarget(home);
948
+ if (latest) {
949
+ slug = latest.slug;
950
+ selected = snapshotLib.listSnapshots(home, slug).find((row) => row.path === latest.snapshot && row.valid) || null;
951
+ }
952
+ }
953
+
954
+ if (!slug) {
955
+ const payload = { ok: false, dir: dest, slug: null, errors: ['安装记录中没有可回滚的技能快照'] };
956
+ if (opts.json) out(JSON.stringify(payload, null, 2));
957
+ else out('回滚失败:安装记录中没有可回滚的技能快照。');
958
+ process.exitCode = 1;
959
+ return payload;
960
+ }
961
+ if (!selected || !selected.valid) {
962
+ const payload = {
963
+ ok: false,
964
+ dir: dest,
965
+ slug,
966
+ errors: [selected && selected.reason ? '选中的快照不可用: ' + selected.reason : '没有可用快照'],
967
+ };
968
+ if (opts.json) out(JSON.stringify(payload, null, 2));
969
+ else out('回滚失败:' + payload.errors[0]);
970
+ process.exitCode = 1;
971
+ return payload;
972
+ }
973
+
974
+ const target = path.join(dest, slug);
975
+ const restored = snapshotLib.restoreSnapshot(selected.path, target);
976
+ if (!restored.ok) {
977
+ const payload = { ok: false, dir: dest, slug, snapshot: selected.path, errors: [restored.error] };
978
+ if (opts.json) out(JSON.stringify(payload, null, 2));
979
+ else out('回滚失败:' + restored.error);
980
+ process.exitCode = 1;
981
+ return payload;
982
+ }
983
+
984
+ const family = familySkillFor(slug);
985
+ const scan = require('../lib/skills-scan');
986
+ const doctor = healthLib.checkInstalledSkill({
987
+ slug,
988
+ target,
989
+ expectedVersion: restored.version && restored.version !== 'unknown' ? restored.version : null,
990
+ expectedPackage: family ? family.pkg : null,
991
+ registry: scan.readRegistry(),
992
+ });
993
+ const custom = runCustomDoctor(target, dest);
994
+ if (!custom.ok) {
995
+ doctor.ok = false;
996
+ doctor.errors.push('自定义 doctor 失败: ' + (custom.error || '未知错误'));
997
+ }
998
+
999
+ const payload = {
1000
+ ok: doctor.ok,
1001
+ dir: dest,
1002
+ slug,
1003
+ snapshot: selected.path,
1004
+ version: restored.version,
1005
+ doctor,
1006
+ errors: doctor.errors,
1007
+ warnings: doctor.warnings,
1008
+ fixes: doctor.fixes,
1009
+ reindexed: false,
1010
+ };
1011
+ try {
1012
+ evidenceLib.appendEvidence({
1013
+ event: 'rollback',
1014
+ skill: slug,
1015
+ package: family ? family.pkg : null,
1016
+ version: restored.version,
1017
+ decision: doctor.ok ? 'ok' : 'fail',
1018
+ snapshot: selected.path,
1019
+ }, { homeDir: home });
1020
+ } catch (error) {
1021
+ payload.ok = false;
1022
+ payload.errors.push('回滚证据写入失败: ' + error.message);
1023
+ }
1024
+ if (payload.ok && !opts.noReindex) {
1025
+ try {
1026
+ reindexRegistry({ ...opts, dir: dest });
1027
+ payload.reindexed = true;
1028
+ } catch (error) {
1029
+ payload.warnings.push('回滚完成,但注册表重扫失败: ' + error.message);
1030
+ }
1031
+ }
1032
+
1033
+ if (opts.json) {
1034
+ out(JSON.stringify(payload, null, 2));
1035
+ } else {
1036
+ out('yotta-skills(元阁)v' + VERSION + ' —— rollback');
1037
+ out('技能: ' + slug);
1038
+ out('快照: ' + selected.path);
1039
+ out('结果: ' + (payload.ok ? '✔ 已恢复 v' + (restored.version || '未知') : '✘ 恢复后 doctor 未通过'));
1040
+ if (payload.errors.length) out('错误: ' + payload.errors.join('; '));
1041
+ if (payload.warnings.length) out('警告: ' + payload.warnings.join('; '));
1042
+ if (payload.reindexed) out('已重扫本地技能注册表。');
1043
+ }
1044
+ process.exitCode = payload.ok ? 0 : 1;
1045
+ return payload;
571
1046
  }
572
1047
 
573
1048
  // ── 展示 ───────────────────────────────────────────────────────────────────
@@ -592,10 +1067,18 @@ function printHelp() {
592
1067
  out(' yotta-skills install --dir <path> 装全家到指定目录');
593
1068
  out(' yotta-skills install <skill> --dir <path> 装单个技能(可多个)');
594
1069
  out(' yotta-skills update --agent <name> 增量更新已装技能(补齐缺失 / 版本不一致)');
1070
+ out(' yotta-skills update --check 只读联网检查更新(退出码 0/3/1)');
1071
+ out(' yotta-skills update --check --scheduled 后台周检(未到期不联网;到期单次检查并写缓存)');
1072
+ out(' yotta-skills doctor --dir <path> 只读自检技能目录(可加 --slug / --json)');
1073
+ out(' yotta-skills rollback --dir <path> 回滚最近一次技能安装或更新(--list 查看快照)');
595
1074
  out(' yotta-skills --dry-run 预览将安装清单(不联网、不改动)');
596
1075
  out(' yotta-skills --inventory 盘点本机已装技能(自研扫描,不依赖任何元技能)');
597
1076
  out(' yotta-skills --reindex 重扫注册表(会话开工 / 装技能后自动调用;增量合并)');
598
1077
  out(' yotta-skills --route "<需求摘要>" 给出场景组合、调用顺序、缺失技能安装建议');
1078
+ out(' yotta-skills hook capabilities 查看宿主六事件能力矩阵');
1079
+ out(' yotta-skills hook evaluate --event <event> --manifest <file> 评估 hook 声明并留证');
1080
+ out(' yotta-skills hook bind --manifest <file> 注册 hook 声明(幂等)');
1081
+ out(' yotta-skills hook unbind <binding-id> 反注册 hook 声明');
599
1082
  out('');
600
1083
  out('选项:');
601
1084
  out(' --agent <name> 智能体键名(--list 可查看;未知智能体请用 --dir)');
@@ -606,11 +1089,17 @@ function printHelp() {
606
1089
  out(' --npm <path> 指定 npm 可执行文件(默认 npm / npm.cmd)');
607
1090
  out(' --python <path> 指定 python 可执行文件(元信 scan 用)');
608
1091
  out(' --verify <path> 指定 yotta_verify.py 路径(默认找目标目录已装的元信)');
609
- out(' --json inventory / reindex / route 时输出 JSON');
1092
+ out(' --json inventory / reindex / route / doctor / rollback 时输出 JSON');
1093
+ out(' --slug <slug> doctor / rollback 时只处理指定技能');
610
1094
  out(' --route <需求> 静态编排路由(输出组合 / 顺序 / 依据 / 缺失技能建议)');
611
1095
  out(' --check update 时仅只读检查更新(联网对 npm 最新版本,不改动;退出码 0/3/1)');
1096
+ out(' --scheduled 与 update --check 合用:后台周检入口(未到期不联网;到期单次检查并写缓存)');
612
1097
  out(' --auto update 时检查到家族更新后自动更新(仅 yotta-* 家族,含装前扫描)');
613
1098
  out(' --registry <url> npm registry 地址(默认 https://registry.npmjs.org/;YOTTA_SKILLS_REGISTRY 覆盖)');
1099
+ out(' --host <name> hook 适配宿主名(默认 generic;当前已实测 codex)');
1100
+ out(' --event <event> hook 六事件之一(before_start / before_tool / before_install / before_publish / after_milestone / before_send)');
1101
+ out(' --manifest <file> hook evaluate / bind 使用的 skill-manifest.json 路径');
1102
+ out(' --context <json> hook evaluate 的检查结果 JSON(checks / wrapperRegistered / evidence)');
614
1103
  out(' --project inventory / reindex 时附加扫描当前项目 .agents/skills / .codex/skills');
615
1104
  out(' --no-reindex 安装 / 更新后不自动重扫注册表');
616
1105
  out(' -h, --help 帮助');
@@ -735,20 +1224,121 @@ function maybeAutoReindex(opts, dest) {
735
1224
  }
736
1225
  }
737
1226
 
1227
+ // ── 运行时 hook 适配层(P0-3) ───────────────────────────────────────────────
1228
+ function readJsonFile(file, label) {
1229
+ try {
1230
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
1231
+ } catch (error) {
1232
+ die(label + ' 读取失败: ' + error.message, 2, '请确认文件存在且为合法 JSON。');
1233
+ }
1234
+ }
1235
+
1236
+ function runHook(opts) {
1237
+ const action = opts.rest[0] || 'capabilities';
1238
+ const host = opts.host || 'generic';
1239
+ const adapter = hookAdapterLib.createHookAdapter({ host });
1240
+
1241
+ if (action === 'capabilities') {
1242
+ const payload = { host, capabilities: adapter.capabilities() };
1243
+ if (opts.json) out(JSON.stringify(payload, null, 2));
1244
+ else {
1245
+ out('yotta-skills(元阁)v' + VERSION + ' —— hook capabilities');
1246
+ out('宿主: ' + host);
1247
+ hookAdapterLib.EVENTS.forEach((event) => out(' ' + event.padEnd(18) + payload.capabilities[event]));
1248
+ }
1249
+ return;
1250
+ }
1251
+
1252
+ if (action === 'evaluate') {
1253
+ if (!opts.event) die('hook evaluate 缺少 --event', 2, '请提供六个统一事件之一。');
1254
+ if (!opts.manifest) die('hook evaluate 缺少 --manifest', 2, '请提供 skill-manifest.json 路径。');
1255
+ const manifest = readJsonFile(opts.manifest, 'manifest');
1256
+ let context = {};
1257
+ if (opts.context) {
1258
+ try {
1259
+ context = JSON.parse(opts.context);
1260
+ } catch (error) {
1261
+ die('--context 不是合法 JSON: ' + error.message, 2, '请传入 JSON 对象。');
1262
+ }
1263
+ }
1264
+ if (!context || typeof context !== 'object' || Array.isArray(context)) {
1265
+ die('--context 必须是 JSON 对象', 2);
1266
+ }
1267
+ const result = adapter.evaluate(opts.event, manifest, context);
1268
+ const entries = result.results.length > 0
1269
+ ? result.results
1270
+ : [{ event: opts.event, skill: manifest.slug, action: null, decision: result.decision, evidence: {}, capability: adapter.capabilities()[opts.event] || 'unsupported', reason: result.user_message }];
1271
+ for (const entry of entries) {
1272
+ hookAdapterLib.appendHookEvidence({
1273
+ event: entry.event,
1274
+ skill: entry.skill,
1275
+ action: entry.action,
1276
+ result: entry.decision,
1277
+ capability: entry.capability,
1278
+ evidence: entry.evidence,
1279
+ reason: entry.reason,
1280
+ }, { homeDir: os.homedir() });
1281
+ }
1282
+ if (opts.json) out(JSON.stringify({ host, event: opts.event, ...result }, null, 2));
1283
+ else {
1284
+ out('yotta-skills(元阁)v' + VERSION + ' —— hook evaluate');
1285
+ out('宿主: ' + host + ' / 事件: ' + opts.event);
1286
+ out('决策: ' + result.decision + ' / verified: ' + result.verified);
1287
+ out(result.user_message);
1288
+ for (const entry of result.results) out(' ' + entry.action + ': ' + entry.decision + '(' + entry.capability + ')');
1289
+ }
1290
+ process.exitCode = result.decision === 'block' ? 3 : 0;
1291
+ return;
1292
+ }
1293
+
1294
+ if (action === 'bind') {
1295
+ if (!opts.manifest) die('hook bind 缺少 --manifest', 2, '请提供 skill-manifest.json 路径。');
1296
+ const manifest = readJsonFile(opts.manifest, 'manifest');
1297
+ const bindings = adapter.bind(manifest, opts.event || null);
1298
+ if (opts.json) out(JSON.stringify({ host, bindings }, null, 2));
1299
+ else {
1300
+ out('yotta-skills(元阁)v' + VERSION + ' —— hook bind');
1301
+ out('宿主: ' + host + ' / 绑定 ' + bindings.length + ' 项');
1302
+ for (const binding of bindings) out(' ' + binding.id + ' ' + binding.event + ' ' + binding.action + ' ' + binding.capability);
1303
+ }
1304
+ return;
1305
+ }
1306
+
1307
+ if (action === 'unbind') {
1308
+ const id = opts.rest[1];
1309
+ if (!id) die('hook unbind 缺少 binding id', 2, '先用 hook bind / list 获取 id。');
1310
+ const removed = adapter.unbind(id);
1311
+ if (opts.json) out(JSON.stringify({ host, id, removed }, null, 2));
1312
+ else out(removed ? '已解除绑定: ' + id : '未找到绑定: ' + id);
1313
+ process.exitCode = removed ? 0 : 1;
1314
+ return;
1315
+ }
1316
+
1317
+ die('未知 hook 子命令: ' + action, 2, '支持 capabilities / evaluate / bind / unbind。');
1318
+ }
1319
+
738
1320
  // ── main ───────────────────────────────────────────────────────────────────
739
1321
  function main() {
740
1322
  const opts = parseArgs(process.argv.slice(2));
741
1323
  if (opts.help) { printHelp(); return; }
742
1324
  if (opts.version) { out('yotta-skills v' + VERSION); return; }
743
- if (opts.list) { printList(opts); return; }
1325
+ if (opts.list && !opts.command) { printList(opts); return; }
744
1326
  if (opts.inventory && !opts.command) { runInventory(opts); return; }
745
1327
  if (opts.reindex && !opts.command) { runReindex(opts); return; }
746
1328
  if (opts.route && !opts.command) { runRoute(opts); return; }
747
1329
 
748
1330
  const command = opts.command || 'install';
1331
+ if (command === 'hook') {
1332
+ runHook(opts);
1333
+ return;
1334
+ }
1335
+ if (command === 'rollback' && opts.list) {
1336
+ runRollback(opts, null);
1337
+ return;
1338
+ }
749
1339
  let dest = resolveTargetDir(opts);
750
1340
  if (!dest && !opts.dryRun) dest = detectProjectDir();
751
- if (!dest && command === 'install' && !opts.dryRun) {
1341
+ if (!dest && (command === 'install' || command === 'doctor' || command === 'rollback') && !opts.dryRun) {
752
1342
  die('未指定目标:请用 --agent <name> 或 --dir <path>(当前目录未检测到项目级技能目录)。', 4, '未收录智能体也可用 --dir 指定其技能目录。');
753
1343
  }
754
1344
 
@@ -767,22 +1357,23 @@ function main() {
767
1357
  return;
768
1358
  }
769
1359
 
770
- if (command === 'update') {
1360
+ if (command === 'doctor') {
1361
+ runDoctor(opts, dest);
1362
+ } else if (command === 'rollback') {
1363
+ runRollback(opts, dest);
1364
+ } else if (command === 'update') {
771
1365
  if (opts.check || opts.auto) {
772
1366
  var runFn = opts.auto ? runUpdateAuto : runUpdateCheck;
773
1367
  runFn(opts, dest).then(function (r) {
774
- if (opts.json && !opts.auto) {
775
- out(JSON.stringify({ dest: dest, updatable: r.rows.filter(function (x) { return x.hasUpdate; }), updates: r.updates, latest: r.latest, failed: r.failed, nonFamily: r.nonFamily }, null, 2));
776
- }
777
1368
  process.exitCode = r.code;
778
1369
  });
779
1370
  } else {
780
- runUpdate(opts, dest);
781
- maybeAutoReindex(opts, dest);
1371
+ const result = runUpdate(opts, dest);
1372
+ if (!result.failed) maybeAutoReindex(opts, dest);
782
1373
  }
783
1374
  } else {
784
- runInstall(opts, dest);
785
- maybeAutoReindex(opts, dest);
1375
+ const result = runInstall(opts, dest);
1376
+ if (!result.failed) maybeAutoReindex(opts, dest);
786
1377
  }
787
1378
  }
788
1379