moluoxixi-ai-rules 0.2.11 → 0.2.12

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.
package/README-en.md CHANGED
@@ -82,7 +82,7 @@ Use grilling to question this development plan and establish validation criteria
82
82
  Use frontend-design to design this interface, then inspect it with Playwright.
83
83
  ```
84
84
 
85
- For a new development role, declare `extends_roles: [development]` and `capabilities: []` in its `role.yaml`. Add role-specific capabilities to its own declaration. Each role may extend at most one parent, and multi-level chains are supported.
85
+ For a new development role, use `schema_version: 2` and declare `extends_roles: [development]` with `provides.capabilities: []` in its `role.yaml`. Add shared capabilities to its own declaration. v2 supports multiple parents, deduplicates shared ancestors and capabilities, and rejects conflicting configurations.
86
86
 
87
87
  ## `moluoxixi`
88
88
 
@@ -381,17 +381,17 @@ Role source: [`roles/trellis`](roles/trellis).
381
381
 
382
382
  ## Shared distribution
383
383
 
384
- Shared skills, MCP catalogs, and vendor declarations live together in `capabilities/<capability>/`, such as `capabilities/common/skills/`. Each capability is a data-only directory containing `capability.yaml`, skills, MCP catalogs, and documentation; roles select capabilities in `role.yaml`. The runtime loader lives in `scripts/lib/capabilities.ts`, composes vendors in declaration order, merges compatible projections, deduplicates identical projections, and rejects source or target conflicts. See [capabilities/README.md](capabilities/README.md) for the layout and complete mapping.
384
+ Shared skills, MCP catalogs, and vendor declarations live together in `capabilities/<capability>/`, such as `capabilities/common/skills/`. Each capability is a data-only directory containing `capability.yaml`, skills, MCP catalogs, and documentation. v2 roles select shared capabilities in `provides.capabilities` and keep their own installation settings in `installation`. The runtime loader lives in `scripts/lib/capabilities.ts`, composes vendors in declaration order, merges compatible projections, deduplicates identical projections, and rejects source or target conflicts. See [capabilities/README.md](capabilities/README.md) for the layout and complete mapping.
385
385
 
386
- `extends_roles` combines capabilities along a single inheritance chain before the child's own choices. Each role may extend at most one parent; repeated capabilities are deduplicated. Multiple parents, missing parents, and inheritance cycles are rejected. A parent may be a declaration-only template. The child declares its own role vendor, hosts, and CLI packages. See [role inheritance](capabilities/README.md#角色继承).
386
+ v2 `extends_roles` combines shared capabilities from multiple parents before the child's own choices. Traversal follows declared parent order and visits each ancestor and capability once. Same-name skills and MCP servers deduplicate only when their sources and effective configurations agree; conflicts, missing parents, and cycles fail. Only `provides.capabilities` is inherited. Private assets, the role vendor, hosts, and CLI packages come from the selected role's `installation`. Staging is validated before setup and managed output is committed. External setup effects cannot be rolled back, and semantic overlap between differently named skills still requires human judgment. v1 and legacy modules retain single inheritance. See [role inheritance](capabilities/README.md#角色继承).
387
387
 
388
388
  Host directories, aliases, skill projection switches, and MCP formats are declared in [hosts/hosts.yaml](hosts/hosts.yaml). `scripts/lib/hosts.ts` loads and validates the declaration and resolves paths; declaration types live in `scripts/lib/types/`. Adding a host only requires a YAML entry. See [hosts/README.md](hosts/README.md) for fields and maintenance guidance.
389
389
 
390
390
  ```text
391
391
  role manifest
392
392
  -> role inheritance and capability composition
393
- -> pinned vendor checkout / package setup
394
- -> vendor staging
393
+ -> pinned vendor checkout
394
+ -> staging validation / setup / commit
395
395
  -> canonical ~/.agents/skills
396
396
  -> host skill projection and MCP merge
397
397
  ```
package/README.md CHANGED
@@ -82,7 +82,7 @@ general
82
82
  请使用 frontend-design 设计这个界面,并用 Playwright 检查效果。
83
83
  ```
84
84
 
85
- 新增开发角色时,在其 `role.yaml` 中声明 `extends_roles: [development]`,并设置 `capabilities: []`;专属能力可在自身声明中追加。每个角色最多继承一个父角色,支持多层链。
85
+ 新增开发角色时,在其 `role.yaml` 中使用 `schema_version: 2`,声明 `extends_roles: [development]` 和 `provides.capabilities: []`;附加共享能力可在自身声明中追加。v2 支持多个父角色,重复祖先和能力自动去重,配置冲突时直接报错。
86
86
 
87
87
  ## `moluoxixi`
88
88
 
