@pulsemcp/air-core 0.0.42 → 0.1.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.
package/README.md CHANGED
@@ -24,8 +24,8 @@ const artifacts = await resolveArtifacts("~/.air/air.json");
24
24
  // Validate a JSON file against its AIR schema
25
25
  const result = validateJson(data, "skills");
26
26
 
27
- // Merge two artifact sets (later wins for matching IDs)
28
- const merged = mergeArtifacts(base, override);
27
+ // Merge two artifact sets (additive union duplicate qualified IDs throw)
28
+ const merged = mergeArtifacts(base, overlay);
29
29
  ```
30
30
 
31
31
  ### With a Catalog Provider (remote URIs)
package/dist/config.d.ts CHANGED
@@ -18,6 +18,11 @@ export interface ResolveOptions {
18
18
  * (e.g., `gitProtocol`). Providers ignore unknown keys.
19
19
  */
20
20
  providerOptions?: Record<string, unknown>;
21
+ /**
22
+ * Sink for non-fatal warnings (e.g. cross-scope shortname collisions, stale
23
+ * `exclude` entries). When omitted, core writes warnings to `console.warn`.
24
+ */
25
+ onWarning?: (message: string) => void;
21
26
  }
22
27
  /**
23
28
  * Merge air.json-level provider fields with runtime overrides and dispatch
@@ -34,28 +39,40 @@ export interface ResolveOptions {
34
39
  export declare function configureProviders(providers: CatalogProvider[], airConfig: AirConfig, providerOptions?: Record<string, unknown>): void;
35
40
  /**
36
41
  * Resolve all artifacts from an air.json file.
37
- * Each artifact property is an array of paths; files merge in order.
38
- * Remote URIs are delegated to the matching CatalogProvider.
39
42
  *
40
- * `catalogs` entries are expanded first via directory-walking discovery —
41
- * each catalog is resolved to a local directory (cloned by the relevant
42
- * provider for remote URIs) and walked up to `CATALOG_MAX_DEPTH` levels
43
- * for artifact index files. Files are identified by `$schema` (preferred)
44
- * or filename, and grouped by artifact type. Skip-listed directories
45
- * (node_modules, .git, dist, build, etc.) and entries matched by a
46
- * root-level `.gitignore` are not descended into. Explicit per-type
47
- * arrays layer on top of catalog-discovered indexes.
43
+ * Every artifact is canonically `@scope/id`:
44
+ * - Catalog providers supply scope via `getScope(uri)` (e.g. the GitHub
45
+ * provider returns `owner/repo`).
46
+ * - Local catalogs and per-type arrays default to scope `local`.
47
+ *
48
+ * Composition is union-only: catalogs and per-type arrays *contribute*
49
+ * artifacts. The only way to remove an artifact is via `air.json#exclude`,
50
+ * which lists qualified IDs to drop from the resolved set. Two contributors
51
+ * producing the same qualified ID hard-fail; same shortname under different
52
+ * scopes warns and both qualified IDs appear in the result.
48
53
  *
49
- * All `path` fields in resolved entries are absolute paths,
50
- * making artifacts self-contained regardless of source location.
54
+ * Reference fields inside artifact bodies (skill.references, plugin.skills,
55
+ * root.default_skills, …) are canonicalized to qualified IDs at resolution
56
+ * time. References inside a catalog's own indexes resolve to that catalog's
57
+ * scope first; cross-catalog references must use the qualified form when
58
+ * the shortname is ambiguous.
59
+ *
60
+ * All `path` fields are absolute, making artifacts self-contained regardless
61
+ * of source location.
51
62
  */
52
63
  export declare function resolveArtifacts(airJsonPath: string, options?: ResolveOptions): Promise<ResolvedArtifacts>;
53
64
  /**
54
- * Merge two resolved artifact sets. Override wins for matching IDs.
55
- * Composite plugins are re-expanded after merging so that newly
56
- * added plugins that reference existing ones are fully resolved.
65
+ * Combine two resolved artifact sets by union. Inputs must already be
66
+ * qualified (`@scope/id`); a duplicate qualified ID across the two inputs
67
+ * is rejected with a clear diagnostic. Composite plugins are re-expanded
68
+ * after merging.
69
+ *
70
+ * `mergeArtifacts` is a low-level helper used to layer two pre-resolved
71
+ * sets — for example, an external orchestrator merging a parent session's
72
+ * artifacts with a subagent's. Most callers should compose at the air.json
73
+ * level (catalogs + exclude) instead.
57
74
  */
