@graphorin/skills 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,646 @@
1
+ /**
2
+ * Frontmatter validator for `SKILL.md` files.
3
+ *
4
+ * Implements the field-resolution algorithm from ADR-043:
5
+ *
6
+ * 1. Anthropic-base (top-level, no prefix) - highest priority.
7
+ * 2. `metadata.graphorin.<field>` bucket per upstream `metadata`
8
+ * convention.
9
+ * 3. `graphorin-<field>` legacy top-level prefix.
10
+ * 4. caller-supplied fallback.
11
+ *
12
+ * The validator surfaces every diagnostic through the typed
13
+ * {@link FrontmatterDiagnostic} contract so callers can decide whether
14
+ * to log, fail, or escalate without re-parsing human strings.
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+
19
+ import { parse as parseYaml } from 'yaml';
20
+
21
+ import { SkillManifestParseError } from '../errors/index.js';
22
+ import {
23
+ compareAuthorSpecHint,
24
+ getGraphorinMapping,
25
+ getKnownField,
26
+ getSpecSnapshot,
27
+ } from '../spec/index.js';
28
+ import type {
29
+ FieldResolution,
30
+ FrontmatterDiagnostic,
31
+ FrontmatterValidatorPolicy,
32
+ HandoffInputFilterDeclaration,
33
+ HandoffInputFilterStep,
34
+ SkillToolDeclaration,
35
+ UnknownFieldPolicy,
36
+ } from '../types/index.js';
37
+
38
+ /** Result of {@link splitSkillMd}. */
39
+ export interface SplitSkillMd {
40
+ readonly frontmatter: string;
41
+ readonly body: string;
42
+ }
43
+
44
+ /**
45
+ * Split a raw SKILL.md string into the YAML frontmatter and the
46
+ * markdown body. The frontmatter delimiter is the canonical
47
+ * `---\n…\n---\n` pair.
48
+ *
49
+ * @stable
50
+ */
51
+ export function splitSkillMd(skillMd: string): SplitSkillMd {
52
+ const normalized = skillMd.replace(/^\uFEFF/u, '').replace(/\r\n/g, '\n');
53
+ const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/u.exec(normalized);
54
+ if (match === null) {
55
+ throw new SkillManifestParseError(
56
+ 'SKILL.md must begin with a YAML frontmatter block delimited by `---` lines.',
57
+ );
58
+ }
59
+ return { frontmatter: match[1] ?? '', body: match[2] ?? '' };
60
+ }
61
+
62
+ /**
63
+ * Parse the YAML frontmatter into a record. Returns `{}` for an empty
64
+ * block.
65
+ *
66
+ * @stable
67
+ */
68
+ export function parseFrontmatterYaml(frontmatter: string): Record<string, unknown> {
69
+ if (frontmatter.trim().length === 0) return {};
70
+ let parsed: unknown;
71
+ try {
72
+ parsed = parseYaml(frontmatter);
73
+ } catch (err) {
74
+ throw new SkillManifestParseError('SKILL.md frontmatter is not valid YAML.', { cause: err });
75
+ }
76
+ if (parsed === null || parsed === undefined) return {};
77
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
78
+ throw new SkillManifestParseError(
79
+ `Top-level SKILL.md frontmatter must be an object; got '${Array.isArray(parsed) ? 'array' : typeof parsed}'.`,
80
+ );
81
+ }
82
+ return parsed as Record<string, unknown>;
83
+ }
84
+
85
+ /**
86
+ * Resolve a single field across the four field-resolution tiers.
87
+ * Returns the resolved value plus the source tier the value came from
88
+ * AND the list of conflicting source names so the validator can
89
+ * surface a structured diagnostic.
90
+ *
91
+ * @stable
92
+ */
93
+ export function resolveSkillField<T = unknown>(
94
+ frontmatter: Record<string, unknown>,
95
+ field: string,
96
+ fallback?: T,
97
+ ): FieldResolution<T> {
98
+ const conflictingSources: string[] = [];
99
+ const presentInBase = field in frontmatter;
100
+ const meta = frontmatter.metadata;
101
+ const metaRecord =
102
+ meta !== undefined && meta !== null && typeof meta === 'object' && !Array.isArray(meta)
103
+ ? (meta as Record<string, unknown>)
104
+ : undefined;
105
+ // mcp-skills-09 (F-10): the docs' preferred authoring form is the
106
+ // NESTED object (`metadata: { graphorin: { sensitivity: ... } }`),
107
+ // which YAML parses to a nested record - the old resolver only read
108
+ // the flat dotted key (`metadata: { 'graphorin.sensitivity': ... }`)
109
+ // and silently dropped nested values. Both shapes now resolve; the
110
+ // flat key wins when both are present (it was the only working form).
111
+ const nested = metaRecord?.graphorin;
112
+ const nestedRecord =
113
+ nested !== undefined && nested !== null && typeof nested === 'object' && !Array.isArray(nested)
114
+ ? (nested as Record<string, unknown>)
115
+ : undefined;
116
+ const presentInMetaFlat = metaRecord !== undefined && `graphorin.${field}` in metaRecord;
117
+ const presentInMetaNested = nestedRecord !== undefined && field in nestedRecord;
118
+ const presentInMeta = presentInMetaFlat || presentInMetaNested;
119
+ const presentInPrefix = `graphorin-${field}` in frontmatter;
120
+ if (presentInBase) conflictingSources.push(field);
121
+ if (presentInMeta) conflictingSources.push(`metadata.graphorin.${field}`);
122
+ if (presentInPrefix) conflictingSources.push(`graphorin-${field}`);
123
+ const conflicting = conflictingSources.length > 1;
124
+
125
+ if (presentInBase) {
126
+ return Object.freeze({
127
+ value: frontmatter[field] as T,
128
+ source: 'anthropic-base' as const,
129
+ conflicting,
130
+ conflictingSources,
131
+ });
132
+ }
133
+ if (presentInMeta) {
134
+ return Object.freeze({
135
+ value: (presentInMetaFlat
136
+ ? (metaRecord as Record<string, unknown>)[`graphorin.${field}`]
137
+ : (nestedRecord as Record<string, unknown>)[field]) as T,
138
+ source: 'metadata-graphorin' as const,
139
+ conflicting,
140
+ conflictingSources,
141
+ });
142
+ }
143
+ if (presentInPrefix) {
144
+ return Object.freeze({
145
+ value: frontmatter[`graphorin-${field}`] as T,
146
+ source: 'graphorin-prefix' as const,
147
+ conflicting,
148
+ conflictingSources,
149
+ });
150
+ }
151
+ return Object.freeze({
152
+ value: fallback,
153
+ source: 'fallback' as const,
154
+ conflicting: false,
155
+ conflictingSources: [],
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Options accepted by {@link validateFrontmatter}.
161
+ *
162
+ * @stable
163
+ */
164
+ export interface ValidateFrontmatterOptions {
165
+ /** Policy for direct collisions (Anthropic-base + `graphorin-*`). */
166
+ readonly conflictPolicy?: FrontmatterValidatorPolicy;
167
+ /** Policy for fields not part of the bundled snapshot or `graphorin-*` catalogue. */
168
+ readonly unknownFieldPolicy?: UnknownFieldPolicy;
169
+ /**
170
+ * Installed Graphorin runtime version. Used to validate
171
+ * `graphorin-runtime-compat` declarations against the running
172
+ * framework.
173
+ */
174
+ readonly runtimeVersion?: string;
175
+ }
176
+
177
+ /** Successful return of {@link validateFrontmatter}. */
178
+ export interface ValidatedFrontmatter {
179
+ readonly raw: Readonly<Record<string, unknown>>;
180
+ readonly diagnostics: ReadonlyArray<FrontmatterDiagnostic>;
181
+ readonly resolved: {
182
+ readonly name: FieldResolution<unknown>;
183
+ readonly description: FieldResolution<unknown>;
184
+ readonly license: FieldResolution<unknown>;
185
+ readonly compatibility: FieldResolution<unknown>;
186
+ readonly metadata: FieldResolution<unknown>;
187
+ readonly allowedTools: FieldResolution<unknown>;
188
+ readonly disableModelInvocation: FieldResolution<unknown>;
189
+ readonly trustLevel: FieldResolution<unknown>;
190
+ readonly runtimeCompat: FieldResolution<unknown>;
191
+ readonly sensitivity: FieldResolution<unknown>;
192
+ readonly sensitivityDefaults: FieldResolution<unknown>;
193
+ readonly sandbox: FieldResolution<unknown>;
194
+ readonly handoffInputFilter: FieldResolution<unknown>;
195
+ readonly anthropicSpec: FieldResolution<unknown>;
196
+ readonly version: FieldResolution<unknown>;
197
+ readonly tools: FieldResolution<unknown>;
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Validate a parsed frontmatter against the bundled spec snapshot and
203
+ * the `graphorin-*` extension catalogue.
204
+ *
205
+ * @stable
206
+ */
207
+ export function validateFrontmatter(
208
+ frontmatter: Record<string, unknown>,
209
+ options: ValidateFrontmatterOptions = {},
210
+ ): ValidatedFrontmatter {
211
+ const conflictPolicy: FrontmatterValidatorPolicy = options.conflictPolicy ?? 'warn';
212
+ const unknownFieldPolicy: UnknownFieldPolicy = options.unknownFieldPolicy ?? 'preserve';
213
+ const diagnostics: FrontmatterDiagnostic[] = [];
214
+
215
+ const resolveAndDiag = <T = unknown>(field: string, fallback?: T): FieldResolution<T> => {
216
+ const resolution = resolveSkillField<T>(frontmatter, field, fallback);
217
+ if (resolution.conflicting) {
218
+ const message =
219
+ `Both '${resolution.conflictingSources.join("' and '")}' are set for '${field}'. ` +
220
+ `The Anthropic-base / metadata.graphorin.* tier wins; remove the lower-priority field to silence this diagnostic.`;
221
+ const hint = `Keep the highest-priority entry: '${resolution.source === 'anthropic-base' ? field : resolution.source === 'metadata-graphorin' ? `metadata.graphorin.${field}` : `graphorin-${field}`}'.`;
222
+ const severity: 'warn' | 'error' = conflictPolicy === 'error' ? 'error' : 'warn';
223
+ if (conflictPolicy !== 'silent') {
224
+ diagnostics.push(
225
+ Object.freeze({
226
+ kind: 'conflict',
227
+ field,
228
+ severity,
229
+ message,
230
+ hint,
231
+ }),
232
+ );
233
+ }
234
+ }
235
+ return resolution;
236
+ };
237
+
238
+ const name = resolveAndDiag<string>('name');
239
+ const description = resolveAndDiag<string>('description');
240
+ const license = resolveAndDiag<string>('license');
241
+ const compatibility = resolveAndDiag<string>('compatibility');
242
+ const metadata = resolveAndDiag<Record<string, unknown>>('metadata');
243
+ const allowedTools = resolveAndDiag<unknown>('allowed-tools');
244
+ const disableModelInvocation = resolveAndDiag<boolean>('disable-model-invocation', false);
245
+ const trustLevel = resolveAndDiag<string>('trust-level');
246
+ const runtimeCompat = resolveAndDiag<string>('runtime-compat');
247
+ // The graphorin-only `version` field carries runtime-compat semantics; the
248
+ // Anthropic-base `metadata.version` is the skill's own version. We honour
249
+ // both - the loader uses `runtimeCompat` for compat, `version` for
250
+ // graphorin-runtime-compat-as-version aliasing.
251
+ const version = resolveAndDiag<string>('version');
252
+ const sensitivity = resolveAndDiag<string>('sensitivity');
253
+ const sensitivityDefaults = resolveAndDiag<Record<string, string>>('sensitivity-defaults');
254
+ const sandbox = resolveAndDiag<Record<string, unknown>>('sandbox');
255
+ const handoffInputFilter = resolveAndDiag<unknown>('handoff-input-filter');
256
+ const anthropicSpec = resolveAndDiag<string>('anthropic-spec');
257
+ const tools = resolveAndDiag<unknown>('tools');
258
+
259
+ if (typeof name.value !== 'string' || name.value.trim().length === 0) {
260
+ diagnostics.push(
261
+ Object.freeze({
262
+ kind: 'missing-required-field',
263
+ field: 'name',
264
+ severity: 'error',
265
+ message: "Required field 'name' is missing or empty.",
266
+ hint: "Add a unique 'name' (kebab-case is conventional) to the SKILL.md frontmatter.",
267
+ }),
268
+ );
269
+ }
270
+ if (typeof description.value !== 'string' || description.value.trim().length === 0) {
271
+ diagnostics.push(
272
+ Object.freeze({
273
+ kind: 'missing-required-field',
274
+ field: 'description',
275
+ severity: 'error',
276
+ message: "Required field 'description' is missing or empty.",
277
+ hint: 'Add a description that explains when the model should activate the skill.',
278
+ }),
279
+ );
280
+ }
281
+ if (allowedTools.value !== undefined) {
282
+ const list = parseAllowedToolsValue(allowedTools.value);
283
+ if (list === null) {
284
+ diagnostics.push(
285
+ Object.freeze({
286
+ kind: 'invalid-field-type',
287
+ field: 'allowed-tools',
288
+ severity: 'warn',
289
+ message:
290
+ "'allowed-tools' must be a string ('read_file write_file') or an array (['read_file', 'write_file']).",
291
+ hint: 'Switch to one of the two supported shapes; entries are split on whitespace for the string form.',
292
+ }),
293
+ );
294
+ }
295
+ }
296
+ if (
297
+ allowedTools.value !== undefined &&
298
+ getKnownField('allowed-tools')?.stability === 'experimental'
299
+ ) {
300
+ diagnostics.push(
301
+ Object.freeze({
302
+ kind: 'experimental-field',
303
+ field: 'allowed-tools',
304
+ severity: 'info',
305
+ message:
306
+ "'allowed-tools' is marked experimental in the bundled spec snapshot; behaviour may evolve.",
307
+ }),
308
+ );
309
+ }
310
+ if (anthropicSpec.value !== undefined && typeof anthropicSpec.value === 'string') {
311
+ const compare = compareAuthorSpecHint(anthropicSpec.value);
312
+ if (compare === 'newer') {
313
+ diagnostics.push(
314
+ Object.freeze({
315
+ kind: 'spec-newer-than-loader',
316
+ field: 'anthropic-spec',
317
+ severity: 'warn',
318
+ message: `Skill targets spec snapshot '${anthropicSpec.value}', newer than the bundled '${getSpecSnapshot().snapshotDate}'. Unknown fields will be preserved leniently.`,
319
+ }),
320
+ );
321
+ } else if (compare === 'older') {
322
+ diagnostics.push(
323
+ Object.freeze({
324
+ kind: 'spec-older-than-loader',
325
+ field: 'anthropic-spec',
326
+ severity: 'info',
327
+ message: `Skill targets spec snapshot '${anthropicSpec.value}', older than the bundled '${getSpecSnapshot().snapshotDate}'.`,
328
+ }),
329
+ );
330
+ } else if (compare === 'unparseable') {
331
+ diagnostics.push(
332
+ Object.freeze({
333
+ kind: 'invalid-field-type',
334
+ field: 'anthropic-spec',
335
+ severity: 'warn',
336
+ message: `'anthropic-spec' must be an ISO-8601 date string (YYYY-MM-DD); got '${String(anthropicSpec.value)}'.`,
337
+ }),
338
+ );
339
+ }
340
+ }
341
+ if (
342
+ runtimeCompat.value !== undefined &&
343
+ typeof runtimeCompat.value === 'string' &&
344
+ options.runtimeVersion !== undefined
345
+ ) {
346
+ if (!isRuntimeCompatSatisfied(runtimeCompat.value, options.runtimeVersion)) {
347
+ diagnostics.push(
348
+ Object.freeze({
349
+ kind: 'invalid-runtime-compat',
350
+ field: 'runtime-compat',
351
+ severity: 'error',
352
+ message: `Skill declares runtime-compat '${runtimeCompat.value}' which does not match installed Graphorin '${options.runtimeVersion}'.`,
353
+ hint: 'Adjust the skill or upgrade Graphorin so the declared range covers the installed version.',
354
+ }),
355
+ );
356
+ }
357
+ }
358
+ for (const key of Object.keys(frontmatter)) {
359
+ if (isRecognisedField(key)) continue;
360
+ if (key.startsWith('graphorin-')) {
361
+ // `graphorin-*` fields not in the mapping table are tolerated as
362
+ // opaque metadata per Phase 08 § Frontmatter validator. Emit a
363
+ // single info-level diagnostic so loaders can surface them.
364
+ if (unknownFieldPolicy === 'reject') {
365
+ diagnostics.push(
366
+ Object.freeze({
367
+ kind: 'unknown-field',
368
+ field: key,
369
+ severity: 'error',
370
+ message: `Unknown 'graphorin-*' field '${key}' rejected by unknownFieldPolicy='reject'.`,
371
+ }),
372
+ );
373
+ } else if (unknownFieldPolicy === 'warn') {
374
+ diagnostics.push(
375
+ Object.freeze({
376
+ kind: 'unknown-field',
377
+ field: key,
378
+ severity: 'warn',
379
+ message: `Unknown 'graphorin-*' field '${key}'. Will be preserved as opaque metadata.`,
380
+ }),
381
+ );
382
+ }
383
+ continue;
384
+ }
385
+ if (unknownFieldPolicy === 'reject') {
386
+ diagnostics.push(
387
+ Object.freeze({
388
+ kind: 'unknown-field',
389
+ field: key,
390
+ severity: 'error',
391
+ message: `Unknown frontmatter field '${key}' rejected by unknownFieldPolicy='reject'.`,
392
+ }),
393
+ );
394
+ } else if (unknownFieldPolicy === 'warn') {
395
+ diagnostics.push(
396
+ Object.freeze({
397
+ kind: 'unknown-field',
398
+ field: key,
399
+ severity: 'warn',
400
+ message: `Unknown frontmatter field '${key}'. Will be preserved as opaque metadata.`,
401
+ }),
402
+ );
403
+ }
404
+ }
405
+
406
+ return Object.freeze({
407
+ raw: Object.freeze({ ...frontmatter }),
408
+ diagnostics: Object.freeze(diagnostics),
409
+ resolved: Object.freeze({
410
+ name,
411
+ description,
412
+ license,
413
+ compatibility,
414
+ metadata,
415
+ allowedTools,
416
+ disableModelInvocation,
417
+ trustLevel,
418
+ runtimeCompat,
419
+ sensitivity,
420
+ sensitivityDefaults,
421
+ sandbox,
422
+ handoffInputFilter,
423
+ anthropicSpec,
424
+ version,
425
+ tools,
426
+ }),
427
+ });
428
+ }
429
+
430
+ /**
431
+ * Parse the `allowed-tools` field. Accepts either a string (with
432
+ * whitespace-separated entries) or a string array. Returns `null` for
433
+ * unsupported shapes so the validator can attach a typed diagnostic.
434
+ *
435
+ * @stable
436
+ */
437
+ export function parseAllowedToolsValue(value: unknown): ReadonlyArray<string> | null {
438
+ if (Array.isArray(value)) {
439
+ if (!value.every((entry) => typeof entry === 'string' && entry.trim().length > 0)) {
440
+ return null;
441
+ }
442
+ return Object.freeze(value.map((entry) => entry.trim()) as string[]);
443
+ }
444
+ if (typeof value === 'string') {
445
+ const tokens = value
446
+ .split(/\s+/u)
447
+ .map((tok) => tok.trim())
448
+ .filter((tok) => tok.length > 0);
449
+ if (tokens.length === 0) return null;
450
+ return Object.freeze(tokens);
451
+ }
452
+ return null;
453
+ }
454
+
455
+ /**
456
+ * Parse the `handoff-input-filter` field into a structured
457
+ * declaration. Returns `null` for unsupported shapes; callers should
458
+ * attach a diagnostic when the return value is `null` and the source
459
+ * value was non-undefined.
460
+ *
461
+ * @stable
462
+ */
463
+ export function parseHandoffInputFilter(value: unknown): HandoffInputFilterDeclaration | null {
464
+ if (typeof value === 'string') {
465
+ const trimmed = value.trim();
466
+ if (trimmed === 'lastUser') return Object.freeze({ kind: 'lastUser' });
467
+ if (trimmed === 'summary') return Object.freeze({ kind: 'summary' });
468
+ if (trimmed === 'full') return Object.freeze({ kind: 'full' });
469
+ const lastN = /^lastN-(\d+)$/u.exec(trimmed);
470
+ if (lastN !== null) {
471
+ const n = Number.parseInt(lastN[1] ?? '0', 10);
472
+ if (Number.isFinite(n) && n > 0) return Object.freeze({ kind: 'lastN', n });
473
+ }
474
+ return null;
475
+ }
476
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
477
+ const obj = value as Record<string, unknown>;
478
+ if (Array.isArray(obj.compose)) {
479
+ const steps: HandoffInputFilterStep[] = [];
480
+ for (const raw of obj.compose) {
481
+ const step = parseHandoffInputFilterStep(raw);
482
+ if (step === null) return null;
483
+ steps.push(step);
484
+ }
485
+ return Object.freeze({ kind: 'compose', steps: Object.freeze(steps) });
486
+ }
487
+ }
488
+ return null;
489
+ }
490
+
491
+ function parseHandoffInputFilterStep(raw: unknown): HandoffInputFilterStep | null {
492
+ if (typeof raw === 'string') {
493
+ if (raw === 'lastUser') return Object.freeze({ kind: 'lastUser' });
494
+ if (raw === 'summary') return Object.freeze({ kind: 'summary' });
495
+ if (raw === 'stripReasoning') return Object.freeze({ kind: 'stripReasoning' });
496
+ return null;
497
+ }
498
+ if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
499
+ const obj = raw as Record<string, unknown>;
500
+ if (typeof obj.lastN === 'number' && Number.isFinite(obj.lastN) && obj.lastN > 0) {
501
+ return Object.freeze({ kind: 'lastN', n: obj.lastN });
502
+ }
503
+ if (
504
+ obj.stripSensitiveOutputs !== undefined &&
505
+ typeof obj.stripSensitiveOutputs === 'object' &&
506
+ obj.stripSensitiveOutputs !== null
507
+ ) {
508
+ const inner = obj.stripSensitiveOutputs as Record<string, unknown>;
509
+ const keepTier = typeof inner.keepTier === 'string' ? inner.keepTier : undefined;
510
+ return Object.freeze({
511
+ kind: 'stripSensitiveOutputs',
512
+ ...(keepTier === undefined ? {} : { keepTier }),
513
+ });
514
+ }
515
+ if (typeof obj.lastUser === 'boolean') return Object.freeze({ kind: 'lastUser' });
516
+ if (typeof obj.summary === 'boolean') return Object.freeze({ kind: 'summary' });
517
+ if (typeof obj.stripReasoning === 'boolean') return Object.freeze({ kind: 'stripReasoning' });
518
+ }
519
+ return null;
520
+ }
521
+
522
+ /**
523
+ * Parse the `tools` field. Accepts either an array of strings (tool
524
+ * names - the loader resolves modules through naming convention) or
525
+ * an array of objects with `name`, `module`, `description`, `tags`.
526
+ * Returns `null` for unsupported shapes.
527
+ *
528
+ * @stable
529
+ */
530
+ export function parseToolsField(value: unknown): ReadonlyArray<SkillToolDeclaration> | null {
531
+ if (!Array.isArray(value)) return null;
532
+ const out: SkillToolDeclaration[] = [];
533
+ for (const entry of value) {
534
+ if (typeof entry === 'string') {
535
+ if (entry.trim().length === 0) return null;
536
+ out.push(Object.freeze({ name: entry.trim() }));
537
+ continue;
538
+ }
539
+ if (entry !== null && typeof entry === 'object' && !Array.isArray(entry)) {
540
+ const obj = entry as Record<string, unknown>;
541
+ if (typeof obj.name !== 'string' || obj.name.trim().length === 0) return null;
542
+ const declaration: Mutable<SkillToolDeclaration> = { name: obj.name.trim() };
543
+ if (typeof obj.module === 'string' && obj.module.trim().length > 0)
544
+ declaration.module = obj.module.trim();
545
+ if (typeof obj.description === 'string') declaration.description = obj.description;
546
+ if (Array.isArray(obj.tags) && obj.tags.every((t) => typeof t === 'string')) {
547
+ declaration.tags = Object.freeze([...(obj.tags as string[])]);
548
+ }
549
+ out.push(Object.freeze(declaration as SkillToolDeclaration));
550
+ continue;
551
+ }
552
+ return null;
553
+ }
554
+ return Object.freeze(out);
555
+ }
556
+
557
+ /**
558
+ * Best-effort semver-range satisfaction check. Supports the patterns
559
+ * the framework actually emits (`^x.y.z`, `~x.y.z`, `>=x.y.z`,
560
+ * `>x.y.z`, `<=x.y.z`, `<x.y.z`, plain `x.y.z`, the AND combinator
561
+ * with whitespace) without pulling a runtime dependency on `semver`.
562
+ * Unrecognised inputs return `false` so the validator emits a typed
563
+ * diagnostic.
564
+ *
565
+ * @stable
566
+ */
567
+ export function isRuntimeCompatSatisfied(range: string, version: string): boolean {
568
+ const versionTuple = parseSemver(version);
569
+ if (versionTuple === null) return false;
570
+ const expressions = range.split(/\s+/u).filter((seg) => seg.length > 0);
571
+ for (const expression of expressions) {
572
+ if (!evaluateSemverExpression(expression, versionTuple)) return false;
573
+ }
574
+ return true;
575
+ }
576
+
577
+ function evaluateSemverExpression(
578
+ expression: string,
579
+ version: readonly [number, number, number],
580
+ ): boolean {
581
+ if (expression.startsWith('^')) {
582
+ const target = parseSemver(expression.slice(1));
583
+ if (target === null) return false;
584
+ if (target[0] === 0 && target[1] === 0) {
585
+ return version[0] === 0 && version[1] === 0 && version[2] === target[2];
586
+ }
587
+ if (target[0] === 0) {
588
+ return version[0] === 0 && version[1] === target[1] && cmpTuple(version, target) >= 0;
589
+ }
590
+ return version[0] === target[0] && cmpTuple(version, target) >= 0;
591
+ }
592
+ if (expression.startsWith('~')) {
593
+ const target = parseSemver(expression.slice(1));
594
+ if (target === null) return false;
595
+ return version[0] === target[0] && version[1] === target[1] && cmpTuple(version, target) >= 0;
596
+ }
597
+ if (expression.startsWith('>=')) {
598
+ const target = parseSemver(expression.slice(2));
599
+ return target !== null && cmpTuple(version, target) >= 0;
600
+ }
601
+ if (expression.startsWith('>')) {
602
+ const target = parseSemver(expression.slice(1));
603
+ return target !== null && cmpTuple(version, target) > 0;
604
+ }
605
+ if (expression.startsWith('<=')) {
606
+ const target = parseSemver(expression.slice(2));
607
+ return target !== null && cmpTuple(version, target) <= 0;
608
+ }
609
+ if (expression.startsWith('<')) {
610
+ const target = parseSemver(expression.slice(1));
611
+ return target !== null && cmpTuple(version, target) < 0;
612
+ }
613
+ if (expression === '*') return true;
614
+ const target = parseSemver(expression);
615
+ return target !== null && cmpTuple(version, target) === 0;
616
+ }
617
+
618
+ function parseSemver(value: string): [number, number, number] | null {
619
+ const trimmed = value.trim();
620
+ // Strip optional `v` prefix and any pre-release / build metadata -
621
+ // we only care about the canonical `MAJOR.MINOR.PATCH` triple.
622
+ const stripped = trimmed.replace(/^v/u, '').split(/[-+]/u, 1)[0] ?? '';
623
+ const parts = stripped.split('.');
624
+ if (parts.length < 1 || parts.length > 3) return null;
625
+ const [major, minor, patch] = [parts[0] ?? '0', parts[1] ?? '0', parts[2] ?? '0'];
626
+ if (!/^\d+$/u.test(major) || !/^\d+$/u.test(minor) || !/^\d+$/u.test(patch)) return null;
627
+ return [Number.parseInt(major, 10), Number.parseInt(minor, 10), Number.parseInt(patch, 10)];
628
+ }
629
+
630
+ function cmpTuple(
631
+ a: readonly [number, number, number],
632
+ b: readonly [number, number, number],
633
+ ): number {
634
+ if (a[0] !== b[0]) return a[0] - b[0];
635
+ if (a[1] !== b[1]) return a[1] - b[1];
636
+ return a[2] - b[2];
637
+ }
638
+
639
+ function isRecognisedField(field: string): boolean {
640
+ if (getKnownField(field) !== undefined) return true;
641
+ if (getGraphorinMapping(field) !== undefined) return true;
642
+ if (field === 'metadata') return true;
643
+ return false;
644
+ }
645
+
646
+ type Mutable<T> = { -readonly [K in keyof T]: T[K] };
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `@graphorin/skills` - skills surface for the Graphorin framework.
3
+ *
4
+ * The package owns:
5
+ *
6
+ * - `SKILL.md` loader with three-tier progressive disclosure
7
+ * (metadata always available; body and resources lazy-loaded).
8
+ * - Frontmatter validator implementing the field-resolution
9
+ * algorithm and conflict policy from ADR-043.
10
+ * - Bundled snapshot of the publicly published `SKILL.md` packaging-
11
+ * format specification + the framework-specific extension
12
+ * catalogue.
13
+ * - Slash-command parser (`/skill:<name>`) + auto-activation
14
+ * metadata generator.
15
+ * - `SkillRegistry` with name-collision detection, activation
16
+ * resolution, and a typed tool-declaration surface the agent
17
+ * runtime bridges into the `@graphorin/tools` registry.
18
+ * - Idempotent `migrateFrontmatter` library that rewrites legacy
19
+ * `graphorin-*` fields onto their upstream equivalents per the
20
+ * bundled mapping table.
21
+ *
22
+ * Stable sub-paths:
23
+ *
24
+ * ```ts
25
+ * import { loadSkills, loadSkillFromSource } from '@graphorin/skills/loader';
26
+ * import { createSkillRegistry } from '@graphorin/skills/registry';
27
+ * import { validateFrontmatter, resolveSkillField } from '@graphorin/skills/frontmatter';
28
+ * import { migrateFrontmatter } from '@graphorin/skills/migration';
29
+ * import { parseSlashCommand } from '@graphorin/skills/activation';
30
+ * import { getSpecSnapshot, getKnownField } from '@graphorin/skills/spec';
31
+ * import { SkillFrontmatterConflictError } from '@graphorin/skills/errors';
32
+ * ```
33
+ *
34
+ * @packageDocumentation
35
+ */
36
+
37
+ /** Canonical version constant, derived from `package.json` at build time. */
38
+ import pkg from '../package.json' with { type: 'json' };
39
+
40
+ export const VERSION: string = pkg.version;
41
+
42
+ export * from './activation/index.js';
43
+ export * from './errors/index.js';
44
+ export * from './frontmatter/index.js';
45
+ export * from './loader/index.js';
46
+ export * from './migration/index.js';
47
+ export * from './registry/index.js';
48
+ export * from './spec/index.js';
49
+ export * from './types/index.js';