@@ -388,9 +388,9 @@ Plan 阶段创建并确认 PRD,复杂任务补齐 design/implement 后 `task.p
388
388
 
389
389
  ## 公共分发机制
390
390
 
391
- 共享 skills、MCP 清单和供应商声明按能力集中在 `capabilities/<能力>/`,例如 `capabilities/common/skills/`。每个能力目录是纯数据目录,只包含 `capability.yaml`、skills、MCP 清单和文档;Role 在 `role.yaml` 中选择 capability。运行时加载器位于 `scripts/lib/capabilities.ts`,负责按声明顺序组合 vendor,合并兼容投影、去重完全相同的 projection,并拒绝来源或目标冲突。目录结构与完整映射见 [capabilities/README.md](capabilities/README.md)。
391
+ 共享 skills、MCP 清单和供应商声明按能力集中在 `capabilities/<能力>/`,例如 `capabilities/common/skills/`。每个能力目录是纯数据目录,只包含 `capability.yaml`、skills、MCP 清单和文档;Role 在 v2 `role.yaml` 的 `provides.capabilities` 中选择 capability,把自身安装设置放在 `installation`。运行时加载器位于 `scripts/lib/capabilities.ts`,负责按声明顺序组合 vendor,合并兼容投影、去重完全相同的 projection,并拒绝来源或目标冲突。目录结构与完整映射见 [capabilities/README.md](capabilities/README.md)。
392
392
 
393
- 角色通过 `extends_roles` 沿单继承链读取父角色的 capabilities,再加入自身能力。每个角色最多继承一个父角色,链上的重复能力自动去重;多父角色、缺失父角色与循环继承会报错。父角色可作为纯声明模板;子角色使用自身的 role vendor、宿主和 CLI packages。规则见 [角色继承](capabilities/README.md#角色继承)。
393
+ v2 角色通过 `extends_roles` 组合多个父模板的共享能力,再加入自身能力。遍历遵循父角色声明顺序,重复祖先和 capability 只处理一次;同名 skill 或 MCP 只有来源和有效配置一致时才去重,冲突、缺失父角色与循环继承会报错。继承只读取 `provides.capabilities`,私有资产、role vendor、宿主和 CLI packages 使用所选角色的 `installation`。校验通过后才执行 setup 和提交受管资产;外部 setup 的环境变化以及不同名称 skill 的语义重复仍需人工判断。v1 与旧模块继续兼容单继承。规则见 [角色继承](capabilities/README.md#角色继承)。
394
394
 
395
395
  宿主目录、别名、skills 投影开关和 MCP 格式统一声明在 [hosts/hosts.yaml](hosts/hosts.yaml)。`scripts/lib/hosts.ts` 负责加载、校验和路径解析,声明类型放在 `scripts/lib/types/`。新增宿主只需更新 YAML;字段与维护说明见 [hosts/README.md](hosts/README.md)。
396
396
 
@@ -399,8 +399,8 @@ Plan 阶段创建并确认 PRD,复杂任务补齐 design/implement 后 `task.p
399
399
  ```text
400
400
  role manifest
401
401
  -> 角色继承与 capabilities 合并
402
- -> 固定 vendor checkout / package setup
403
- -> vendor staging
402
+ -> 固定 vendor checkout
403
+ -> vendor staging 校验 / setup / 提交
404
404
  -> canonical ~/.agents/skills
405
405
  -> 宿主 skill 投影与 MCP 合并
406
406
  ```
@@ -54,25 +54,63 @@ AIRules 自有共享 skills 实际存放在 `capabilities/<能力>/skills/`。
54
54
 
55
55
  ```yaml
56
56
  # roles/<role>/role.yaml
57
+ schema_version: 2
58
+ role_id: example
57
59
  extends_roles: [development]
58
- capabilities: []
60
+ provides:
61
+ capabilities: []
59
62
  ```
60
63
 
61
- `roles/<role>/role.yaml` 声明角色自身的 `role_vendor`、CLI 安装命令、宿主支持、继承关系和 publishable packages。角色自有的 `init-project`、hooks、agents、packages 等仍放在 `roles/<role>/`,由 `role-assets` 全量同步。旧包若仍带有 `constants/skills.ts`,安装器会在没有 `role.yaml` 时兼容读取它。
64
+ `roles/<role>/role.yaml` 把共享能力放在 `provides`,把角色自身的安装设置放在 `installation`。角色自有的 `init-project`、hooks、agents、packages 等仍放在 `roles/<role>/`,由 `role-assets` 全量远程同步。旧包若仍带有 `constants/skills.ts`,安装器会在没有 `role.yaml` 时兼容读取它;v2 声明不会执行这些旧模块。
62
65
 
63
- 共享能力的上游仓库、固定 commit 和投影路径统一以 `capability.yaml` 的 `vendors` 为准;角色通过 `capabilities` 与 `extends_roles` 引用这些声明。
66
+ 共享能力的上游仓库、固定 commit 和投影路径统一以 `capability.yaml` 的 `vendors` 为准;角色通过 `provides.capabilities` 与 `extends_roles` 引用这些声明。
64
67
 
65
68
  ## 角色继承
66
69
 
