@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.
- package/CHANGELOG.md +20 -0
- package/README.md +3 -3
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/package.json +14 -13
- package/src/activation/index.ts +76 -0
- package/src/errors/index.ts +162 -0
- package/src/frontmatter/index.ts +646 -0
- package/src/index.ts +49 -0
- package/src/loader/index.ts +813 -0
- package/src/migration/index.ts +192 -0
- package/src/registry/bridge.ts +169 -0
- package/src/registry/index.ts +414 -0
- package/src/spec/index.ts +158 -0
- package/src/types/index.ts +338 -0
|
@@ -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';
|