@yottameta/yotta-skills-plugin 0.29.0 → 0.29.1

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.
@@ -87,7 +87,9 @@ const AGENT_DIRS = {
87
87
  pochi: { label: 'Pochi', dirs: ['.pochi/skills'] },
88
88
  adal: { label: 'AdaL', dirs: ['.adal/skills'] },
89
89
  dsh: { label: 'DSH / DeepSeek Harness', dirs: ['.dsh/skills'] },
90
- yottacode: { label: 'YottaCode', dirs: ['.yottacode/skills'], verified: false },
90
+ // YottaCode 自带三层技能管理(skill-inventory / skill / user-skills),
91
+ // 不纳入元阁接管:neverLink 表示发现 / 显示 / 链接全部跳过(0.29.1 U2)。
92
+ yottacode: { label: 'YottaCode', dirs: ['.yottacode/skills'], verified: false, neverLink: true },
91
93
  box: { label: 'Box Agent', dirs: ['.box-agent/skills'], verified: false },
92
94
  lmstudio: { label: 'LM Studio', dirs: ['.lmstudio/skills'] },
93
95
  ccswitch: { label: 'CC Switch', dirs: ['.cc-switch/skills'] },
@@ -167,6 +169,14 @@ function resolveUserDir(rel, options) {
167
169
  return path.join(home, rel);
168
170
  }
169
171
 
172
+ const YOTTACODE_PATH_RE = /(^|[\\/])\.yottacode([\\/]|$)/i;
173
+
174
+ /** YottaCode 路径判定(不接管红线;发现 / 链接 / 注册 / 标记统一 fail-closed)。 */
175
+ function isYottaCodeDir(dir) {
176
+ if (!dir) return false;
177
+ return YOTTACODE_PATH_RE.test(path.resolve(dir));
178
+ }
179
+
170
180
  /** All known agent skill directories, including missing ones. */
171
181
  function knownRoots(options) {
172
182
  const roots = [];
@@ -179,8 +189,11 @@ function knownRoots(options) {
179
189
  roots.push({ dir: resolved, agentId, label, known: true, verified: verified !== false });
180
190
  };
181
191
  for (const [agentId, info] of Object.entries(AGENT_DIRS)) {
192
+ if (info.neverLink) continue;
182
193
  for (const rel of info.dirs) {
183
- add(resolveUserDir(rel, options), agentId, info.label, info.verified !== false);
194
+ const dir = resolveUserDir(rel, options);
195
+ if (isYottaCodeDir(dir)) continue;
196
+ add(dir, agentId, info.label, info.verified !== false);
184
197
  }
185
198
  }
186
199
  return roots;
@@ -213,7 +226,7 @@ function envRoots(options) {
213
226
  add(envPath(env, 'DSH_AGENTS_HOME') && path.join(env.DSH_AGENTS_HOME, 'skills'), 'dsh', 'DSH Agents(DSH_AGENTS_HOME)');
214
227
  add(envPath(env, 'OPENCLAW_STATE_DIR') && path.join(env.OPENCLAW_STATE_DIR, 'skills'), 'openclaw', 'OpenClaw(OPENCLAW_STATE_DIR)');
215
228
  add(envPath(env, 'CLAUDE_CONFIG_DIR') && path.join(env.CLAUDE_CONFIG_DIR, 'skills'), 'claude', 'Claude Code(CLAUDE_CONFIG_DIR)');
216
- add(envPath(env, 'YOTTACODE_HOME') && path.join(env.YOTTACODE_HOME, 'skills'), 'yottacode', 'YottaCode(YOTTACODE_HOME)');
229
+ // YOTTACODE_HOME 不纳入元阁接管(YottaCode 自带三层技能管理,0.29.1 U2)。
217
230
 
218
231
  const extra = String(env.YOTTA_SKILLS_DISCOVERY_ROOTS || '')
219
232
  .split(path.delimiter)
@@ -255,4 +268,5 @@ module.exports = {
255
268
  envRoots,
256
269
  bridgeOnlyDirs,
257
270
  isBridgeOnlyDir,
271
+ isYottaCodeDir,
258
272
  };
@@ -82,6 +82,7 @@ function isNoisePath(dir, baseDir) {
82
82
  if (part === 'temp' || part === 'tmp') return true;
83
83
  if (/\.bak$/.test(part)) return true;
84
84
  if (part === 'plugin-sources' || part === 'plugins' || part === 'node_modules') return true;
85
+ if (part === '.yottacode') return true;
85
86
  if (part === 'candidate' || part.startsWith('candidate-') || part.includes('staging')) return true;
86
87
  if (part === 'memories' || part === 'connectors' || part === 'workspace') return true;
87
88
  }
@@ -192,7 +193,7 @@ function commonRoots(options) {
192
193
  add(env.OPENCLAW_STATE_DIR, 2);
193
194
  add(env.CLAUDE_CONFIG_DIR, 2);
194
195
  add(env.CODEX_HOME, 2);
195
- add(env.YOTTACODE_HOME, 2);
196
+ // YOTTACODE_HOME 不扫描(YottaCode 自带三层技能管理,0.29.1 U2)。
196
197
 
197
198
  // A portable agent install often lives beside the current workspace. The
198
199
  // scanner only follows agent-looking branches, so this stays bounded.
@@ -286,6 +287,28 @@ function judgeHostState(host) {
286
287
  return 'missing';
287
288
  }
288
289
 
290
+ /**
291
+ * 0.29.1 U2:installed 配对优先级 ——
292
+ * user-registry / 目录覆盖 / 已核实映射 > env > 未核实映射 > 自动发现。
293
+ */
294
+ function hostPairingPriority(host) {
295
+ if (host.detection === 'user-registry' || host.registry) return 0;
296
+ if (host.detection === 'override') return 1;
297
+ if (host.verified && host.detection === 'mapping') return 1;
298
+ if (host.detection === 'env') return 2;
299
+ if (host.detection === 'mapping') return 3;
300
+ return 4;
301
+ }
302
+
303
+ /** 同级按技能数降序 + 路径稳定排序(消除 last-write-wins)。 */
304
+ function compareHostCandidates(a, b) {
305
+ const priority = hostPairingPriority(a) - hostPairingPriority(b);
306
+ if (priority) return priority;
307
+ const count = (b.skillCount || 0) - (a.skillCount || 0);
308
+ if (count) return count;
309
+ return String(a.dir).localeCompare(String(b.dir));
310
+ }
311
+
289
312
  /**
290
313
  * Discover hosts and their skill directories.
291
314
  *
@@ -301,19 +324,56 @@ function discoverHosts(options) {
301
324
  const seen = new Map();
302
325
  const errors = [];
303
326
 
327
+ // 0.29.1 U2:用户级「不接管」名单 + 目录覆盖(hosts.json;旧文件向后兼容)。
328
+ let registry = { hosts: [], excluded: [], overrides: [] };
329
+ try {
330
+ registry = hostsRegistry.readHostsRegistry(options);
331
+ } catch (_) { /* registry 读取失败不影响发现 */ }
332
+ const overridesByAgent = new Map(
333
+ (registry.overrides || []).map((item) => [String(item.agentId).toLowerCase(), item]),
334
+ );
335
+ const previousDirKeys = new Set();
336
+ for (const item of registry.overrides || []) {
337
+ for (const prev of item.previousDirs || []) previousDirKeys.add(normalizePath(prev).toLowerCase());
338
+ }
339
+ // YottaCode 不接管:.yottacode 路径 + YOTTACODE_HOME 整棵子树(含任意自定义目录名)。
340
+ const yottacodeHome = envObject(options).YOTTACODE_HOME
341
+ ? path.resolve(envObject(options).YOTTACODE_HOME)
342
+ : null;
343
+ const isYottaCodePath = (dir) => {
344
+ if (!dir) return false;
345
+ if (agentDirs.isYottaCodeDir(dir)) return true;
346
+ if (!yottacodeHome) return false;
347
+ const resolved = path.resolve(dir);
348
+ const relative = path.relative(yottacodeHome, resolved);
349
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
350
+ };
351
+ const isExcluded = (meta) => Boolean(hostsRegistry.excludedEntryFor(registry, meta || {}));
352
+ const skipDir = (dir, agentId) => isYottaCodePath(dir) || isExcluded({ dir, agentId });
353
+
304
354
  for (const root of agentDirs.knownRoots(options)) {
355
+ const override = root.agentId ? overridesByAgent.get(String(root.agentId).toLowerCase()) : null;
356
+ if (override) {
357
+ if (skipDir(override.dir, root.agentId)) continue;
358
+ addRoot(roots, seen, override.dir, {
359
+ ...root,
360
+ label: override.label || root.label,
361
+ verified: true,
362
+ detection: 'override',
363
+ });
364
+ continue;
365
+ }
366
+ if (skipDir(root.dir, root.agentId)) continue;
305
367
  addRoot(roots, seen, root.dir, { ...root, detection: 'mapping' });
306
368
  }
307
369
  for (const root of agentDirs.envRoots(options)) {
370
+ if (skipDir(root.dir, root.agentId)) continue;
308
371
  addRoot(roots, seen, root.dir, { ...root, detection: root.env ? 'env' : 'workspace' });
309
372
  }
310
373
  // 合并顺序:内置映射 → 环境变量 → 用户注册表(F2)→ 自动发现;
311
374
  // 同 realpath 用户注册优先(分类 = 已核实,detection = user-registry)。
312
- let registry = { hosts: [] };
313
- try {
314
- registry = hostsRegistry.readHostsRegistry(options);
315
- } catch (_) { /* registry 读取失败不影响发现 */ }
316
375
  for (const item of registry.hosts || []) {
376
+ if (skipDir(item.dir, item.agentId)) continue;
317
377
  addRoot(roots, seen, item.dir, {
318
378
  detection: 'user-registry',
319
379
  label: item.label || '自定义宿主',
@@ -329,6 +389,9 @@ function discoverHosts(options) {
329
389
  maxDepth: root.maxDepth,
330
390
  baseDir: options.homeDir || os.homedir(),
331
391
  })) {
392
+ if (skipDir(found.dir, null)) continue;
393
+ // 被目录覆盖替换掉的旧映射目录不再进入默认发现 / 链接。
394
+ if (previousDirKeys.has(normalizePath(found.dir).toLowerCase())) continue;
332
395
  addRoot(roots, seen, found.dir, {
333
396
  detection: 'discovered',
334
397
  label: '自动发现:' + path.basename(found.parent),
@@ -362,23 +425,44 @@ function discoverHosts(options) {
362
425
  installedSeen.add(key);
363
426
  installed.push({ label, source, hasSkillsDir: false });
364
427
  };
428
+ // 0.29.1 U2:不接管(excluded)/ neverLink(YottaCode)宿主的应用标记同样跳过显示。
429
+ const isMarkerSuppressed = (name) => {
430
+ for (const [agentId, info] of Object.entries(agentDirs.AGENT_DIRS)) {
431
+ if (!info.neverLink) continue;
432
+ if (markerMatchesHost(name, { agentId, label: info.label })) return true;
433
+ }
434
+ return (registry.excluded || []).some((item) => {
435
+ if (item.kind !== 'agent') return false;
436
+ const info = agentDirs.AGENT_DIRS[String(item.value).toLowerCase()] || {};
437
+ return markerMatchesHost(name, { agentId: item.value, label: info.label || item.value });
438
+ });
439
+ };
365
440
  for (const name of markers) {
441
+ if (isMarkerSuppressed(name)) continue;
366
442
  if (knownLabelMatches(name) || AGENT_HINT.test(name)) addInstalled(name, '应用标记');
367
443
  }
368
444
  for (const host of roots) {
369
445
  if (host.detection === 'user-registry') addInstalled(host.label, '用户注册');
370
446
  }
447
+ // 0.29.1 U2:配对确定性优选(先收集全部候选,再按优先级选定),消除 last-write-wins。
448
+ const pairCandidates = new Map();
371
449
  for (const host of roots) {
372
450
  if (!host.exists) continue;
373
451
  if (!host.markerEvidence && host.detection !== 'user-registry') continue;
374
- const item = installed.find((entry) => markerMatchesHost(entry.label, host));
375
- if (item) {
376
- item.hasSkillsDir = true;
377
- item.skillDir = host.dir;
378
- item.skillCount = host.skillCount;
379
- item.agentId = host.agentId;
452
+ for (const item of installed) {
453
+ if (!markerMatchesHost(item.label, host)) continue;
454
+ if (!pairCandidates.has(item)) pairCandidates.set(item, []);
455
+ pairCandidates.get(item).push(host);
380
456
  }
381
457
  }
458
+ for (const [item, hosts] of pairCandidates) {
459
+ hosts.sort(compareHostCandidates);
460
+ const host = hosts[0];
461
+ item.hasSkillsDir = true;
462
+ item.skillDir = host.dir;
463
+ item.skillCount = host.skillCount;
464
+ item.agentId = host.agentId;
465
+ }
382
466
 
383
467
  return {
384
468
  generatedAt: new Date().toISOString(),
@@ -388,6 +472,19 @@ function discoverHosts(options) {
388
472
  };
389
473
  }
390
474
 
475
+ /**
476
+ * 0.29.1 U2:未配对且无技能目录的已安装标记(CLI / 面板共用单一真源)。
477
+ * 已配对(hasSkillsDir)或 label 与已显示宿主重复的标记不再出现在「无技能目录」区。
478
+ */
479
+ function unpairedMarkers(discovery) {
480
+ const shown = new Set();
481
+ for (const host of (discovery && discovery.hosts) || []) {
482
+ if (host.exists || host.state === 'marker-only') shown.add(String(host.label).toLowerCase());
483
+ }
484
+ return ((discovery && discovery.installed) || []).filter((item) =>
485
+ !item.hasSkillsDir && !shown.has(String(item.label).toLowerCase()));
486
+ }
487
+
391
488
  module.exports = {
392
489
  AGENT_HINT,
393
490
  discoverHosts,
@@ -395,4 +492,5 @@ module.exports = {
395
492
  countSkills,
396
493
  markerMatchesHost,
397
494
  judgeHostState,
495
+ unpairedMarkers,
398
496
  };
@@ -48,6 +48,7 @@ const CLI_GLOBAL_OPTIONS = [
48
48
  { flag: '--label', arg: '<名称>', what: 'hub hosts add 的显示名称(默认取目录名)', when: '注册自定义宿主时', caution: '最长 48 字符,禁控制字符' },
49
49
  { flag: '--id', arg: '<agentId>', what: 'hub hosts add / remove 的智能体标识', when: '按智能体注册或移除自定义宿主时', caution: '' },
50
50
  { flag: '--purge', arg: '', what: 'hub hosts remove 清理残留目录(默认预览;--yes 执行)', when: '清理实体未确认的残留技能目录时', caution: '只删指向 Hub 的链接;目录移入回收站保留 7 天' },
51
+ { flag: '--clear', arg: '', what: 'hub hosts set 恢复内置映射(删除目录覆盖)', when: '想撤销目录覆盖时', caution: '' },
51
52
  { flag: '--scan', arg: '', what: 'hub adopt 只读预演(默认行为)', when: '先看收编候选与冲突时', caution: '' },
52
53
  { flag: '--apply', arg: '', what: 'hub adopt 执行收编', when: '确认候选后执行时', caution: '执行时逐项运行元信扫描;high / critical 默认阻断' },
53
54
  { flag: '--in-place', arg: '', what: 'hub adopt 原地登记,不复制', when: '想让 Hub 直接指向原目录时', caution: '原目录删除即断链;默认复制保真更安全' },
@@ -78,7 +79,7 @@ const CLI_HELP_MODEL = [
78
79
  {
79
80
  name: 'update',
80
81
  usage: 'update [--agent <name> | --dir <path>] [--installed-only] [--check [--scheduled] | --auto] [--only <a,b> | --domain <name>]',
81
- what: '增量更新已装技能(补齐缺失 / 升级版本不一致)',
82
+ what: '增量更新已装技能(补齐缺失 / 升级;只升不降:本地更高保留,--force 才显式降级)',
82
83
  when: '日常维护、只读检查更新、后台周检、自动跟进时',
83
84
  options: ['--agent', '--dir', '--installed-only', '--check', '--scheduled', '--auto', '--only', '--domain', '--dry-run', '--skip-scan'],
84
85
  },
@@ -179,8 +180,11 @@ const CLI_HELP_MODEL = [
179
180
  { name: 'hosts remove', usage: 'hub hosts remove <dir | --id <agentId>> [--purge [--yes]] [--json]', what: '移除注册(绝不删目录);--purge = 清理残留目录(默认预览,--yes 执行)', when: '注销自定义宿主、清理残留目录时', options: ['--id', '--purge', '--yes', '--json'] },
180
181
  { name: 'hosts list', usage: 'hub hosts list [--json]', what: '查看自定义宿主注册表(hosts.json)', when: '核对已注册的自定义目录时', options: ['--json'] },
181
182
  { name: 'hosts mark', usage: 'hub hosts mark <dir> --state <available|orphan|ignored> [--json]', what: '手动标记宿主状态(可用 / 残留 / 忽略)', when: '实体证据不足需要人工确认时', options: ['--state', '--json'] },
183
+ { name: 'hosts exclude', usage: 'hub hosts exclude <agentId|dir> [--json]', what: '加入「不接管」名单(发现 / 显示 / 链接三层跳过)', when: '某宿主 / 目录不归元阁管理时', options: ['--json'] },
184
+ { name: 'hosts include', usage: 'hub hosts include <agentId|dir> [--json]', what: '恢复接管(移出「不接管」名单)', when: '重新纳入发现 / 链接时', options: ['--json'] },
185
+ { name: 'hosts set', usage: 'hub hosts set <agentId> --dir <dir> [--label <名称>] [--json] | hub hosts set <agentId> --clear', what: '目录覆盖:用户级覆盖内置映射;--clear 恢复默认', when: '宿主技能目录与内置映射不一致时', options: ['--dir', '--label', '--clear', '--json'] },
182
186
  { name: 'install', usage: 'hub install [skill...]', what: '把元技能装进 Hub 真源(清单 27 + 特殊家族 5)', when: '初始化 Hub 或补装技能时', options: ['--hub'] },
183
- { name: 'update', usage: 'hub update', what: '更新 Hub 内的元技能(非元技能不参与)', when: '日常维护 Hub 真源时', options: ['--hub'] },
187
+ { name: 'update', usage: 'hub update', what: '更新 Hub 内的元技能(非元技能不参与;只升不降)', when: '日常维护 Hub 真源时', options: ['--hub'] },
184
188
  { name: 'adopt', usage: 'hub adopt [--scan | --apply] [--include <a,b>] [--in-place]', what: '把各宿主已有技能收编进 Hub(默认复制保真)', when: '接管机器上已装的技能时', options: ['--scan', '--apply', '--include', '--in-place', '--force', '--skip-scan', '--allow-unverified'] },
185
189
  { name: 'refresh', usage: 'hub refresh <slug> --from <path>', what: '手动同步非元阁技能(无统一更新源)', when: '外部技能更新后刷新 Hub 副本时', options: ['--from'] },
186
190
  { name: 'link', usage: 'hub link [skill...] [--all | --agent <id> | --dir <path>] [--include-discovered] [--dry-run]', what: '把 Hub 技能链接到宿主目录(指定智能体 / 全量)', when: '分发技能到各智能体时', options: ['--all', '--agent', '--dir', '--include-discovered', '--force', '--dry-run'] },
@@ -293,6 +297,9 @@ const CLI_HELP_QUICK = [
293
297
  { cmd: 'hub hosts add', example: 'yotta-skills hub hosts add <技能目录>' },
294
298
  { cmd: 'hub hosts list', example: 'yotta-skills hub hosts list' },
295
299
  { cmd: 'hub hosts mark', example: 'yotta-skills hub hosts mark <技能目录> --state orphan' },
300
+ { cmd: 'hub hosts exclude', example: 'yotta-skills hub hosts exclude <agentId|目录>' },
301
+ { cmd: 'hub hosts include', example: 'yotta-skills hub hosts include <agentId|目录>' },
302
+ { cmd: 'hub hosts set', example: 'yotta-skills hub hosts set <agentId> --dir <目录>' },
296
303
  { cmd: 'hub hosts remove', example: 'yotta-skills hub hosts remove <技能目录>', note: '只移除注册,不删目录' },
297
304
  { cmd: 'hub hosts remove', example: 'yotta-skills hub hosts remove <技能目录> --purge', danger: true, note: '清理残留目录:先预览,--yes 才执行;目录入回收站保留 7 天' },
298
305
  { cmd: 'hub install', example: 'yotta-skills hub install' },
@@ -42,10 +42,19 @@ function readHostsRegistry(opts) {
42
42
  return {
43
43
  version: HOSTS_REGISTRY_VERSION,
44
44
  hosts: value.hosts.filter((item) => item && typeof item.dir === 'string' && item.dir),
45
+ // 0.29.1 U2:不接管名单 + 目录覆盖(可选字段,向后兼容旧文件)。
46
+ excluded: Array.isArray(value.excluded)
47
+ ? value.excluded.filter((item) => item && typeof item.value === 'string' && item.value &&
48
+ (item.kind === 'agent' || item.kind === 'dir'))
49
+ : [],
50
+ overrides: Array.isArray(value.overrides)
51
+ ? value.overrides.filter((item) => item && typeof item.agentId === 'string' && item.agentId &&
52
+ typeof item.dir === 'string' && item.dir)
53
+ : [],
45
54
  };
46
55
  }
47
56
  } catch (_) { /* missing or corrupt registry falls back to empty */ }
48
- return { version: HOSTS_REGISTRY_VERSION, hosts: [] };
57
+ return { version: HOSTS_REGISTRY_VERSION, hosts: [], excluded: [], overrides: [] };
49
58
  }
50
59
 
51
60
  function writeHostsRegistry(opts, registry) {
@@ -55,6 +64,8 @@ function writeHostsRegistry(opts, registry) {
55
64
  fs.writeFileSync(tmp, JSON.stringify({
56
65
  version: HOSTS_REGISTRY_VERSION,
57
66
  hosts: registry.hosts,
67
+ excluded: registry.excluded || [],
68
+ overrides: registry.overrides || [],
58
69
  }, null, 2) + '\n', 'utf8');
59
70
  fs.renameSync(tmp, file);
60
71
  return file;
@@ -84,6 +95,14 @@ function isInside(root, target) {
84
95
  return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
85
96
  }
86
97
 
98
+ /** YottaCode 目标判定:.yottacode 路径或 YOTTACODE_HOME 子树(0.29.1 U2)。 */
99
+ function isYottaCodeTarget(dir, opts) {
100
+ if (agentDirsLib.isYottaCodeDir(dir)) return true;
101
+ const env = (opts && opts.env) || process.env;
102
+ const ycHome = env.YOTTACODE_HOME ? path.resolve(env.YOTTACODE_HOME) : null;
103
+ return Boolean(ycHome && dir && isInside(ycHome, dir));
104
+ }
105
+
87
106
  /** fail-closed 校验:返回中文错误或 null。 */
88
107
  function validateHostDir(dir, opts) {
89
108
  const target = path.resolve(dir);
@@ -101,6 +120,9 @@ function validateHostDir(dir, opts) {
101
120
  if (agentDirsLib.isBridgeOnlyDir(target, opts)) {
102
121
  return '锁 / 数据桥接目录不允许注册:' + target;
103
122
  }
123
+ if (isYottaCodeTarget(target, opts)) {
124
+ return 'YottaCode 自带三层技能管理(skill-inventory / skill / user-skills),不纳入元阁接管:' + target;
125
+ }
104
126
  return null;
105
127
  }
106
128
 
@@ -165,6 +187,9 @@ function markHost(opts, input) {
165
187
  }
166
188
  const dir = input && input.dir ? path.resolve(input.dir) : null;
167
189
  if (!dir) return { ok: false, error: '缺少目录' };
190
+ if (isYottaCodeTarget(dir, options)) {
191
+ return { ok: false, error: 'YottaCode 自带三层技能管理,不纳入元阁接管,不能标记:' + dir };
192
+ }
168
193
  const registry = readHostsRegistry(options);
169
194
  let entry = registry.hosts.find((item) => samePath(item.dir, dir));
170
195
  if (!entry) {
@@ -181,6 +206,118 @@ function markHost(opts, input) {
181
206
  return { ok: true, entry, registry };
182
207
  }
183
208
 
209
+ /** 0.29.1 U2:不接管名单匹配(agentId 或目录);返回命中条目或 null。 */
210
+ function excludedEntryFor(registry, input) {
211
+ const agentId = input && input.agentId ? String(input.agentId).toLowerCase() : null;
212
+ const dir = input && input.dir ? path.resolve(input.dir) : null;
213
+ for (const item of (registry && registry.excluded) || []) {
214
+ if (!item) continue;
215
+ if (item.kind === 'agent' && agentId && String(item.value).toLowerCase() === agentId) return item;
216
+ if (item.kind === 'dir' && dir && samePath(item.value, dir)) return item;
217
+ }
218
+ return null;
219
+ }
220
+
221
+ /** 目标归类:已收录 agentId -> agent;否则按目录。 */
222
+ function classifyExcludeTarget(target) {
223
+ const value = String(target || '').trim();
224
+ const agentId = value.toLowerCase();
225
+ if (agentDirsLib.AGENT_DIRS[agentId]) return { kind: 'agent', value: agentId };
226
+ return { kind: 'dir', value: path.resolve(value) };
227
+ }
228
+
229
+ /**
230
+ * 加入「不接管」名单(0.29.1 U2):发现 / 显示 / 链接三层全部跳过,
231
+ * `--include-discovered` 也不纳入;幂等。
232
+ */
233
+ function excludeHost(opts, input) {
234
+ const options = opts || {};
235
+ const target = input && input.target ? String(input.target).trim() : '';
236
+ if (!target) return { ok: false, error: '缺少目标:hub hosts exclude <agentId|目录>' };
237
+ const { kind, value } = classifyExcludeTarget(target);
238
+ if (kind === 'dir') {
239
+ if (isYottaCodeTarget(value, options)) {
240
+ return { ok: false, error: 'YottaCode 自带三层技能管理,不纳入元阁接管,无需加入不接管名单:' + value };
241
+ }
242
+ if (agentDirsLib.isBridgeOnlyDir(value, options)) {
243
+ return { ok: false, error: '锁 / 数据桥接目录本就不参与链接,无需加入不接管名单:' + value };
244
+ }
245
+ }
246
+ const registry = readHostsRegistry(options);
247
+ const existing = excludedEntryFor(registry, { agentId: kind === 'agent' ? value : null, dir: kind === 'dir' ? value : null });
248
+ if (existing) return { ok: true, entry: existing, registry, already: true };
249
+ const label = input && input.label
250
+ ? String(input.label).trim()
251
+ : kind === 'agent'
252
+ ? ((agentDirsLib.AGENT_DIRS[value] || {}).label || value)
253
+ : path.basename(value);
254
+ const entry = { kind, value, label, addedAt: nowIso() };
255
+ registry.excluded = (registry.excluded || []).concat([entry]);
256
+ writeHostsRegistry(options, registry);
257
+ return { ok: true, entry, registry };
258
+ }
259
+
260
+ /** 移出「不接管」名单(恢复接管)。 */
261
+ function includeHost(opts, input) {
262
+ const options = opts || {};
263
+ const target = input && input.target ? String(input.target).trim() : '';
264
+ if (!target) return { ok: false, error: '缺少目标:hub hosts include <agentId|目录>' };
265
+ const { kind, value } = classifyExcludeTarget(target);
266
+ const registry = readHostsRegistry(options);
267
+ const index = (registry.excluded || []).findIndex((item) => item.kind === kind &&
268
+ (kind === 'agent' ? String(item.value).toLowerCase() === value : samePath(item.value, value)));
269
+ if (index < 0) return { ok: false, error: '未在不接管名单:' + target };
270
+ const removed = registry.excluded.splice(index, 1)[0];
271
+ writeHostsRegistry(options, registry);
272
+ return { ok: true, removed, registry };
273
+ }
274
+
275
+ /**
276
+ * 目录覆盖(0.29.1 U2):用户级覆盖已收录 agentId 的内置映射目录;
277
+ * 被覆盖的旧目录不再进入默认发现 / 链接。
278
+ */
279
+ function setHostOverride(opts, input) {
280
+ const options = opts || {};
281
+ const agentId = input && input.agentId ? String(input.agentId).trim().toLowerCase() : '';
282
+ if (!agentId) return { ok: false, error: '缺少 agentId:hub hosts set <agentId> --dir <目录>(恢复默认用 --clear)' };
283
+ const info = agentDirsLib.AGENT_DIRS[agentId];
284
+ if (!info) return { ok: false, error: '未收录智能体: ' + agentId };
285
+ if (info.neverLink) return { ok: false, error: 'YottaCode 不纳入元阁接管,不能设置目录覆盖。' };
286
+ const dir = input && input.dir ? path.resolve(input.dir) : null;
287
+ if (!dir) return { ok: false, error: '缺少目录:hub hosts set <agentId> --dir <目录>(恢复默认用 --clear)' };
288
+ const guard = validateHostDir(dir, options);
289
+ if (guard) return { ok: false, error: guard };
290
+ const label = input && input.label ? String(input.label).trim() : info.label;
291
+ const labelError = validateLabel(label);
292
+ if (labelError) return { ok: false, error: labelError };
293
+ const registry = readHostsRegistry(options);
294
+ const entry = {
295
+ agentId,
296
+ dir,
297
+ label,
298
+ previousDirs: (info.dirs || []).map((rel) => agentDirsLib.resolveUserDir(rel, options)),
299
+ addedAt: nowIso(),
300
+ };
301
+ const index = (registry.overrides || []).findIndex((item) => String(item.agentId).toLowerCase() === agentId);
302
+ if (index >= 0) registry.overrides[index] = entry;
303
+ else registry.overrides = (registry.overrides || []).concat([entry]);
304
+ writeHostsRegistry(options, registry);
305
+ return { ok: true, entry, registry };
306
+ }
307
+
308
+ /** 清除目录覆盖(恢复内置映射)。 */
309
+ function clearHostOverride(opts, input) {
310
+ const options = opts || {};
311
+ const agentId = input && input.agentId ? String(input.agentId).trim().toLowerCase() : '';
312
+ if (!agentId) return { ok: false, error: '缺少 agentId:hub hosts set <agentId> --clear' };
313
+ const registry = readHostsRegistry(options);
314
+ const index = (registry.overrides || []).findIndex((item) => String(item.agentId).toLowerCase() === agentId);
315
+ if (index < 0) return { ok: false, error: '未找到目录覆盖:' + agentId };
316
+ const removed = registry.overrides.splice(index, 1)[0];
317
+ writeHostsRegistry(options, registry);
318
+ return { ok: true, removed, registry };
319
+ }
320
+
184
321
  module.exports = {
185
322
  HOSTS_REGISTRY_VERSION,
186
323
  MANUAL_STATES,
@@ -194,4 +331,9 @@ module.exports = {
194
331
  addHost,
195
332
  removeHost,
196
333
  markHost,
334
+ excludedEntryFor,
335
+ excludeHost,
336
+ includeHost,
337
+ setHostOverride,
338
+ clearHostOverride,
197
339
  };
@@ -204,12 +204,17 @@ function hostsPayload(ctx) {
204
204
  brokenLinks: hostLinks.filter((link) => link.status !== 'ok').length,
205
205
  };
206
206
  });
207
+ const registry = hostsRegistryLib.readHostsRegistry({ homeDir: ctx.homeDir, env: ctx.env });
207
208
  return {
208
209
  generatedAt: found.generatedAt,
209
210
  hubDir: ctx.hubDir,
210
211
  standard: hubLib.STANDARD_ID,
211
212
  hosts,
212
213
  installed: found.installed,
214
+ // 0.29.1 U2:面板只渲染未配对标记(与 CLI hubHostLines 共用单一真源)。
215
+ unpaired: discoveryLib.unpairedMarkers(found),
216
+ excluded: registry.excluded || [],
217
+ overrides: registry.overrides || [],
213
218
  links,
214
219
  hubSkills,
215
220
  summary: {
@@ -219,6 +224,9 @@ function hostsPayload(ctx) {
219
224
  discovered: hosts.filter((host) => host.exists && !host.verified && !host.bridgeOnly).length,
220
225
  bridge: hosts.filter((host) => host.exists && host.bridgeOnly).length,
221
226
  installedMarks: found.installed.length,
227
+ unpairedMarks: discoveryLib.unpairedMarkers(found).length,
228
+ excluded: (registry.excluded || []).length,
229
+ overrides: (registry.overrides || []).length,
222
230
  links: links.length,
223
231
  brokenLinks: links.filter((link) => link.status !== 'ok').length,
224
232
  // 0.29.0 F3:宿主生命周期状态细分(面板分组 / 筛选)。
@@ -603,6 +611,70 @@ function writeHostPurge(ctx, body) {
603
611
  };
604
612
  }
605
613
 
614
+ // ── 宿主标记 / 目录覆盖 / 不接管(0.29.1 U2;与 CLI 同源 hosts.json) ─────────
615
+ function writeHostMark(ctx, body) {
616
+ const dir = String(body.dir || '').trim();
617
+ if (!dir) return { code: 400, payload: { error: '缺少目录。' } };
618
+ const state = String(body.state || '').trim().toLowerCase();
619
+ const result = hostsRegistryLib.markHost({ homeDir: ctx.homeDir, env: ctx.env }, { dir, state });
620
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
621
+ auditViewHostEvent(ctx, { event: 'hosts.mark', dir: result.entry.dir, state, via: 'view' });
622
+ return { code: 200, payload: { action: 'hosts.mark', entry: result.entry } };
623
+ }
624
+
625
+ function writeHostSet(ctx, body) {
626
+ const agentId = String(body.agentId || '').trim().toLowerCase();
627
+ if (!agentId) return { code: 400, payload: { error: '缺少 agentId。' } };
628
+ const registryOpts = { homeDir: ctx.homeDir, env: ctx.env };
629
+ if (body.clear) {
630
+ const result = hostsRegistryLib.clearHostOverride(registryOpts, { agentId });
631
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
632
+ auditViewHostEvent(ctx, { event: 'hosts.set', agentId, cleared: true, dir: result.removed.dir, via: 'view' });
633
+ return { code: 200, payload: { action: 'hosts.set', cleared: true, removed: result.removed } };
634
+ }
635
+ const dir = String(body.dir || '').trim();
636
+ const result = hostsRegistryLib.setHostOverride(registryOpts, { agentId, dir, label: body.label });
637
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
638
+ auditViewHostEvent(ctx, {
639
+ event: 'hosts.set',
640
+ agentId,
641
+ dir: result.entry.dir,
642
+ label: result.entry.label,
643
+ via: 'view',
644
+ });
645
+ return { code: 200, payload: { action: 'hosts.set', entry: result.entry } };
646
+ }
647
+
648
+ function writeHostExclude(ctx, body) {
649
+ const target = String(body.target || '').trim();
650
+ if (!target) return { code: 400, payload: { error: '缺少 target(agentId 或目录)。' } };
651
+ const result = hostsRegistryLib.excludeHost({ homeDir: ctx.homeDir, env: ctx.env }, { target });
652
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
653
+ auditViewHostEvent(ctx, {
654
+ event: 'hosts.exclude',
655
+ kind: result.entry.kind,
656
+ value: result.entry.value,
657
+ label: result.entry.label,
658
+ via: 'view',
659
+ });
660
+ return { code: 200, payload: { action: 'hosts.exclude', entry: result.entry, already: Boolean(result.already) } };
661
+ }
662
+
663
+ function writeHostInclude(ctx, body) {
664
+ const target = String(body.target || '').trim();
665
+ if (!target) return { code: 400, payload: { error: '缺少 target(agentId 或目录)。' } };
666
+ const result = hostsRegistryLib.includeHost({ homeDir: ctx.homeDir, env: ctx.env }, { target });
667
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
668
+ auditViewHostEvent(ctx, {
669
+ event: 'hosts.include',
670
+ kind: result.removed.kind,
671
+ value: result.removed.value,
672
+ label: result.removed.label,
673
+ via: 'view',
674
+ });
675
+ return { code: 200, payload: { action: 'hosts.include', removed: result.removed } };
676
+ }
677
+
606
678
  function json(res, code, value) {
607
679
  if (res.writableEnded) return;
608
680
  res.writeHead(code, {
@@ -764,6 +836,10 @@ function handlePost(ctx, pathname, req, res) {
764
836
  if (pathname === '/api/hosts/add') return writeHostAdd(ctx, body);
765
837
  if (pathname === '/api/hosts/remove') return writeHostRemove(ctx, body);
766
838
  if (pathname === '/api/hosts/purge') return writeHostPurge(ctx, body);
839
+ if (pathname === '/api/hosts/mark') return writeHostMark(ctx, body);
840
+ if (pathname === '/api/hosts/set') return writeHostSet(ctx, body);
841
+ if (pathname === '/api/hosts/exclude') return writeHostExclude(ctx, body);
842
+ if (pathname === '/api/hosts/include') return writeHostInclude(ctx, body);
767
843
  return { code: 404, payload: { error: 'not found' } };
768
844
  };
769
845
  ctx.queue(run)
@@ -8,6 +8,7 @@ const lifecycleLib = require('./install-lifecycle');
8
8
  const healthLib = require('./install-health');
9
9
  const snapshotLib = require('./install-snapshot');
10
10
  const hookAdapterLib = require('./hook-adapter');
11
+ const scanLib = require('./skills-scan');
11
12
 
12
13
  function nowTag() {
13
14
  return new Date().toISOString().replace(/[:.]/g, '-');
@@ -60,6 +61,15 @@ function restoreBackup(target, backup, hadTarget) {
60
61
  return false;
61
62
  }
62
63
 
64
+ /** 0.29.1 U1「只升不降」跳过原因文案(equal / local-ahead / unknown)。 */
65
+ function versionSkipNote(relation, installed, target) {
66
+ if (relation === 'equal') return '已是最新';
67
+ if (relation === 'local-ahead') {
68
+ return '本地领先 v' + installed + ' > 目标 v' + target + ',保留不降级(--force 可强制)';
69
+ }
70
+ return '无法比较版本(本地 v' + installed + ' / 目标 v' + target + '),保留(--force 可强制)';
71
+ }
72
+
63
73
  function createInstaller(deps) {
64
74
  const homeDir = deps.homeDir || os.homedir();
65
75
  const runPhase = deps.runPhase || lifecycleLib.runPhase;
@@ -83,8 +93,18 @@ function createInstaller(deps) {
83
93
  return function installOne(skill, dest, opts) {
84
94
  const target = path.join(dest, skill.slug);
85
95
  const existingVersion = deps.readInstalledVersion(target);
86
- if (!opts.force && existingVersion === skill.version) {
87
- return { skill, status: 'skip', version: existingVersion, note: '已是最新', exitCode: 0 };
96
+ // 0.29.1 U1「只升不降」:pin 模式目标即最终版本,入口即可判定;
97
+ // range / latest 模式(目标非最终版本)在 npm pack 解析出精确版本后复判(见下方)。
98
+ const relation = scanLib.versionRelation(existingVersion, skill.version);
99
+ if (!opts.force && opts.pin !== false &&
100
+ (relation === 'equal' || relation === 'local-ahead' || relation === 'unknown')) {
101
+ return {
102
+ skill,
103
+ status: 'skip',
104
+ version: existingVersion,
105
+ note: versionSkipNote(relation, existingVersion, skill.version),
106
+ exitCode: 0,
107
+ };
88
108
  }
89
109
  if (opts.dryRun) {
90
110
  // 0.29.0 D1:只读预览 —— 不发网络请求、不写任何文件(含台账)。
@@ -124,6 +144,23 @@ function createInstaller(deps) {
124
144
  return { skill, status: 'fail', version: null, note: packed.error, exitCode: 1 };
125
145
  }
126
146
 
147
+ // 0.29.1 U1:解析出精确版本后、任何解压 / 快照 / 写入之前复判(latest / range 路径)。
148
+ const resolvedVersion = packed.resolved || (skill.version === 'latest' ? null : skill.version);
149
+ if (!opts.force && existingVersion) {
150
+ const resolvedRelation = resolvedVersion
151
+ ? scanLib.versionRelation(existingVersion, resolvedVersion)
152
+ : 'unknown';
153
+ if (resolvedRelation === 'equal' || resolvedRelation === 'local-ahead' || resolvedRelation === 'unknown') {
154
+ return {
155
+ skill,
156
+ status: 'skip',
157
+ version: existingVersion,
158
+ note: versionSkipNote(resolvedRelation, existingVersion, resolvedVersion || skill.version),
159
+ exitCode: 0,
160
+ };
161
+ }
162
+ }
163
+
127
164
  const extractDir = path.join(tmp, 'extract');
128
165
  fs.mkdirSync(extractDir, { recursive: true });
129
166
  extracted = deps.extractTarball(packed.tarball, extractDir);