67
- `extends_roles` 将角色作为可复用的能力模板。每个角色只允许继承一个父角色,或用 `extends_roles: []` 声明没有父角色。多父角色声明会在加载阶段报错,重复填写相同父角色也会报错;YAML 与旧 `extendsRoles` 导出使用同一校验规则。
70
+ `schema_version: 2` 支持在 `extends_roles` 中声明多个父角色。角色可以作为纯能力模板,也可以独立安装。继承只读取 `provides.capabilities`;`installation` 中的设置始终属于当前选择的角色:
71
+
72
+ | 字段 | 用途 | 是否继承 |
73
+ |---|---|---|
74
+ | `provides.capabilities` | 引用共享 skills、MCP 与供应商 setup | 是 |
75
+ | `installation.role_vendor` | 当前角色的远程资产与专属 setup | 否 |
76
+ | `installation.assets` | 当前角色的私有资产路径 | 否 |
77
+ | `installation.hosts` | 宿主支持范围 | 否 |
78
+ | `installation.packages` | 角色 CLI 与 npm 安装声明 | 否 |
79
+ | `installation.distribution`、`installation.entrypoints` | 分发约定与初始化入口 | 否 |
80
+
81
+ ```yaml
82
+ schema_version: 2
83
+ role_id: example
84
+ extends_roles: [development, general]
85
+ provides:
86
+ capabilities: []
87
+ installation:
88
+ hosts: all
89
+ role_vendor:
90
+ name: example-role
91
+ source: https://github.com/moluoxixi/AIRules.git
92
+ projections:
93
+ - kind: role-assets
94
+ source_dir: roles/example
95
+ ```
96
+
97
+ 安装器按父角色声明顺序进行深度优先遍历,先处理祖先,再处理自身。上例中 `development` 已继承 `general`,最终顺序仍是 `general → development → example`;同一祖先与 capability 只处理一次。父角色顺序不会产生隐式覆盖。
68
98
 
69
99
  `general` 提供记忆、skill 编写与文档整理;`development` 继承 `general`,集中维护 `coding`、`productivity` 与 `frontend`。具体开发角色统一继承 `development` 的基础 skills 与 MCP,再选择自己的工程工作流。`engineering` 由 Matt 角色单独选择。两个基础角色也可独立安装。
70
100
 
71
- 子角色安装时沿单继承链读取父角色的 `capabilities`,再加入自己的能力;链上重复的能力只处理一次。子角色可用 `capabilities: []` 继承整组模板能力。能力组合仍会校验供应商版本和投影目标冲突。
101
+ 纯模板只需声明 `schema_version`、`role_id`、`extends_roles` 和 `provides.capabilities`。安装共享能力的具体角色必须提供自己的 `installation.role_vendor`,使第一方能力从该角色的 AIRules 远程 checkout 投影到 vendor;父模板的私有 skills、MCP、hooks、agents 和 packages 不会随继承安装。
102
+
103
+ 重复与冲突按以下规则处理:
104
+
105
+ - 同一供应商必须使用相同仓库、revision 和有效 setup;兼容的投影可以合并。
106
+ - 同名 skill 只有供应商、来源、revision、源码路径、输出目标和有效 setup 全部相同时才去重。namespace 与显式 skill 选择的重叠也会校验;不同来源的同名 skill 直接报错。
107
+ - 同名 MCP 只有完整连接配置和有效 setup 相同时才去重。JSON 对象字段顺序不影响比较,`args`、`env`、URL、headers 或 setup 的差异都会报错。
108
+ - 相同 setup 命令组只执行一次;组内命令顺序与有意重复的命令保留。
109
+ - 缺失父角色、循环继承、重复填写父角色、单份声明中的重复 capability,以及供应商或文件目标的大小写冲突都会报错。冲突信息包含角色继承路径与能力来源。
72
110
 
73
- 继承范围是 capability 声明及其 skills、MCP 与供应商 setup。子角色的 `role_vendor`、角色专属资产、CLI packages 和 hosts 由自身声明;安装器将继承的第一方能力从子角色的 AIRules 远程 checkout 投影到 vendor。能力模板可只声明 `capabilities` 和 `extends_roles`;独立安装的角色需提供自身 `role_vendor`。
111
+ 安装器在执行 setup 前校验来源、内容和受管目标目录,再提交暂存结果。校验或 setup 失败时保留上一版受管资产;提交失败时尝试恢复旧内容,恢复失败会保留备份并报告错误。不同名称的 skills 仍可能在语义上重复,安装器无法判断这类冲突;外部 setup 命令造成的环境变化也不能自动回滚。
74
112
 
75
- 缺失父角色、循环继承、单份声明中的重复能力及供应商版本冲突会报错。同一供应商的同一源码只投影一次;不同来源的同名 skill 仍报冲突。`grilling` skill 归属 `productivity`,随该能力的 namespace 一起同步。
113
+ `schema_version: 1`、未声明版本的旧 YAML 和旧 `extendsRoles` 模块继续兼容单继承。迁移到 v2 时,将顶层 `capabilities` 移入 `provides`,将安装字段移入 `installation`。`grilling` skill 仍归属 `productivity`,没有独立的 grilling 分类。
76
114
 
77
115
  ## MCP 清单