58
- export declare function mergeArtifacts(base: ResolvedArtifacts, override: ResolvedArtifacts): ResolvedArtifacts;
75
+ export declare function mergeArtifacts(base: ResolvedArtifacts, overlay: ResolvedArtifacts): ResolvedArtifacts;
59
76
  /**
60
77
  * Recursively expand plugin references.
61
78
  *
@@ -66,12 +83,12 @@ export declare function mergeArtifacts(base: ResolvedArtifacts, override: Resolv
66
83
  * not a runtime concept.
67
84
  *
68
85
  * Semantics:
69
- * - Child plugins are expanded depth-first in declaration order
70
- * - Parent's direct declarations override children (later wins via dedup)
71
- * - Circular references are rejected with a clear error message
72
- * - Plugins without a `plugins` field are returned unchanged
73
- * - The `plugins` array on each entry is preserved as metadata (e.g., for
74
- * UI display of the dependency graph) even though primitives are inlined
86
+ * - All IDs are already qualified (`@scope/id`) at this stage.
87
+ * - Child plugins are expanded depth-first in declaration order.
88
+ * - Parent's direct declarations come last (later wins via dedup).
89
+ * - Circular references are rejected with a clear error message.
90
+ * - Plugins without a `plugins` field are returned unchanged.
91
+ * - The `plugins` array on each entry is preserved as metadata.
75
92
  *
76
93
  * Returns a new ResolvedArtifacts object; the input is not mutated.
77
94
  */
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,SAAS,EACT,iBAAiB,EAOjB,eAAe,EAChB,MAAM,YAAY,CAAC;AAoGpB,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,CAG5D;AAED;;GAEG;AACH,wBAAgB,qBAAqB,IAAI,MAAM,CAM9C;AAED;;;GAGG;AACH,wBAAgB,cAAc,IAAI,MAAM,GAAG,IAAI,CAG9C;AAED,MAAM,WAAW,cAAc;IAC7B,2EAA2E;IAC3E,SAAS,CAAC,EAAE,eAAe,EAAE,CAAC;IAC9B;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC3C;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,eAAe,EAAE,EAC5B,SAAS,EAAE,SAAS,EACpB,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACxC,IAAI,CAwBN;AAsOD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,gBAAgB,CACpC,WAAW,EAAE,MAAM,EACnB,OAAO,CAAC,EAAE,cAAc,GACvB,OAAO,CAAC,iBAAiB,CAAC,CAkD5B;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,iBAAiB,EACvB,QAAQ,EAAE,iBAAiB,GAC1B,iBAAiB,CASnB;AAmBD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,iBAAiB,GAAG,iBAAiB,CAiF7E;AAED,wBAAgB,cAAc,IAAI,iBAAiB,CASlD"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,SAAS,EACT,iBAAiB,EAOjB,eAAe,EAChB,MAAM,YAAY,CAAC;AAkNpB,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,CAG5D;AAED;;GAEG;AACH,wBAAgB,qBAAqB,IAAI,MAAM,CAM9C;AAED;;;GAGG;AACH,wBAAgB,cAAc,IAAI,MAAM,GAAG,IAAI,CAG9C;AAED,MAAM,WAAW,cAAc;IAC7B,2EAA2E;IAC3E,SAAS,CAAC,EAAE,eAAe,EAAE,CAAC;IAC9B;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC1C;;;OAGG;IACH,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACvC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,eAAe,EAAE,EAC5B,SAAS,EAAE,SAAS,EACpB,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACxC,IAAI,CAwBN;AAufD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,gBAAgB,CACpC,WAAW,EAAE,MAAM,EACnB,OAAO,CAAC,EAAE,cAAc,GACvB,OAAO,CAAC,iBAAiB,CAAC,CAgF5B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,iBAAiB,EACvB,OAAO,EAAE,iBAAiB,GACzB,iBAAiB,CA2BnB;AAmBD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,iBAAiB,GAAG,iBAAiB,CAiF7E;AAED,wBAAgB,cAAc,IAAI,iBAAiB,CASlD"}
package/dist/config.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync, readdirSync, existsSync } from "fs";
2
2
  import { resolve, dirname } from "path";
