happyskills 0.50.0 → 0.52.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,110 @@
1
+ const { test } = require('node:test')
2
+ const assert = require('node:assert/strict')
3
+ const { parse_typed, parse_scalar } = require('./yaml_frontmatter')
4
+
5
+ const wrap = (body) => `---\n${body}\n---\n`
6
+
7
+ test('parse_scalar: empty / null literals', () => {
8
+ assert.deepEqual(parse_scalar(''), { value: null, type: 'null' })
9
+ assert.deepEqual(parse_scalar('null'), { value: null, type: 'null' })
10
+ assert.deepEqual(parse_scalar('~'), { value: null, type: 'null' })
11
+ })
12
+
13
+ test('parse_scalar: booleans', () => {
14
+ assert.equal(parse_scalar('true').value, true)
15
+ assert.equal(parse_scalar('true').type, 'boolean')
16
+ assert.equal(parse_scalar('False').value, false)
17
+ })
18
+
19
+ test('parse_scalar: numbers', () => {
20
+ assert.deepEqual(parse_scalar('42'), { value: 42, type: 'number', is_int: true })
21
+ assert.equal(parse_scalar('1.5').value, 1.5)
22
+ })
23
+
24
+ test('parse_scalar: plain string', () => {
25
+ assert.deepEqual(parse_scalar('hello world'), { value: 'hello world', type: 'string', quoted: 'plain' })
26
+ })
27
+
28
+ test('parse_scalar: double-quoted string', () => {
29
+ const r = parse_scalar('"[question or topic]"')
30
+ assert.equal(r.value, '[question or topic]')
31
+ assert.equal(r.type, 'string')
32
+ assert.equal(r.quoted, 'double')
33
+ })
34
+
35
+ test('parse_scalar: single-quoted string', () => {
36
+ const r = parse_scalar("'foo bar'")
37
+ assert.equal(r.value, 'foo bar')
38
+ assert.equal(r.type, 'string')
39
+ assert.equal(r.quoted, 'single')
40
+ })
41
+
42
+ test('parse_scalar: unquoted brackets parse as array (the bug)', () => {
43
+ const r = parse_scalar('[question or topic]')
44
+ assert.equal(r.type, 'array')
45
+ assert.deepEqual(r.value, ['question or topic'])
46
+ })
47
+
48
+ test('parse_scalar: unquoted brace-delimited parses as object', () => {
49
+ const r = parse_scalar('{a: 1, b: 2}')
50
+ assert.equal(r.type, 'object')
51
+ assert.deepEqual(r.value, { a: 1, b: 2 })
52
+ })
53
+
54
+ test('parse_scalar: bracketed list with multiple items', () => {
55
+ const r = parse_scalar('[a, b, c]')
56
+ assert.deepEqual(r.value, ['a', 'b', 'c'])
57
+ })
58
+
59
+ test('parse_typed: full frontmatter with the bug', () => {
60
+ const content = wrap([
61
+ 'name: init-context',
62
+ 'description: ProjectMemory — Load project docs.',
63
+ 'argument-hint: [question or topic]'
64
+ ].join('\n'))
65
+
66
+ const parsed = parse_typed(content)
67
+ assert.equal(parsed.fields.name.type, 'string')
68
+ assert.equal(parsed.fields.description.type, 'string')
69
+ assert.equal(parsed.fields['argument-hint'].type, 'array')
70
+ assert.deepEqual(parsed.fields['argument-hint'].value, ['question or topic'])
71
+ })
72
+
73
+ test('parse_typed: quoted form is a string', () => {
74
+ const content = wrap('argument-hint: "[question or topic]"')
75
+ const parsed = parse_typed(content)
76
+ assert.equal(parsed.fields['argument-hint'].type, 'string')
77
+ assert.equal(parsed.fields['argument-hint'].value, '[question or topic]')
78
+ assert.equal(parsed.fields['argument-hint'].quoted, 'double')
79
+ })
80
+
81
+ test('parse_typed: trailing comment is stripped from plain scalars', () => {
82
+ const content = wrap('name: my-skill # this is a comment')
83
+ const parsed = parse_typed(content)
84
+ assert.equal(parsed.fields.name.value, 'my-skill')
85
+ })
86
+
87
+ test('parse_typed: # inside a quoted string is preserved', () => {
88
+ const content = wrap('description: "foo # bar"')
89
+ const parsed = parse_typed(content)
90
+ assert.equal(parsed.fields.description.value, 'foo # bar')
91
+ })
92
+
93
+ test('parse_typed: top-level colon inside unquoted value (deploy: production)', () => {
94
+ // `description: deploy: production` — first colon splits the key, the
95
+ // second one stays inside the value as a literal character.
96
+ const content = wrap('description: deploy: production')
97
+ const parsed = parse_typed(content)
98
+ assert.equal(parsed.fields.description.type, 'string')
99
+ assert.equal(parsed.fields.description.value, 'deploy: production')
100
+ })
101
+
102
+ test('parse_typed: returns null when no frontmatter block', () => {
103
+ assert.equal(parse_typed('no frontmatter here'), null)
104
+ })
105
+
106
+ test('parse_typed: surfaces structural errors for malformed lines', () => {
107
+ const content = wrap('name: ok\nthis line has no colon')
108
+ const parsed = parse_typed(content)
109
+ assert.ok(parsed.errors.length >= 1)
110
+ })
@@ -0,0 +1,107 @@
1
+ // Schema describing every known SKILL.md frontmatter field.
2
+ //
3
+ // Why a schema (and not per-field functions): bugs like the unquoted
4
+ // `argument-hint: [foo]` (which YAML parses as an array, not a string) are
5
+ // type mismatches. A single generic checker that compares each field's
6
+ // parsed YAML type against a declared `type` catches the whole class in one
7
+ // pass — and every new field gets the check by adding one row, not a new
8
+ // branch.
9
+ //
10
+ // Unknown fields are NOT rejected. Anthropic may add new frontmatter fields
11
+ // over time; we warn only when an unknown key looks like a typo of a known
12
+ // one (Levenshtein distance 1).
13
+
14
+ const NAME_PATTERN = /^[a-z][a-z0-9-]*$/
15
+
16
+ // Forbidden characters: YAML structural metacharacters that, even when they
17
+ // pass our parser as plain strings, break Claude Code's stricter loader or
18
+ // the skill discovery layer. See docs/gotchas/skills.md § 1.2.
19
+ const FORBIDDEN_CHARS = {
20
+ ';': 'semicolon', ':': 'colon', '#': 'hash', '{': 'left brace', '}': 'right brace',
21
+ '[': 'left bracket', ']': 'right bracket', "'": 'single quote', '"': 'double quote',
22
+ '!': 'exclamation', '&': 'ampersand', '*': 'asterisk', '%': 'percent', '|': 'pipe', '>': 'greater-than'
23
+ }
24
+
25
+ // Type categories accepted by the validator. Multi-type fields list each
26
+ // permitted type — e.g. `allowed-tools` can be a YAML string or a YAML list.
27
+ const FRONTMATTER_SCHEMA = {
28
+ name: {
29
+ types: ['string'],
30
+ required: true,
31
+ pattern: NAME_PATTERN,
32
+ pattern_message: 'must match /^[a-z][a-z0-9-]*$/',
33
+ max_length: 64,
34
+ non_empty: true,
35
+ matches_dir: true
36
+ },
37
+ description: {
38
+ types: ['string'],
39
+ required: true,
40
+ non_empty: true,
41
+ max_length: 1024,
42
+ forbidden_chars: true
43
+ },
44
+ 'argument-hint': {
45
+ types: ['string'],
46
+ forbidden_chars_in_plain_only: true
47
+ },
48
+ compatibility: {
49
+ types: ['string'],
50
+ max_length: 500,
51
+ forbidden_chars: true
52
+ },
53
+ 'allowed-tools': {
54
+ types: ['string', 'array']
55
+ },
56
+ 'disable-model-invocation': {
57
+ types: ['boolean']
58
+ },
59
+ 'user-invocable': {
60
+ types: ['boolean']
61
+ },
62
+ context: {
63
+ types: ['string'],
64
+ enum: ['fork']
65
+ },
66
+ agent: {
67
+ types: ['string']
68
+ },
69
+ keywords: {
70
+ types: ['string', 'array']
71
+ }
72
+ }
73
+
74
+ const KNOWN_FIELDS = Object.keys(FRONTMATTER_SCHEMA)
75
+
76
+ // Cheap Levenshtein for did-you-mean. Bounded at distance 2.
77
+ const levenshtein = (a, b) => {
78
+ if (a === b) return 0
79
+ if (Math.abs(a.length - b.length) > 2) return 3
80
+ const m = a.length, n = b.length
81
+ const prev = Array(n + 1).fill(0).map((_, i) => i)
82
+ const cur = Array(n + 1).fill(0)
83
+ for (let i = 1; i <= m; i++) {
84
+ cur[0] = i
85
+ for (let j = 1; j <= n; j++) {
86
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1
87
+ cur[j] = Math.min(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost)
88
+ }
89
+ for (let j = 0; j <= n; j++) prev[j] = cur[j]
90
+ }
91
+ return prev[n]
92
+ }
93
+
94
+ const did_you_mean = (key) => {
95
+ for (const known of KNOWN_FIELDS) {
96
+ if (levenshtein(key, known) === 1) return known
97
+ }
98
+ return null
99
+ }
100
+
101
+ module.exports = {
102
+ FRONTMATTER_SCHEMA,
103
+ FORBIDDEN_CHARS,
104
+ KNOWN_FIELDS,
105
+ NAME_PATTERN,
106
+ did_you_mean
107
+ }
@@ -2,84 +2,231 @@ const path = require('path')
2
2
  const { error: { catch_errors } } = require('puffy-core')