78
116
 
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { parseDocument } from 'yaml';
5
+ import { setupIdentity } from './mcp-catalog.js';
5
6
  /**
6
7
  * Capability content is data owned by each directory. This loader is the
7
8
  * shared runtime that turns capability.yaml into the existing vendor model.
@@ -204,40 +205,70 @@ export function composeCapabilityDefinitions(selections, options) {
204
205
  const seenCapabilities = new Set();
205
206
  const roleVendor = cloneVendor(options.roleVendor);
206
207
  const capabilityVendors = [];
208
+ const roleOrigin = options.roleOrigin ?? 'selected role installation';
209
+ const roleProjections = new Map();
207
210
  for (const selection of selections) {
208
211
  if (seenCapabilities.has(selection.name))
209
212
  throw new Error(`Capability "${selection.name}" is declared more than once`);
210
213
  seenCapabilities.add(selection.name);
211
- for (const projection of selection.definition.roleProjections ?? [])
214
+ const origin = selection.origin ?? options.capabilityOrigins?.[selection.name] ?? `capability ${selection.name}`;
215
+ for (const projection of selection.definition.roleProjections ?? []) {
212
216
  appendProjection(roleVendor, projection);
217
+ const key = projectionKey(projection);
218
+ const origins = roleProjections.get(key) ?? [];
219
+ origins.push(origin);
220
+ roleProjections.set(key, origins);
221
+ }
213
222
  for (const vendor of selection.definition.vendors ?? [])
214
- capabilityVendors.push(cloneVendor(vendor));
223
+ capabilityVendors.push({ vendor: cloneVendor(vendor), origin });
215
224
  }
225
+ const roleProjectionOrigins = roleVendor.projections.map((projection) => {
226
+ const origins = roleProjections.get(projectionKey(projection));
227
+ return origins?.length ? [...new Set(origins)] : [roleOrigin];
228
+ });
216
229
  const sequence = options.roleVendorPosition === 'after'
217
- ? [...capabilityVendors, roleVendor]
218
- : [roleVendor, ...capabilityVendors];
230
+ ? [...capabilityVendors, { vendor: roleVendor, origin: roleOrigin, projectionOrigins: roleProjectionOrigins }]
231
+ : [{ vendor: roleVendor, origin: roleOrigin, projectionOrigins: roleProjectionOrigins }, ...capabilityVendors];
219
232
  return mergeVendors(sequence);
220
233
  }
221
234
  function mergeVendors(sequence) {
222
235
  const result = [];
223
236
  const byName = new Map();
224
- for (const candidate of sequence) {
225
- const existing = byName.get(candidate.name);
237
+ for (const entry of sequence) {
238
+ const candidate = entry.vendor;
239
+ const caseAlias = [...byName.keys()].find(name => name.toLowerCase() === candidate.name.toLowerCase() && name !== candidate.name);
240
+ if (caseAlias)
241
+ throw new Error(`Vendor names differ only by case: ${caseAlias} (${byName.get(caseAlias).origin}), ${candidate.name} (${entry.origin})`);
242
+ const existingEntry = byName.get(candidate.name);
243
+ const existing = existingEntry?.vendor;
226
244
  if (!existing) {
227
245
  const cloned = cloneVendor(candidate);
228
- byName.set(cloned.name, cloned);
246
+ byName.set(cloned.name, {
247
+ vendor: cloned,
248
+ origin: entry.origin,
249
+ projectionOrigins: entry.projectionOrigins ?? cloned.projections.map(() => [entry.origin]),
250
+ });
229
251
  result.push(cloned);
230
252
  continue;
231
253
  }
232
254
  if (existing.source !== candidate.source
233
255
  || existing.revision !== candidate.revision
234
256
  || setupKey(existing.setup) !== setupKey(candidate.setup)) {
235
- throw new Error(`Vendor "${candidate.name}" has conflicting source, revision, or setup definitions`);
257
+ throw new Error(`Vendor "${candidate.name}" has conflicting source, revision, or setup definitions between ${existingEntry?.origin} and ${entry.origin}`);
236
258
  }
237
- for (const projection of candidate.projections)
259
+ for (const [index, projection] of candidate.projections.entries()) {
260
+ const projectionOwner = existing.projections.findIndex(existingProjection => projectionKey(existingProjection) === projectionKey(projection));
261
+ if (projectionOwner >= 0) {
262
+ const origins = entry.projectionOrigins?.[index] ?? [entry.origin];
263
+ existingEntry.projectionOrigins[projectionOwner] = [...new Set([...existingEntry.projectionOrigins[projectionOwner], ...origins])];
264
+ continue;
265
+ }
238
266
  appendProjection(existing, projection);
267
+ existingEntry.projectionOrigins.push(entry.projectionOrigins?.[index] ?? [entry.origin]);
268
+ }
269
+ existingEntry.origin = `${existingEntry.origin}, ${entry.origin}`;
239
270
  }
240
- validateProjectionTargets(result);
271
+ validateProjectionTargets([...byName.values()]);
241
272
  return result;
242
273
  }
243
274
  function appendProjection(vendor, projection) {
@@ -248,32 +279,41 @@ function appendProjection(vendor, projection) {
248
279
  }
249
280
  function validateProjectionTargets(vendors) {
250
281
  const owners = new Map();
251
- for (const vendor of vendors) {
252
- for (const projection of vendor.projections) {
253
- const key = projectionKey(projection);
254
- for (const target of projectionTargets(projection)) {
255
- const owner = owners.get(target);
256
- if (owner) {
257
- throw new Error(`Projection target "${target}" conflicts between vendor "${owner.vendor}" and vendor "${vendor.name}"`);
282
+ for (const entry of vendors) {
283
+ const vendor = entry.vendor;
284
+ for (const [index, projection] of vendor.projections.entries()) {
285
+ for (const { target, identity } of projectionClaims(projection)) {
286
+ const owner = owners.get(target.toLowerCase());
287
+ if (owner && (owner.vendor !== vendor.name || owner.projection !== identity)) {
288
+ throw new Error(`Projection target "${target}" conflicts between vendor "${owner.vendor}" (${owner.origin}) and vendor "${vendor.name}" (${entry.projectionOrigins[index]?.join(', ') ?? entry.origin})`);
258
289
  }
259
- owners.set(target, { projection: key, vendor: vendor.name });
290
+ owners.set(target.toLowerCase(), { projection: identity, vendor: vendor.name, origin: entry.projectionOrigins[index]?.join(', ') ?? entry.origin });
260
291
  }
261
292
  }
262
293
  }
263
294
  }
264
- function projectionTargets(projection) {
295
+ function projectionClaims(projection) {
296
+ const identity = projectionKey(projection);
265
297
  if (projection.kind === 'namespace')
266
- return [`skill-namespace:${projection.output}`];
298
+ return [{ target: `skill-namespace:${leafName(projection.output)}`, identity }];
267
299
  if (projection.kind === 'skills') {
268
300
  return projection.skills.map((skill) => {
269
- const name = typeof skill === 'string' ? skill : skill.output ?? leafName(skill.name);
270
- return `skill:${name}`;
301
+ const source = typeof skill === 'string' ? skill : skill.name;
302
+ const name = leafName(typeof skill === 'string' ? skill : skill.output ?? skill.name);
303
+ return {
304
+ target: `skill:${name}`,
305
+ identity: JSON.stringify([
306
+ path.posix.join(projection.sourceBaseDir.replaceAll('\\', '/'), source.replaceAll('\\', '/')),
307
+ name,
308
+ setupKey(typeof skill === 'string' ? undefined : skill.setup),
309
+ ]),
310
+ };
271
311
  });
272
312
  }
273
313
  if (projection.kind === 'mcp')
274
- return [`mcp:${projection.output}`];
314
+ return [{ target: `mcp:${projection.output}`, identity }];
275
315
  if (projection.kind === 'role-assets')
276
- return ['role-assets'];
316
+ return [{ target: 'role-assets', identity }];
277
317
  return assertNever(projection);
278
318
  }
279
319
  function projectionKey(projection) {
@@ -300,16 +340,11 @@ function projectionKey(projection) {
300
340
  }
301
341
  function skillKey(skill) {
302
342
  if (typeof skill === 'string')
303
- return skill;
304
- return [skill.name, skill.output, setupKey(skill.setup)];
343
+ return [skill, leafName(skill), setupKey(undefined)];
344
+ return [skill.name, leafName(skill.output ?? skill.name), setupKey(skill.setup)];
305
345
  }
306
346
  function setupKey(setup) {
307
- return JSON.stringify((setup ?? []).map(command => [
308
- command.command,
309
- command.args ?? [],
310
- command.windowsCommandShim,
311
- command.skipIfCommandAvailable,
312
- ]));
347
+ return setupIdentity(setup);
313
348
  }
314
349
  function cloneVendor(vendor) {
315
350
  return {
@@ -32,7 +32,7 @@ interface SyncFlattenedSkillsOptions {
32
32
  }
33
33
  export declare function syncFlattenedSkills(sourceDir: string, targetDir: string, moluoHome: string, options?: SyncFlattenedSkillsOptions): void;
34
34
  /**
35
- * 将第一方 skills 源目录投影到 vendor/skills,作为第三方 vendor 后的本地覆盖层。
35
+ * 将第一方 skills 源目录投影到 vendor/skills;v2 仅投影所选角色并拒绝共享目标冲突。
36
36
  * 该函数只清理曾经指向同一 source skills 根目录的过时链接,不会删除第三方 vendor 技能。
37
37
  */