3
3
  import ignore from "ignore";
4
4
  import { detectSchemaType, detectSchemaFromValue, } from "./schemas.js";
5
+ import { deriveScope, qualifyId, parseQualifiedId, isQualified, resolveReference, } from "./scope.js";
5
6
  function loadJsonFile(filePath) {
6
7
  if (!existsSync(filePath)) {
7
8
  return {};
@@ -43,16 +44,13 @@ function resolveEntryPaths(entries, sourceDir) {
43
44
  return resolved;
44
45
  }
45
46
  /**
46
- * Load and merge entries from an array of index file paths.
47
- * Local paths are resolved relative to baseDir.
48
- * URI paths (with schemes) are delegated to the matching CatalogProvider.
49
- *
50
- * After loading, relative `path` fields in entries are resolved
51
- * to absolute paths, so downstream consumers don't need source directory context.
47
+ * Load every contribution for a single artifact type and return them as a
48
+ * flat list. Each contribution preserves the scope of the catalog it came
49
+ * from so qualification can happen during merging.
52
50
  */
53
- async function loadAndMerge(paths, baseDir, providers) {
54
- let merged = {};
55
- for (const p of paths) {
51
+ async function loadContributions(paths, baseDir, providers) {
52
+ const contributions = [];
53
+ for (const { path: p, scope } of paths) {
56
54
  const scheme = getScheme(p);
57
55
  let data;
58
56
  let sourceDir;
@@ -73,9 +71,81 @@ async function loadAndMerge(paths, baseDir, providers) {
73
71
  }
74
72
  const entries = stripSchema(data);
75
73
  const resolved = resolveEntryPaths(entries, sourceDir);
76
- merged = { ...merged, ...resolved };
74
+ contributions.push({ scope, source: p, entries: resolved });
75
+ }
76
+ return contributions;
77
+ }
78
+ /**
79
+ * Merge per-source contributions into a single `@scope/id`-keyed map.
80
+ *
81
+ * Composition rules:
82
+ * - Every entry is qualified `@scope/id` using the contribution's scope.
83
+ * - Two contributions producing the same qualified ID hard-fail with a
84
+ * diagnostic that names both sources.
85
+ * - Two contributions producing the same shortname under different scopes
86
+ * both land in the map. Cross-scope shortname collisions are reported by
87
+ * {@link warnCrossScopeShortnames} *after* `exclude` runs, so excluding
88
+ * one of the colliding artifacts silences the warning.
89
+ *
90
+ * Returns the merged map plus a `sources` map that records which contribution
91
+ * each qualified ID came from — used later for the post-exclude warning pass.
92
+ */
93
+ function mergeContributions(contributions, artifactType) {
94
+ const merged = {};
95
+ const sourceByQualified = new Map();
96
+ for (const contribution of contributions) {
97
+ for (const [shortname, entry] of Object.entries(contribution.entries)) {
98
+ if (isQualified(shortname)) {
99
+ throw new Error(`Artifact index entry must use a bare shortname; got qualified ID ` +
100
+ `"${shortname}" in ${contribution.source}. Scopes are assigned by ` +
101
+ `the catalog source, not by authors.`);
102
+ }
103
+ const qualified = qualifyId(contribution.scope, shortname);
104
+ const existingSource = sourceByQualified.get(qualified);
105
+ if (existingSource !== undefined) {
106
+ throw new Error(`Duplicate ${artifactType} ID "${qualified}" produced by both ` +
107
+ `"${existingSource}" and "${contribution.source}". Two catalogs ` +
108
+ `with the same scope contributed the same shortname; rename one ` +
109
+ `or remove the duplicate from your air.json.`);
110
+ }
111
+ sourceByQualified.set(qualified, contribution.source);
112
+ merged[qualified] = entry;
113
+ }
114
+ }
115
+ return { merged, sources: sourceByQualified };
116
+ }
117
+ /**
118
+ * Emit one warning per shortname that survives `exclude` under more than one
119
+ * scope. Running this after {@link applyExclude} means excluding either side
120
+ * of the collision silences the warning.
121
+ */
122
+ function warnCrossScopeShortnames(artifacts, sourcesByType, warnings) {
123
+ for (const type of ARTIFACT_TYPES) {
124
+ const pool = artifacts[type];
125
+ const sources = sourcesByType[type];
126
+ const scopesByShortname = new Map();
127
+ for (const qualified of Object.keys(pool)) {
128
+ const { scope, id: shortname } = parseQualifiedId(qualified);
129
+ const source = sources.get(qualified) ?? "(unknown source)";
130
+ let scopeMap = scopesByShortname.get(shortname);
131
+ if (!scopeMap) {
132
+ scopeMap = new Map();
133
+ scopesByShortname.set(shortname, scopeMap);
134
+ }
135
+ scopeMap.set(scope, source);
136
+ }
137
+ for (const [shortname, scopeMap] of scopesByShortname) {
138
+ if (scopeMap.size <= 1)
139
+ continue;
140
+ const scopeList = [...scopeMap.entries()]
141
+ .map(([scope, source]) => `@${scope} (from ${source})`)
142
+ .join(", ");
143
+ warnings.push(`Cross-scope shortname collision: ${type} "${shortname}" is ` +
144
+ `provided by ${scopeMap.size} scopes — ${scopeList}. Short references ` +
145
+ `to "${shortname}" without a scope are ambiguous; use the qualified ` +
146
+ `form "@scope/${shortname}" to disambiguate.`);
147
+ }
77
148
  }
78
- return merged;
79
149
  }
80
150
  export function loadAirConfig(airJsonPath) {
81
151
  const content = readFileSync(airJsonPath, "utf-8");
@@ -205,9 +275,7 @@ function detectIndexType(absPath, filename) {
205
275
  * within `CATALOG_MAX_DEPTH` levels, skipping `CATALOG_SKIP_DIRS`, hidden
206
276
  * entries, and anything matched by a root-level `.gitignore` if present.
207
277
  *
208
- * Files are returned sorted by relative path (alphabetic, depth-naive) so
209
- * that "later wins" collision semantics within a single catalog are stable
210
- * across machines and filesystems.
278
+ * Files are returned sorted by relative path.
211
279
  */
212
280
  function discoverCatalogIndexes(catalogDir) {
213
281
  if (!existsSync(catalogDir))
@@ -288,88 +356,311 @@ async function resolveCatalogRoot(catalog, baseDir, providers) {
288
356
  return await provider.resolveCatalogDir(catalog);
289
357
  }
290
358
  /**
291
- * Expand every entry in `catalogs[]` into a per-type map of absolute index
292
- * file paths. Each catalog is resolved to a local directory and walked for
293
- * artifact index files (depth-capped, gitignore-aware, skip-listed).
294
- *
295
- * Within a single catalog, files of the same type discovered at multiple
296
- * locations are merged in sorted relPath order with later-wins semantics —
297
- * the downstream `loadAndMerge` consumes the returned arrays in order and
298
- * applies the same "later wins by ID" rule as everywhere else in AIR.
299
- *
300
- * Across catalogs, catalogs earlier in `catalogs[]` are processed first so
301
- * that later catalogs override earlier ones, matching the existing contract.
359
+ * Expand every entry in `catalogs[]` into per-type index file paths grouped
360
+ * by scope. Each catalog is resolved to a local directory and walked for
361
+ * artifact index files (depth-capped, gitignore-aware, skip-listed); the
362
+ * scope assigned to those files comes from the provider's `getScope(uri)`,
363
+ * or `local` for filesystem catalogs and providers without `getScope`.
302
364
  */
303
365
  async function expandAllCatalogs(catalogs, baseDir, providers) {
304
- const result = {
305
- skills: [],
306
- references: [],
307
- mcp: [],
308
- plugins: [],
309
- roots: [],
310
- hooks: [],
311
- };
366
+ const expansions = [];
312
367
  for (const catalog of catalogs) {
313
368
  const catalogDir = await resolveCatalogRoot(catalog, baseDir, providers);
314
369
  const discovered = discoverCatalogIndexes(catalogDir);
370
+ const scope = deriveScope(catalog, providers);
371
+ const paths = {
372
+ skills: [],
373
+ references: [],
374
+ mcp: [],
375
+ plugins: [],
376
+ roots: [],
377
+ hooks: [],
378
+ };
315
379
  for (const entry of discovered) {
316
- result[entry.type].push(entry.absPath);
380
+ paths[entry.type].push(entry.absPath);
381
+ }
382
+ expansions.push({ scope, paths });
383
+ }
384
+ return expansions;
385
+ }
386
+ /**
387
+ * Canonicalize every reference field in an artifact body to its qualified
388
+ * form. References that fail to resolve (missing or ambiguous) populate
389
+ * `errors` so callers can fail composition with all problems at once.
390
+ *
391
+ * `fromScope` is the scope of the artifact that owns the reference — used
392
+ * to apply the intra-catalog rule (a short reference inside a catalog binds
393
+ * to that catalog's scope first).
394
+ */
395
+ function canonicalizeReferences(artifacts, excluded, errors) {
396
+ const result = {
397
+ skills: { ...artifacts.skills },
398
+ references: { ...artifacts.references },
399
+ mcp: { ...artifacts.mcp },
400
+ plugins: { ...artifacts.plugins },
401
+ roots: { ...artifacts.roots },
402
+ hooks: { ...artifacts.hooks },
403
+ };
404
+ /**
405
+ * For a `missing` reference, decide whether the target was specifically
406
+ * dropped by `air.json#exclude`. Exact qualified-ID match wins; otherwise a
407
+ * short reference matches any excluded entry whose shortname matches `ref`.
408
+ * The list of matching excluded IDs is returned so the error can name them.
409
+ */
410
+ function excludedMatches(ref) {
411
+ if (isQualified(ref)) {
412
+ return excluded.has(ref) ? [ref] : [];
413
+ }
414
+ const matches = [];
415
+ for (const id of excluded) {
416
+ if (parseQualifiedId(id).id === ref)
417
+ matches.push(id);
418
+ }
419
+ return matches;
420
+ }
421
+ function resolveList(list, pool, fromScope, poolType, ownerLabel, field) {
422
+ if (!list)
423
+ return undefined;
424
+ const out = [];
425
+ for (const ref of list) {
426
+ const res = resolveReference(pool, ref, fromScope);
427
+ if (res.status === "ok") {
428
+ out.push(res.qualified);
429
+ }
430
+ else if (res.status === "missing") {
431
+ const matches = excludedMatches(ref);
432
+ if (matches.length > 0) {
433
+ errors.push(`${ownerLabel}.${field} references ${poolType} "${ref}", ` +
434
+ `which is removed by air.json#exclude (${matches.join(", ")}). ` +
435
+ `Drop the exclude entry or also remove every artifact that ` +
436
+ `references it.`);
437
+ }
438
+ else {
439
+ errors.push(`${ownerLabel}.${field} references unknown ${poolType} "${ref}". ` +
440
+ `Available qualified IDs: ${listIds(pool)}.`);
441
+ }
442
+ }
443
+ else {
444
+ errors.push(`${ownerLabel}.${field} reference "${ref}" is ambiguous across ` +
445
+ `scopes — candidates: ${res.candidates.join(", ")}. ` +
446
+ `Use the qualified form to disambiguate.`);
447
+ }
448
+ }
449
+ return out;
450
+ }
451
+ function processOwner(qualified, owner) {
452
+ const { scope } = parseQualifiedId(qualified);
453
+ const ownerLabel = qualified;
454
+ if (owner.kind === "skill" || owner.kind === "hook") {
455
+ const next = { ...owner.entry };
456
+ next.references = resolveList(next.references, result.references, scope, "reference", ownerLabel, "references");
457
+ if (owner.kind === "skill")
458
+ result.skills[qualified] = next;
459
+ else
460
+ result.hooks[qualified] = next;
461
+ }
462
+ else if (owner.kind === "plugin") {
463
+ const next = { ...owner.entry };
464
+ next.skills = resolveList(next.skills, result.skills, scope, "skill", ownerLabel, "skills");
465
+ next.mcp_servers = resolveList(next.mcp_servers, result.mcp, scope, "mcp", ownerLabel, "mcp_servers");
466
+ next.hooks = resolveList(next.hooks, result.hooks, scope, "hook", ownerLabel, "hooks");
467
+ next.plugins = resolveList(next.plugins, result.plugins, scope, "plugin", ownerLabel, "plugins");
468
+ result.plugins[qualified] = next;
317
469
  }
470
+ else {
471
+ const next = { ...owner.entry };
472
+ next.default_skills = resolveList(next.default_skills, result.skills, scope, "skill", ownerLabel, "default_skills");
473
+ next.default_mcp_servers = resolveList(next.default_mcp_servers, result.mcp, scope, "mcp", ownerLabel, "default_mcp_servers");
474
+ next.default_plugins = resolveList(next.default_plugins, result.plugins, scope, "plugin", ownerLabel, "default_plugins");
475
+ next.default_hooks = resolveList(next.default_hooks, result.hooks, scope, "hook", ownerLabel, "default_hooks");
476
+ next.default_subagent_roots = resolveList(next.default_subagent_roots, result.roots, scope, "root", ownerLabel, "default_subagent_roots");
477
+ result.roots[qualified] = next;
478
+ }
479
+ }
480
+ for (const [qualified, entry] of Object.entries(artifacts.skills)) {
481
+ processOwner(qualified, { kind: "skill", entry });
482
+ }
483
+ for (const [qualified, entry] of Object.entries(artifacts.hooks)) {
484
+ processOwner(qualified, { kind: "hook", entry });
485
+ }
486
+ for (const [qualified, entry] of Object.entries(artifacts.plugins)) {
487
+ processOwner(qualified, { kind: "plugin", entry });
488
+ }
489
+ for (const [qualified, entry] of Object.entries(artifacts.roots)) {
490
+ processOwner(qualified, { kind: "root", entry });
318
491
  }
319
492
  return result;
320
493
  }
494
+ function listIds(pool) {
495
+ const keys = Object.keys(pool);
496
+ if (keys.length === 0)
497
+ return "(none)";
498
+ if (keys.length > 8) {
499
+ return `${keys.slice(0, 8).join(", ")}, … (${keys.length} total)`;
500
+ }
501
+ return keys.join(", ");
502
+ }
503
+ /**
504
+ * Apply `air.json#exclude` to a resolved artifact set. Excluded qualified IDs
505
+ * disappear from every artifact map; entries that don't match anything are
506
+ * surfaced as warnings so typos in `exclude` are visible to authors.
507
+ */
508
+ function applyExclude(artifacts, exclude, warnings) {
509
+ if (exclude.length === 0) {
510
+ return { artifacts, excluded: new Set() };
511
+ }
512
+ const excludeSet = new Set();
513
+ for (const id of exclude) {
514
+ if (!isQualified(id)) {
515
+ throw new Error(`air.json "exclude" entries must be qualified IDs (@scope/id); got "${id}".`);
516
+ }
517
+ excludeSet.add(id);
518
+ }
519
+ const result = {
520
+ skills: {},
521
+ references: {},
522
+ mcp: {},
523
+ plugins: {},
524
+ roots: {},
525
+ hooks: {},
526
+ };
527
+ const seen = new Set();
528
+ for (const type of ARTIFACT_TYPES) {
529
+ const src = artifacts[type];
530
+ const dst = result[type];
531
+ for (const [id, entry] of Object.entries(src)) {
532
+ if (excludeSet.has(id)) {
533
+ seen.add(id);
534
+ continue;
535
+ }
536
+ dst[id] = entry;
537
+ }
538
+ }
539
+ for (const id of excludeSet) {
540
+ if (!seen.has(id)) {
541
+ warnings.push(`air.json "exclude" entry "${id}" did not match any resolved ` +
542
+ `artifact. Remove it or check for typos.`);
543
+ }
544
+ }
545
+ return { artifacts: result, excluded: excludeSet };
546
+ }
321
547
  /**
322
548
  * Resolve all artifacts from an air.json file.
323
- * Each artifact property is an array of paths; files merge in order.
324
- * Remote URIs are delegated to the matching CatalogProvider.
325
549
  *
326
- * `catalogs` entries are expanded first via directory-walking discovery —
327
- * each catalog is resolved to a local directory (cloned by the relevant
328
- * provider for remote URIs) and walked up to `CATALOG_MAX_DEPTH` levels
329
- * for artifact index files. Files are identified by `$schema` (preferred)
330
- * or filename, and grouped by artifact type. Skip-listed directories
331
- * (node_modules, .git, dist, build, etc.) and entries matched by a
332
- * root-level `.gitignore` are not descended into. Explicit per-type
333
- * arrays layer on top of catalog-discovered indexes.
550
+ * Every artifact is canonically `@scope/id`:
551
+ * - Catalog providers supply scope via `getScope(uri)` (e.g. the GitHub
552
+ * provider returns `owner/repo`).
553
+ * - Local catalogs and per-type arrays default to scope `local`.
554
+ *
555
+ * Composition is union-only: catalogs and per-type arrays *contribute*
556
+ * artifacts. The only way to remove an artifact is via `air.json#exclude`,
557
+ * which lists qualified IDs to drop from the resolved set. Two contributors
558
+ * producing the same qualified ID hard-fail; same shortname under different
559
+ * scopes warns and both qualified IDs appear in the result.
560
+ *
561
+ * Reference fields inside artifact bodies (skill.references, plugin.skills,
562
+ * root.default_skills, …) are canonicalized to qualified IDs at resolution
563
+ * time. References inside a catalog's own indexes resolve to that catalog's
564
+ * scope first; cross-catalog references must use the qualified form when
565
+ * the shortname is ambiguous.
334
566
  *
335
- * All `path` fields in resolved entries are absolute paths,
336
- * making artifacts self-contained regardless of source location.
567
+ * All `path` fields are absolute, making artifacts self-contained regardless
568
+ * of source location.
337
569
  */
338
570
  export async function resolveArtifacts(airJsonPath, options) {
339
571
  const airConfig = loadAirConfig(airJsonPath);
340
572
  const baseDir = dirname(resolve(airJsonPath));
341
573
  const providers = options?.providers || [];
342
574
  const catalogs = airConfig.catalogs || [];
575
+ const onWarning = options?.onWarning ?? ((msg) => console.warn(`warning: ${msg}`));
576
+ const warnings = [];
343
577
  // Configure providers with merged options: air.json fields are the base,
344
578
  // explicit providerOptions override them. Providers ignore unknown keys.
345
579
  configureProviders(providers, airConfig, options?.providerOptions);
346
- const fromCatalogs = await expandAllCatalogs(catalogs, baseDir, providers);
347
- function paths(type, explicit) {
348
- return [...fromCatalogs[type], ...explicit];
580
+ const expansions = await expandAllCatalogs(catalogs, baseDir, providers);
581
+ function pathsFor(type) {
582
+ const list = [];
583
+ for (const expansion of expansions) {
584
+ for (const p of expansion.paths[type]) {
585
+ list.push({ path: p, scope: expansion.scope });
586
+ }
587
+ }
588
+ for (const p of airConfig[type] || []) {
589
+ // Per-type arrays default to LOCAL_SCOPE, but a provider URI in a
590
+ // per-type array still gets its provider scope so artifacts from
591
+ // different orgs/repos cannot collide under @local/<id>.
592
+ list.push({ path: p, scope: deriveScope(p, providers) });
593
+ }
594
+ return list;
595
+ }
596
+ const sourcesByType = {
597
+ skills: new Map(),
598
+ references: new Map(),
599
+ mcp: new Map(),
600
+ plugins: new Map(),
601
+ roots: new Map(),
602
+ hooks: new Map(),
603
+ };
604
+ async function load(type) {
605
+ const paths = pathsFor(type);
606
+ const contributions = await loadContributions(paths, baseDir, providers);
607
+ const { merged, sources } = mergeContributions(contributions, type);
608
+ sourcesByType[type] = sources;
609
+ return merged;
349
610
  }
350
611
  const resolved = {
351
- skills: await loadAndMerge(paths("skills", airConfig.skills || []), baseDir, providers),
352
- references: await loadAndMerge(paths("references", airConfig.references || []), baseDir, providers),
353
- mcp: await loadAndMerge(paths("mcp", airConfig.mcp || []), baseDir, providers),
354
- plugins: await loadAndMerge(paths("plugins", airConfig.plugins || []), baseDir, providers),
355
- roots: await loadAndMerge(paths("roots", airConfig.roots || []), baseDir, providers),
356
- hooks: await loadAndMerge(paths("hooks", airConfig.hooks || []), baseDir, providers),
612
+ skills: await load("skills"),
613
+ references: await load("references"),
614
+ mcp: await load("mcp"),
615
+ plugins: await load("plugins"),
616
+ roots: await load("roots"),
617
+ hooks: await load("hooks"),
357
618
  };
358
- return expandPlugins(resolved);
619
+ // Apply exclude before reference canonicalization so dropped IDs cannot
620
+ // satisfy references and a clear "missing reference" error surfaces.
621
+ const { artifacts: filtered, excluded } = applyExclude(resolved, airConfig.exclude || [], warnings);
622
+ // Cross-scope shortname collisions are reported on the post-exclude pool, so
623
+ // excluding one side of the collision silences the warning automatically.
624
+ warnCrossScopeShortnames(filtered, sourcesByType, warnings);
625
+ const refErrors = [];
626
+ const canonical = canonicalizeReferences(filtered, excluded, refErrors);
627
+ if (refErrors.length > 0) {
628
+ throw new Error(`Reference resolution failed:\n - ${refErrors.join("\n - ")}`);
629
+ }
630
+ for (const w of warnings)
631
+ onWarning(w);
632
+ return expandPlugins(canonical);
359
633
  }
360
634
  /**
361
- * Merge two resolved artifact sets. Override wins for matching IDs.
362
- * Composite plugins are re-expanded after merging so that newly
363
- * added plugins that reference existing ones are fully resolved.
635
+ * Combine two resolved artifact sets by union. Inputs must already be
636
+ * qualified (`@scope/id`); a duplicate qualified ID across the two inputs
637
+ * is rejected with a clear diagnostic. Composite plugins are re-expanded
638
+ * after merging.
639
+ *
640
+ * `mergeArtifacts` is a low-level helper used to layer two pre-resolved
641
+ * sets — for example, an external orchestrator merging a parent session's
642
+ * artifacts with a subagent's. Most callers should compose at the air.json
643
+ * level (catalogs + exclude) instead.
364
644
  */
365
- export function mergeArtifacts(base, override) {
645
+ export function mergeArtifacts(base, overlay) {
646
+ function unionOrThrow(a, b, label) {
647
+ const out = { ...a };
648
+ for (const [k, v] of Object.entries(b)) {
649
+ if (k in out) {
650
+ throw new Error(`mergeArtifacts: duplicate ${label} ID "${k}" in both base and overlay. ` +
651
+ `Override is not supported — drop one source via air.json#exclude.`);
652
+ }
653
+ out[k] = v;
654
+ }
655
+ return out;
656
+ }
366
657
  return expandPlugins({
367
- skills: { ...base.skills, ...override.skills },
368
- references: { ...base.references, ...override.references },
369
- mcp: { ...base.mcp, ...override.mcp },
370
- plugins: { ...base.plugins, ...override.plugins },
371
- roots: { ...base.roots, ...override.roots },
372
- hooks: { ...base.hooks, ...override.hooks },
658
+ skills: unionOrThrow(base.skills, overlay.skills, "skill"),
659
+ references: unionOrThrow(base.references, overlay.references, "reference"),
660
+ mcp: unionOrThrow(base.mcp, overlay.mcp, "mcp"),
661
+ plugins: unionOrThrow(base.plugins, overlay.plugins, "plugin"),
662
+ roots: unionOrThrow(base.roots, overlay.roots, "root"),
663
+ hooks: unionOrThrow(base.hooks, overlay.hooks, "hook"),
373
664
  });
374
665
  }
375
666
  /**
@@ -398,12 +689,12 @@ function deduplicateIds(arr) {
398
689
  * not a runtime concept.
399
690
  *
400
691
  * Semantics:
401
- * - Child plugins are expanded depth-first in declaration order
402
- * - Parent's direct declarations override children (later wins via dedup)
403
- * - Circular references are rejected with a clear error message
404
- * - Plugins without a `plugins` field are returned unchanged
405
- * - The `plugins` array on each entry is preserved as metadata (e.g., for
406
- * UI display of the dependency graph) even though primitives are inlined
692
+ * - All IDs are already qualified (`@scope/id`) at this stage.
693
+ * - Child plugins are expanded depth-first in declaration order.
694
+ * - Parent's direct declarations come last (later wins via dedup).
695
+ * - Circular references are rejected with a clear error message.
696
+ * - Plugins without a `plugins` field are returned unchanged.
697
+ * - The `plugins` array on each entry is preserved as metadata.
407
698
  *
408
699
  * Returns a new ResolvedArtifacts object; the input is not mutated.
409
700
  */