3
3
  const { file_exists, read_file } = require('../utils/fs')
4
4
  const { parse_frontmatter } = require('../utils/skill_scanner')
5
- const { SKILL_MD, SKILL_TYPES } = require('../constants')
5
+ const { parse_typed } = require('../utils/yaml_frontmatter')
6
+ const { SKILL_MD, README_MD, SKILL_TYPES } = require('../constants')
7
+ const {
8
+ FRONTMATTER_SCHEMA,
9
+ FORBIDDEN_CHARS,
10
+ KNOWN_FIELDS,
11
+ did_you_mean
12
+ } = require('./frontmatter_schema')
6
13
 
7
- const FORBIDDEN_CHARS = {
8
- ';': 'semicolon', ':': 'colon', '#': 'hash', '{': 'left brace', '}': 'right brace',
9
- '[': 'left bracket', ']': 'right bracket', "'": 'single quote', '"': 'double quote',
10
- '!': 'exclamation', '&': 'ampersand', '*': 'asterisk', '%': 'percent', '|': 'pipe', '>': 'greater-than'
11
- }
12
-
13
- const NAME_PATTERN = /^[a-z][a-z0-9-]*$/
14
14
  const PLACEHOLDER_DESC = 'Describe what this skill does and when to invoke it'
15
15
  const DESC_SOFT_CAP = 250