38
38
  export declare function syncFirstPartySkillsToVendor(sourceRoot: string, moluoHome: string, role?: string): Promise<void>;
@@ -5,9 +5,10 @@ import path from 'node:path';
5
5
  import { areSamePaths, canonicalPath, canonicalPathKey, isPathInside } from './canonical-path.js';
6
6
  import { findHostConfig, resolveGlobalAgentSkillsPath, resolveHostPaths } from './hosts.js';
7
7
  import { buildLinkPlan } from './links.js';
8
- import { loadMcpCatalog, validateMcpServerNames } from './mcp-catalog.js';
8
+ import { loadMcpCatalog, mergeMcpServer, readMcpServerFile, setupIdentity } from './mcp-catalog.js';
9
9
  import { requireRoleName } from './role-assets.js';
10
- import { DEFAULT_ROLE, roleOverlayOrder } from './roles.js';
10
+ import { readRoleContract } from './role-contract.js';
11
+ import { DEFAULT_ROLE, resolveRoleInheritance } from './roles.js';
11
12
  import { collectFlattenedSkillSources, discoverSkillDirectories, flattenedSkillName } from './skill-projection.js';
12
13
  import { loadVendorManifest, rolePackageSetupCommands } from './vendors.js';
13
14
  /** 获取 vendor 技能目录的绝对路径 */
@@ -82,18 +83,13 @@ function isSetupCommandAvailable(command) {
82
83
  * @param manifest 已解析的 VendorManifest
83
84
  */
84
85
  export function runSkillSetupCommands(manifest, homeDir) {
85
- const rolePackageCommands = rolePackageSetupCommands(manifest.packages);
86
- if (rolePackageCommands.length > 0) {
87
- console.log('\n[setup] 安装角色声明的 npm packages...');
88
- runSetupCommandGroup('role packages', rolePackageCommands);
89
- }
86
+ // Build and validate the whole command plan before executing its first command.
87
+ const groups = [{ owner: 'role packages', commands: rolePackageSetupCommands(manifest.packages) }];
88
+ const mcpOwners = new Map();
90
89
  for (const [vendorName, vendor] of Object.entries(manifest.vendors)) {
91
- if (vendor.setup && vendor.setup.length > 0) {
92
- console.log(`\n[setup] 执行 ${vendorName} 的安装前置命令...`);
93
- runSetupCommandGroup(vendorName, vendor.setup);
94
- }
90
+ groups.push({ owner: vendorName, commands: vendor.setup ?? [] });
95
91
  for (const link of vendor.links) {
96
- const setupCommands = [...(link.setup ?? [])];
92
+ const owner = `${vendorName}/${link.kind === 'mcp-file' ? 'mcp' : path.basename(link.target)}`;
97
93
  if (link.kind === 'mcp-file') {
98
94
  if (!homeDir) {
99
95
  throw new Error(`[setup] ${vendorName} MCP setup requires the AIRules home directory`);
@@ -107,15 +103,34 @@ export function runSkillSetupCommands(manifest, homeDir) {
107
103
  if (!stats?.isFile() || stats.isSymbolicLink() || !isPathInside(checkoutRoot, realpathSync(sourceFile))) {
108
104
  throw new Error(`[setup] ${vendorName} MCP catalog must be a plain file inside its checkout: ${link.source}`);
109
105
  }
110
- setupCommands.push(...loadMcpCatalog(sourceFile).setup);
111
- }
112
- if (setupCommands.length === 0)
106
+ const catalog = loadMcpCatalog(sourceFile);
107
+ const serverGroups = [];
108
+ for (const [name, server] of Object.entries(catalog.servers)) {
109
+ const setup = catalog.serverSetup[name] ?? [];
110
+ if (mergeMcpServer(mcpOwners, name, {
111
+ server,
112
+ setup: [...(vendor.setup ?? []), ...(link.setup ?? []), ...setup],
113
+ }, `${vendorName} (${sourceFile})`)) {
114
+ serverGroups.push({ owner: `${owner}:${name}`, commands: setup });
115
+ }
116
+ }
117
+ if (serverGroups.length > 0) {
118
+ groups.push({ owner, commands: link.setup ?? [] }, ...serverGroups);
119
+ }
113
120
  continue;
114
- const assetName = link.kind === 'mcp-file' ? 'mcp' : path.basename(link.target);
115
- console.log(`\n[setup] 执行 ${vendorName}/${assetName} 的安装前置命令...`);
116
- runSetupCommandGroup(`${vendorName}/${assetName}`, setupCommands);
121
+ }
122
+ groups.push({ owner, commands: link.setup ?? [] });
117
123
  }
118
124
  }
125
+ const executed = new Set();
126
+ for (const { owner, commands } of groups) {
127
+ const identity = setupIdentity(commands);
128
+ if (commands.length === 0 || executed.has(identity))
129
+ continue;
130
+ executed.add(identity);
131
+ console.log(`\n[setup] 执行 ${owner} 的安装前置命令...`);
132
+ runSetupCommandGroup(owner, commands);
133
+ }
119
134
  }
120
135
  function runSetupCommandGroup(owner, commands) {
121
136
  for (const command of commands) {
@@ -249,18 +264,19 @@ export function syncFlattenedSkills(sourceDir, targetDir, moluoHome, options = {
249
264
  }
250
265
  }
251
266
  /**
252
- * 将第一方 skills 源目录投影到 vendor/skills,作为第三方 vendor 后的本地覆盖层。
267
+ * 将第一方 skills 源目录投影到 vendor/skills;v2 仅投影所选角色并拒绝共享目标冲突。
253
268
  * 该函数只清理曾经指向同一 source skills 根目录的过时链接,不会删除第三方 vendor 技能。
254
269
  */
255
270
  export async function syncFirstPartySkillsToVendor(sourceRoot, moluoHome, role = DEFAULT_ROLE) {
256
271
  const legacySkillsDir = path.join(sourceRoot, 'skills');
257
272
  const rolesRoot = path.join(sourceRoot, 'roles');
258
- const sourceSkillRoots = existsSync(rolesRoot)
259
- ? (await roleOverlayOrder(sourceRoot, role))
260
- .map(roleName => path.join(sourceRoot, 'roles', roleName, 'skills'))
261
- .filter(existsSync)
262
- : [];
263
- if (sourceSkillRoots.length === 0 && existsSync(legacySkillsDir)) {
273
+ const lineage = existsSync(rolesRoot) ? await resolveRoleInheritance(sourceRoot, role) : [];
274
+ const isV2 = lineage.at(-1)?.contract?.schemaVersion === 2;
275
+ const selectedRoles = isV2 ? lineage.slice(-1) : lineage;
276
+ const sourceSkillRoots = selectedRoles
277
+ .map(entry => path.join(sourceRoot, 'roles', entry.role, entry.contract?.assets?.skills ?? 'skills'))
278
+ .filter(existsSync);
279
+ if (!isV2 && sourceSkillRoots.length === 0 && existsSync(legacySkillsDir)) {
264
280
  sourceSkillRoots.push(legacySkillsDir);
265
281
  }
266
282
  if (sourceSkillRoots.length === 0) {
@@ -283,6 +299,13 @@ export async function syncFirstPartySkillsToVendor(sourceRoot, moluoHome, role =
283
299
  }
284
300
  }
285
301
  const skillSources = [...seenSkillNames.values()].map(({ name, source }) => ({ name, source }));
302
+ if (isV2) {
303
+ for (const skill of skillSources) {
304
+ const target = path.join(vendorSkillsDir, skill.name);
305
+ if (existsSync(target) && !isSamePath(realpathSync(target), skill.source))
306
+ throw new Error(`Role/shared skill target conflict "${skill.name}": ${target} conflicts with ${skill.source}`);
307
+ }
308
+ }
286
309
  const currentSkillNames = new Set(skillSources.map(skill => skill.name));
287
310
  const normalizedSourceSkillRoots = sourceSkillRoots.map(canonicalPath);
288
311
  const normalizedManagedSkillRoots = existsSync(rolesRoot)
@@ -400,37 +423,12 @@ function escapeTomlString(value) {
400
423
  function tomlKey(key) {
401
424
  return /^[\w-]+$/u.test(key) ? key : `"${escapeTomlString(key)}"`;
402
425
  }
403
- function readMcpServerFile(sourceFile) {
404
- const stats = lstatSync(sourceFile);
405
- if (!stats.isFile() || stats.isSymbolicLink())
406
- throw new Error(`MCP source must be a plain file: ${sourceFile}`);
407
- const raw = readFileSync(sourceFile, 'utf8').trim();
408
- if (!raw)
409
- return {};
410
- let parsed;
411
- try {
412
- parsed = JSON.parse(raw);
413
- }
414
- catch (error) {
415
- throw new Error(`MCP source is invalid JSON: ${sourceFile}`, { cause: error });
416
- }
417
- if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
418
- throw new Error(`MCP source must contain an "mcpServers" object: ${sourceFile}`);
419
- const servers = parsed.mcpServers;
420
- if (servers === undefined)
421
- return {};
422
- if (servers === null || typeof servers !== 'object' || Array.isArray(servers))
423
- throw new Error(`MCP source must contain an "mcpServers" object: ${sourceFile}`);
424
- const serverRecord = servers;
425
- validateMcpServerNames(serverRecord, sourceFile);
426
- return serverRecord;
427
- }
428
426
  function readVendorMcpServers(moluoHome) {
429
427
  const root = path.join(moluoHome, 'vendor', 'mcps');
430
428
  if (!existsSync(root))
431
429
  return {};
432
430
  const servers = Object.create(null);
433
- const sources = new Map();
431
+ const owners = new Map();
434
432
  function visit(directory) {
435
433
  const stats = lstatSync(directory);
436
434
  if (!stats.isDirectory() || stats.isSymbolicLink())
@@ -445,12 +443,8 @@ function readVendorMcpServers(moluoHome) {
445
443
  else if (entry.isFile() && entry.name === 'mcp.json') {
446
444
  const fileServers = readMcpServerFile(entryPath);
447
445
  for (const [name, server] of Object.entries(fileServers)) {
448
- const previous = sources.get(name);
449
- if (previous) {
450
- throw new Error(`Duplicate shared MCP server "${name}": ${previous} conflicts with ${entryPath}`);
451
- }
452
- servers[name] = server;
453
- sources.set(name, entryPath);
446
+ if (mergeMcpServer(owners, name, { server, setup: [] }, entryPath))
447
+ servers[name] = server;
454
448
  }
455
449
  }
456
450
  }
@@ -461,9 +455,19 @@ function readVendorMcpServers(moluoHome) {
461
455
  export function readInstalledMcpServers(moluoHome, role) {
462
456
  const servers = readVendorMcpServers(moluoHome);
463
457
  if (role) {
464
- const roleSource = path.join(moluoHome, 'roles', requireRoleName(role), 'mcp', 'mcp.json');
465
- if (existsSync(roleSource))
466
- Object.assign(servers, readMcpServerFile(roleSource));
458
+ const roleRoot = path.join(moluoHome, 'roles', requireRoleName(role));
459
+ const contractPath = path.join(roleRoot, 'role.yaml');
460
+ const assets = existsSync(contractPath) ? readRoleContract(contractPath).assets : undefined;
461
+ const roleSource = path.resolve(roleRoot, assets?.mcp ?? 'mcp', 'mcp.json');
462
+ if (!isPathInside(roleRoot, roleSource))
463
+ throw new Error(`Role MCP source must stay inside the selected role: ${roleSource}`);
464
+ if (existsSync(roleSource)) {
465
+ const owners = new Map(Object.entries(servers).map(([name, server]) => [name, { server, setup: [], owner: 'shared capabilities' }]));
466
+ for (const [name, server] of Object.entries(readMcpServerFile(roleSource))) {
467
+ if (mergeMcpServer(owners, name, { server, setup: [] }, `${role} (${roleSource})`))
468
+ servers[name] = server;
469
+ }
470
+ }
467
471
  }
468
472
  return Object.keys(servers).length > 0 ? servers : undefined;
469
473
  }
@@ -1,7 +1,9 @@
1
- import type { SetupCommand } from './vendors.js';
2
- export interface McpCatalog {
3
- servers: Record<string, Record<string, unknown>>;
4
- setup: SetupCommand[];
5
- }
1
+ import type { SetupCommand } from './types/manifest.js';
2
+ import type { McpCatalog, McpServerDefinition, McpServerOwner } from './types/mcp.js';
3
+ export type { McpCatalog } from './types/mcp.js';
6
4
  export declare function validateMcpServerNames(servers: Record<string, unknown>, sourceFile: string): void;
5
+ export declare function readMcpServerFile(sourceFile: string): Record<string, unknown>;
7
6
  export declare function loadMcpCatalog(sourceFile: string): McpCatalog;
7
+ export declare function setupIdentity(setup?: readonly SetupCommand[]): string;
8
+ /** Returns false for a completely identical declaration; incompatible names fail closed. */
9
+ export declare function mergeMcpServer(owners: Map<string, McpServerOwner>, name: string, definition: McpServerDefinition, owner: string): boolean;
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import { canonicalJson } from './core/canonical-json.js';
2
3
  const reservedServerNames = new Set(['__proto__', 'constructor', 'prototype']);
3
4
  function isRecord(value) {
4
5
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -33,6 +34,29 @@ export function validateMcpServerNames(servers, sourceFile) {
33
34
  }
34
35
  }
35
36
  }
37
+ export function readMcpServerFile(sourceFile) {
38
+ const stats = fs.lstatSync(sourceFile);
39
+ if (!stats.isFile() || stats.isSymbolicLink())
40
+ throw new Error(`MCP source must be a plain file: ${sourceFile}`);
41
+ const raw = fs.readFileSync(sourceFile, 'utf8').trim();
42
+ if (!raw)
43
+ return {};
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(raw);
47
+ }
48
+ catch (error) {
49
+ throw new Error(`MCP source is invalid JSON: ${sourceFile}`, { cause: error });
50
+ }
51
+ if (!isRecord(parsed))
52
+ throw new Error(`MCP source must contain an "mcpServers" object: ${sourceFile}`);
53
+ if (parsed.mcpServers === undefined)
54
+ return {};
55
+ if (!isRecord(parsed.mcpServers))
56
+ throw new Error(`MCP source must contain an "mcpServers" object: ${sourceFile}`);
57
+ validateMcpServerNames(parsed.mcpServers, sourceFile);
58
+ return parsed.mcpServers;
59
+ }
36
60
  export function loadMcpCatalog(sourceFile) {
37
61
  let parsed;
38
62
  try {
@@ -47,6 +71,7 @@ export function loadMcpCatalog(sourceFile) {
47
71
  validateMcpServerNames(parsed.mcps, sourceFile);
48
72
  const servers = Object.create(null);
49
73
  const setup = [];
74
+ const serverSetup = Object.create(null);
50
75
  for (const [name, value] of Object.entries(parsed.mcps)) {
51
76
  if (!name || !isRecord(value) || !isRecord(value.mcp)) {
52
77
  throw new Error(`MCP catalog entry must contain an "mcp" object: ${sourceFile}#${name}`);
@@ -55,9 +80,29 @@ export function loadMcpCatalog(sourceFile) {
55
80
  throw new Error(`MCP catalog entry setup must be an array: ${sourceFile}#${name}`);
56
81
  }
57
82
  servers[name] = value.mcp;
58
- for (const [index, command] of (value.setup ?? []).entries()) {
59
- setup.push(parseSetupCommand(command, `${sourceFile}#${name}.setup[${index}]`));
83
+ serverSetup[name] = (value.setup ?? []).map((command, index) => parseSetupCommand(command, `${sourceFile}#${name}.setup[${index}]`));
84
+ setup.push(...serverSetup[name]);
85
+ }
86
+ return { servers, serverSetup, setup };
87
+ }
88
+ export function setupIdentity(setup = []) {
89
+ return canonicalJson(setup.map(command => ({
90
+ command: command.command,
91
+ args: command.args ?? [],
92
+ windowsCommandShim: command.windowsCommandShim ?? false,
93
+ skipIfCommandAvailable: command.skipIfCommandAvailable ?? null,
94
+ })));
95
+ }
96
+ /** Returns false for a completely identical declaration; incompatible names fail closed. */
97
+ export function mergeMcpServer(owners, name, definition, owner) {
98
+ const previous = owners.get(name);
99
+ if (previous) {
100
+ if (canonicalJson(previous.server) !== canonicalJson(definition.server)
101
+ || setupIdentity(previous.setup) !== setupIdentity(definition.setup)) {
102
+ throw new Error(`Shared MCP server "${name}" has conflicting connection or setup definitions: ${previous.owner} conflicts with ${owner}`);
60
103
  }
104
+ return false;
61
105
  }
62
- return { servers, setup };
106
+ owners.set(name, { ...definition, owner });
107
+ return true;
63
108
  }
@@ -0,0 +1,3 @@
1
+ import type { RoleContractData } from './types/roles.js';
2
+ export declare function requireRoleInheritance(value: unknown, location: string, schemaVersion?: number): string[];
3
+ export declare function readRoleContract(contractPath: string): RoleContractData;