16
16
  const DESC_TARGET_MIN = 80
17
17
  const DESC_TARGET_MAX = 180
18
18
 
19
- const result = (field, rule, severity, message, value) => ({
20
- file: SKILL_MD, field, rule, severity, message, ...(value !== undefined ? { value } : {})
19
+ // Every result carries enough structured detail for an LLM (or human) to
20
+ // pinpoint and auto-correct: which file, which field, which YAML line, what
21
+ // the validator expected vs. saw, and — for the common fixable cases — the
22
+ // literal corrected line to write back.
23
+ const result = (field, rule, severity, message, value, extras = {}) => ({
24
+ file: SKILL_MD,
25
+ field,
26
+ rule,
27
+ severity,
28
+ message,
29
+ ...(value !== undefined ? { value } : {}),
30
+ ...extras
21
31
  })
22
32
 
23
- const validate_name = (fm, dir_name) => {
24
- const results = []
25
- const name = fm.name
33
+ // Format a parsed YAML type for an error message ("array", "boolean", etc.).
34
+ const fmt_type = (t) => t === 'block_scalar' ? 'block scalar' : t
26
35
 
27
- if (!name) {
28
- results.push(result('name', 'present', 'error', 'Frontmatter must include a "name" field'))
29
- return results
36
+ // Schema-driven validation. One pass over every declared field; one pass over
37
+ // every unknown field (with did-you-mean for typos).
38
+ const validate_against_schema = (typed, dir_name) => {
39
+ const results = []
40
+ const fields = typed.fields
41
+
42
+ // Bubble up structural parse errors (lines without a colon, etc.) first —
43
+ // they indicate the frontmatter is malformed enough that later checks may
44
+ // be misleading.
45
+ for (const err of typed.errors) {
46
+ results.push(result(null, 'frontmatter_parse', 'error',
47
+ `SKILL.md line ${err.line}: ${err.message}`,
48
+ undefined,
49
+ { line: err.line }
50
+ ))
30
51
  }
31
52
 
32
- results.push(result('name', 'present', 'pass', `name: "${name}"`, name))
53
+ // Required + type + per-rule checks for every known field.
54
+ for (const key of KNOWN_FIELDS) {
55
+ const schema = FRONTMATTER_SCHEMA[key]
56
+ const present = key in fields
57
+
58
+ if (!present) {
59
+ if (schema.required) {
60
+ results.push(result(key, 'present', 'error',
61
+ `Frontmatter is missing the required "${key}" field. Add it inside the --- block at the top of SKILL.md.`,
62
+ undefined,
63
+ { fix: `${key}: <value>` }
64
+ ))
65
+ }
66
+ continue
67
+ }
33
68
 
34
- if (name.length > 64) {
35
- results.push(result('name', 'max_length', 'error', `Name is ${name.length} chars (max 64)`, name))
36
- } else {
37
- results.push(result('name', 'max_length', 'pass', `name: ${name.length} chars (max 64)`, name))
69
+ const f = fields[key]
70
+
71
+ // Type check first — every later rule assumes the type matches.
72
+ if (!schema.types.includes(f.type)) {
73
+ const expected = schema.types.join(' or ')
74
+ const fix_line = f.type === 'array' || f.type === 'object'
75
+ ? `${key}: "${f.raw}"`
76
+ : null
77
+ const hint = f.type === 'array'
78
+ ? ` — looks like an unquoted "[...]" was interpreted as a YAML list. Quote the value to keep it as a string. Replace line ${f.line} with: ${fix_line}`
79
+ : f.type === 'object'
80
+ ? ` — looks like an unquoted "{...}" was interpreted as a YAML map. Quote the value to keep it as a string. Replace line ${f.line} with: ${fix_line}`
81
+ : ''
82
+ results.push(result(
83
+ key,
84
+ 'type',
85
+ 'error',
86
+ `SKILL.md line ${f.line}: "${key}" must be a ${expected} (got ${fmt_type(f.type)})${hint}`,
87
+ f.raw,
88
+ { line: f.line, expected, actual: fmt_type(f.type), ...(fix_line ? { fix: fix_line } : {}) }
89
+ ))
90
+ continue
91
+ }
92
+
93
+ results.push(result(key, 'type', 'pass', `${key}: ${fmt_type(f.type)}`, undefined, { line: f.line }))
94
+
95
+ // String-specific rules.
96
+ if (f.type === 'string') {
97
+ const value = f.value
98
+
99
+ if (schema.non_empty && !value.trim()) {
100
+ results.push(result(key, 'non_empty', 'error',
101
+ `SKILL.md line ${f.line}: "${key}" must not be empty or whitespace-only`,
102
+ undefined,
103
+ { line: f.line }
104
+ ))
105
+ continue
106
+ }
107
+
108
+ if (schema.max_length && value.length > schema.max_length) {
109
+ results.push(result(key, 'max_length', 'error',
110
+ `SKILL.md line ${f.line}: "${key}" is ${value.length} chars (max ${schema.max_length})`,
111
+ value,
112
+ { line: f.line, length: value.length, max: schema.max_length }
113
+ ))
114
+ } else if (schema.max_length) {
115
+ results.push(result(key, 'max_length', 'pass', `${key}: ${value.length} chars (max ${schema.max_length})`, undefined, { line: f.line }))
116
+ }
117
+
118
+ if (schema.pattern && !schema.pattern.test(value)) {
119
+ results.push(result(key, 'format', 'error',
120
+ `SKILL.md line ${f.line}: "${key}" ${schema.pattern_message || 'has invalid format'}. Got: "${value}"`,
121
+ value,
122
+ { line: f.line }
123
+ ))
124
+ }
125
+
126
+ if (schema.enum && !schema.enum.includes(value)) {
127
+ results.push(result(key, 'enum', 'warning',
128
+ `SKILL.md line ${f.line}: "${key}" should be one of [${schema.enum.join(', ')}], got "${value}"`,
129
+ value,
130
+ { line: f.line, expected: schema.enum, actual: value }
131
+ ))
132
+ }
133
+
134
+ // Forbidden-chars: always run on plain (unquoted) scalars, since those
135
+ // are the ones that can re-trigger YAML structural parsing in another
136
+ // loader. For quoted scalars, only run if the schema demands it.
137
+ const should_scan = schema.forbidden_chars || (schema.forbidden_chars_in_plain_only && f.quoted === 'plain')
138
+ if (should_scan) {
139
+ let found = false
140
+ for (const [char, ch_name] of Object.entries(FORBIDDEN_CHARS)) {
141
+ const idx = value.indexOf(char)
142
+ if (idx !== -1) {
143
+ results.push(result(key, 'no_forbidden_characters', 'error',
144
+ `SKILL.md line ${f.line}: "${key}" contains forbidden character ${ch_name} ('${char}') at position ${idx} of the value. Rephrase without it.`,
145
+ value,
146
+ { line: f.line, character: char, character_name: ch_name, char_index: idx }
147
+ ))
148
+ found = true
149
+ break
150
+ }
151
+ }
152
+ if (!found) results.push(result(key, 'no_forbidden_characters', 'pass', 'No forbidden characters', undefined, { line: f.line }))
153
+ }
154
+ }
38
155
  }
39
156
 
40
- if (!NAME_PATTERN.test(name)) {
41
- results.push(result('name', 'format', 'error', 'Name must match /^[a-z][a-z0-9-]*$/', name))
42
- } else {
43
- results.push(result('name', 'format', 'pass', 'Name format valid', name))
157
+ // Unknown-field warnings — did-you-mean for likely typos.
158
+ for (const key of Object.keys(fields)) {
159
+ if (KNOWN_FIELDS.includes(key)) continue
160
+ const f = fields[key]
161
+ const suggestion = did_you_mean(key)
162
+ if (suggestion) {
163
+ results.push(result(key, 'unknown_field', 'warning',
164
+ `SKILL.md line ${f.line}: Unknown frontmatter field "${key}" — did you mean "${suggestion}"? Rename the key on line ${f.line}.`,
165
+ undefined,
166
+ { line: f.line, suggestion, fix: `${suggestion}: ${f.raw}` }
167
+ ))
168
+ }
44
169
  }
45
170
 
171
+ return results
172
+ }
173
+
174
+ // Per-field post-checks that go beyond the schema (rich recommendations,
175
+ // name↔dir cross-check, placeholder detection, description soft cap).
176
+ const validate_name_extras = (fields, dir_name) => {
177
+ const results = []
178
+ const f = fields.name
179
+ if (!f || f.type !== 'string') return results
180
+ const name = f.value
181
+
46
182
  if (name.includes('--')) {
47
- results.push(result('name', 'no_consecutive_hyphens', 'error', 'Name must not contain consecutive hyphens (--)', name))
183
+ results.push(result('name', 'no_consecutive_hyphens', 'error',
184
+ `SKILL.md line ${f.line}: Name "${name}" must not contain consecutive hyphens (--)`,
185
+ name,
186
+ { line: f.line }
187
+ ))
48
188
  }
49
-
50
189
  if (name.startsWith('-') || name.endsWith('-')) {
51
- results.push(result('name', 'no_edge_hyphens', 'error', 'Name must not start or end with a hyphen', name))
190
+ results.push(result('name', 'no_edge_hyphens', 'error',
191
+ `SKILL.md line ${f.line}: Name "${name}" must not start or end with a hyphen`,
192
+ name,
193
+ { line: f.line }
194
+ ))
52
195
  }
53
-
54
196
  if (dir_name && name !== dir_name) {
55
- results.push(result('name', 'matches_directory', 'warning', `Name "${name}" does not match directory "${dir_name}"`, name))
197
+ results.push(result('name', 'matches_directory', 'warning',
198
+ `SKILL.md line ${f.line}: name "${name}" does not match directory "${dir_name}". Rename one so they match.`,
199
+ name,
200
+ { line: f.line, expected: dir_name, actual: name, fix: `name: ${dir_name}` }
201
+ ))
56
202
  } else if (dir_name) {
57
- results.push(result('name', 'matches_directory', 'pass', `Name matches directory "${dir_name}"`, name))
203
+ results.push(result('name', 'matches_directory', 'pass', `Name matches directory "${dir_name}"`, name, { line: f.line }))
58
204
  }
59
205
 
60
206
  return results
61
207
  }
62
208
 
63
- const validate_description = (fm) => {
209
+ const validate_description_extras = (fields) => {
64
210
  const results = []
65
- const desc = fm.description
211
+ const f = fields.description
212
+ if (!f || f.type !== 'string') return results
213
+ const desc = f.value
66
214
 
67
- if (!desc) {
68
- results.push(result('description', 'present', 'error', 'Frontmatter must include a "description" field'))
69
- return results
70
- }
71
-
72
- results.push(result('description', 'present', 'pass', `description present (${desc.length} chars)`))
73
-
74
- if (!desc.trim()) {
75
- results.push(result('description', 'non_empty', 'error', 'Description must not be empty or whitespace-only', desc))
76
- return results
215
+ if (desc === PLACEHOLDER_DESC) {
216
+ results.push(result('description', 'not_placeholder', 'warning',
217
+ `SKILL.md line ${f.line}: description is still the init placeholder — update it before publishing`,
218
+ desc,
219
+ { line: f.line }
220
+ ))
77
221
  }
78
222
 
79
223
  if (desc.length > 1024) {
80
- const deficit = desc.length - 1024
81
224
  results.push({
82
- ...result('description', 'max_length', 'error', `Description is ${desc.length} chars (max 1024). Must reduce by ${deficit} chars.`, desc),
225
+ ...result('description', 'max_length_advice', 'error',
226
+ `SKILL.md line ${f.line}: description is ${desc.length} chars (max 1024). Must reduce by ${desc.length - 1024} chars.`,
227
+ desc,
228
+ { line: f.line, length: desc.length, max: 1024 }
229
+ ),
83
230
  recommendations: [
84
231
  'STEP 1 - AUDIT: Before changing anything, read the skill\'s routing table or capability list. Map each phrase in the description to the capability it triggers. Mark each phrase as: IDENTITY (describes what the skill is, usually one phrase), UNIQUE (the only phrase matching a specific capability), or REINFORCING (overlaps with another phrase\'s coverage).',
85
232
  'STEP 2 - LOSSLESS COMPRESSION: Apply these transformations that reduce characters without changing semantic meaning: (a) Remove articles (a, an, the). (b) Remove possessives (my, your) when the subject is implied. (c) Remove filler verbs (do, does, can, have, is, am). (d) Merge parallel structures that share the same verb (e.g., \'install kit. publish kit\' becomes \'install, publish kits\') or the same object (e.g., \'find kits, search kits\' becomes \'find, search kits\'). Stop here if now under the limit.',
@@ -90,79 +237,75 @@ const validate_description = (fm) => {
90
237
  'STEP 4 - VERIFY: Cross-check the shortened description against the skill\'s routing table or capability list. Every documented capability must still have at least one semantically matching phrase in the description. If any capability lost coverage, restore its trigger phrase and find different savings.'
91
238
  ]
92
239
  })
240
+ } else if (desc.length <= DESC_SOFT_CAP) {
241
+ results.push(result('description', 'soft_cap', 'pass', `Description: ${desc.length} chars (soft cap ${DESC_SOFT_CAP})`, undefined, { line: f.line }))
93
242
  } else {
94
- results.push(result('description', 'max_length', 'pass', `Description: ${desc.length} chars (max 1024)`))
95
-
96
- if (desc.length > DESC_SOFT_CAP) {
97
- results.push({
98
- ...result('description', 'soft_cap', 'warning', `Description is ${desc.length} chars (target: ${DESC_TARGET_MIN}-${DESC_TARGET_MAX}, soft cap: ${DESC_SOFT_CAP}). Above ${DESC_SOFT_CAP} chars usually signals a mega-skill — apply the Constellation Pattern to decompose it into a core skill plus focused satellites. Run "npx happyskills audit <name>" or ask your agent "audit this skill" — happyskills-design will walk you through the Constellation Decomposition Workflow.`, desc),
99
- recommendations: [
100
- 'CANONICAL Apply the Constellation Pattern: decompose the skill into a core entry-point skill plus satellite skills, each owning one orthogonal verb cluster, bundled via the core skill.json dependencies. This is the answer once a description crosses the soft cap. happyskills-design implements the Constellation Decomposition Workflow end-to-end — invoke it with "audit this skill" or "decompose this mega-skill".',
101
- 'Alternative — Compress the description first (AUDIT/LOSSLESS/LOSSY procedure in happyskills-design references/skill-authoring.md). Buys time, but compression alone will not keep up as the API surface grows.',
102
- 'Alternative — Hybrid umbrella+satellites: keep one main skill for high-frequency operations, extract specialized domains into satellites.',
103
- 'For the canonical Constellation Pattern reference (orthogonal verb ownership, the five-slot description grammar, failure modes, orthogonality test): happyskills-design references/constellation-pattern.md, or docs/cli-skill.md in the HappySkills repo.'
104
- ]
105
- })
106
- } else {
107
- results.push(result('description', 'soft_cap', 'pass', `Description: ${desc.length} chars (soft cap ${DESC_SOFT_CAP})`))
108
- }
243
+ results.push({
244
+ ...result('description', 'soft_cap', 'warning', `SKILL.md line ${f.line}: description is ${desc.length} chars (target: ${DESC_TARGET_MIN}-${DESC_TARGET_MAX}, soft cap: ${DESC_SOFT_CAP}). Above ${DESC_SOFT_CAP} chars usually signals a mega-skill — apply the Constellation Pattern to decompose it into a core skill plus focused satellites. Run "npx happyskills audit <name>" or ask your agent "audit this skill" — happyskills-design will walk you through the Constellation Decomposition Workflow.`, desc),
245
+ recommendations: [
246
+ 'CANONICAL — Apply the Constellation Pattern: decompose the skill into a core entry-point skill plus satellite skills, each owning one orthogonal verb cluster, bundled via the core skill.json dependencies. This is the answer once a description crosses the soft cap. happyskills-design implements the Constellation Decomposition Workflow end-to-end — invoke it with "audit this skill" or "decompose this mega-skill".',
247
+ 'Alternative Compress the description first (AUDIT/LOSSLESS/LOSSY procedure in happyskills-design references/skill-authoring.md). Buys time, but compression alone will not keep up as the API surface grows.',
248
+ 'Alternative — Hybrid umbrella+satellites: keep one main skill for high-frequency operations, extract specialized domains into satellites.',
249
+ 'For the canonical Constellation Pattern reference (orthogonal verb ownership, the five-slot description grammar, failure modes, orthogonality test): happyskills-design references/constellation-pattern.md, or docs/cli-skill.md in the HappySkills repo.'
250
+ ]
251
+ })
109
252
  }
110
253
 
111
- if (desc === PLACEHOLDER_DESC) {
112
- results.push(result('description', 'not_placeholder', 'warning', 'Description is the init placeholder — update it before publishing', desc))
113
- }
254
+ return results
255
+ }
114
256
 
115
- for (const [char, name] of Object.entries(FORBIDDEN_CHARS)) {
116
- if (desc.includes(char)) {
117
- results.push(result('description', 'no_forbidden_characters', 'error', `Description contains forbidden character: ${name} (${char})`, desc))
118
- return results
119
- }
257
+ const validate_agent_requires_fork = (fields) => {
258
+ const results = []
259
+ if (fields.agent === undefined) return results
260
+ const context = fields.context
261
+ const agent_f = fields.agent
262
+ if (!context || context.value !== 'fork') {
263
+ results.push(result('agent', 'requires_context', 'warning',
264
+ `SKILL.md line ${agent_f.line}: agent is set but context is not "fork" — agent skills require "context: fork". Add (or change) the context line.`,
265
+ undefined,
266
+ { line: agent_f.line, fix: 'context: fork' }
267
+ ))
120
268
  }
121
-
122
- results.push(result('description', 'no_forbidden_characters', 'pass', 'No forbidden characters'))
123
269
  return results
124
270
  }
125
271
 
126
- const validate_optional_fields = (fm) => {
127
- const results = []
272
+ const validate_skill_md = (skill_dir, dir_name, skill_type) => catch_errors('Failed to validate SKILL.md', async () => {
273
+ const is_kit = skill_type === SKILL_TYPES.KIT
128
274
 
129
- if (fm.compatibility !== undefined) {
130
- if (fm.compatibility.length > 500) {
131
- results.push(result('compatibility', 'max_length', 'error', `Compatibility is ${fm.compatibility.length} chars (max 500)`, fm.compatibility))
132
- } else {
133
- results.push(result('compatibility', 'max_length', 'pass', `Compatibility: ${fm.compatibility.length} chars (max 500)`))
275
+ // Kits have no SKILL.md at all — they ship a README.md describing the
276
+ // bundle. Validate that branch separately so the file-label, error
277
+ // messages, and skipped checks all reflect the kit format.
278
+ if (is_kit) {
279
+ const readme_path = path.join(skill_dir, README_MD)
280
+ const skill_md_path = path.join(skill_dir, SKILL_MD)
281
+ const kit_result = (field, rule, severity, message, value) => ({
282
+ file: README_MD, field, rule, severity, message, ...(value !== undefined ? { value } : {})
283
+ })
284
+ const kit_results = []
285
+ const [, readme_exists] = await file_exists(readme_path)
286
+ if (!readme_exists) {
287
+ kit_results.push(kit_result(null, 'exists', 'error', 'README.md not found (required for kits)'))
288
+ return { results: kit_results, frontmatter: null, content: null }
134
289
  }
135
- }
136
-
137
- const bool_fields = ['disable-model-invocation', 'user-invocable']
138
- for (const field of bool_fields) {
139
- if (fm[field] !== undefined) {
140
- const val = fm[field]
141
- if (val !== 'true' && val !== 'false') {
142
- results.push(result(field, 'boolean_type', 'warning', `${field} should be true or false, got "${val}"`, val))
143
- } else {
144
- results.push(result(field, 'boolean_type', 'pass', `${field}: ${val}`))
145
- }
290
+ kit_results.push(kit_result(null, 'exists', 'pass', 'README.md exists'))
291
+
292
+ // Kits must NOT contain a SKILL.md — its presence would re-expose
293
+ // the kit to agent auto-invocation and re-trigger the frontmatter
294
+ // warnings in Codex/Gemini that the README.md format exists to avoid.
295
+ const [, skill_md_exists] = await file_exists(skill_md_path)
296
+ if (skill_md_exists) {
297
+ kit_results.push({
298
+ file: SKILL_MD, field: null, rule: 'not_in_kit', severity: 'error',
299
+ message: 'Kits must not contain a SKILL.md — delete it. Kit documentation belongs in README.md.'
300
+ })
146
301
  }
147
- }
148
-
149
- if (fm.context !== undefined && fm.context !== 'fork') {
150
- results.push(result('context', 'value', 'warning', `context should be "fork", got "${fm.context}"`, fm.context))
151
- } else if (fm.context !== undefined) {
152
- results.push(result('context', 'value', 'pass', 'context: fork'))
153
- }
154
302
 
155
- if (fm.agent !== undefined && fm.context !== 'fork') {
156
- results.push(result('agent', 'requires_context', 'warning', 'agent is set but context is not "fork" — agent skills require context: fork'))
303
+ const [, readme_content] = await read_file(readme_path)
304
+ return { results: kit_results, frontmatter: null, content: readme_content }
157
305
  }
158
306
 
159
- return results
160
- }
161
-
162
- const validate_skill_md = (skill_dir, dir_name, skill_type) => catch_errors('Failed to validate SKILL.md', async () => {
163
307
  const results = []
164
308
  const md_path = path.join(skill_dir, SKILL_MD)
165
- const is_kit = skill_type === SKILL_TYPES.KIT
166
309
 
167
310
  const [, exists] = await file_exists(md_path)
168
311
  if (!exists) {
@@ -174,22 +317,23 @@ const validate_skill_md = (skill_dir, dir_name, skill_type) => catch_errors('Fai
174
317
 
175
318
  const [, content] = await read_file(md_path)
176
319
 
177
- if (is_kit) {
178
- return { results, frontmatter: null, content }
179
- }
180
-
320
+ // Two parses: the legacy plain-string parser keeps downstream callers
321
+ // (cross-rule validation, convert, etc.) working unchanged, while the
322
+ // typed parser is what the schema runs against.
181
323
  const fm = parse_frontmatter(content)
324
+ const typed = parse_typed(content)
182
325
 
183
- if (!fm) {
326
+ if (!typed) {
184
327
  results.push(result(null, 'frontmatter', 'error', 'SKILL.md has no YAML frontmatter (---)'))
185
328
  return { results, frontmatter: null, content }
186
329
  }
187
330
 
188
331
  results.push(result(null, 'frontmatter', 'pass', 'Frontmatter present'))
189
332
 
190
- results.push(...validate_name(fm, dir_name))
191
- results.push(...validate_description(fm))
192
- results.push(...validate_optional_fields(fm))
333
+ results.push(...validate_against_schema(typed, dir_name))
334
+ results.push(...validate_name_extras(typed.fields, dir_name))
335
+ results.push(...validate_description_extras(typed.fields))
336
+ results.push(...validate_agent_requires_fork(typed.fields))
193
337
 
194
338
  const line_count = content.split('\n').length
195
339
  if (line_count >= 500) {