happyskills 0.40.0 → 0.41.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 CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.41.0] - 2026-05-04
11
+
12
+ ### Added
13
+ - Add `versions` command — list every published version of a skill, newest first. Useful for deciding which version to install when the latest is not always the right pick. Supports `--limit <n>` and `--json`. Backed by the existing `GET /repos/:owner/:repo/refs` endpoint, so the same access rules apply (public skills require no auth; private/workspace skills require read access).
14
+ - Add `changelog` command — print a skill's `CHANGELOG.md` as it exists at the latest version, or at a specific version with `--version <ver>`. Useful for reasoning about when a feature shipped before pinning to an older release. Falls back to a synthesized changelog built from registry release messages when the skill has no `CHANGELOG.md`. Supports `--json`. Resolves the file via `GET /tree?ref=...` + `GET /blob/:sha`, both of which enforce read access server-side.
15
+ - Add 250-char description soft-cap warning to `validate` for `SKILL.md`. Above the soft cap, the warning recommends decomposing the skill into a focused family or applying the AUDIT/LOSSLESS/LOSSY compression procedure — flags mega-skill descriptions before they hit the registry.
16
+
10
17
  ## [0.40.0] - 2026-05-03
11
18
 
12
19
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
package/src/api/repos.js CHANGED
@@ -80,6 +80,13 @@ const get_blob = (owner, repo, sha) => catch_errors(`Get blob ${owner}/${repo}/$
80
80
  return data
81
81
  })
82
82
 
83
+ const get_tree = (owner, repo, ref) => catch_errors(`Get tree for ${owner}/${repo} failed`, async () => {
84
+ const qs = ref ? `?ref=${encodeURIComponent(ref)}` : ''
85
+ const [errors, data] = await client.get(`/repos/${owner}/${repo}/tree${qs}`)
86
+ if (errors) throw errors[errors.length - 1]
87
+ return data
88
+ })
89
+
83
90
  const semantic_search = (query, options = {}) => catch_errors('Semantic search failed', async () => {
84
91
  const body = {
85
92
  q: query,
@@ -98,4 +105,4 @@ const semantic_search = (query, options = {}) => catch_errors('Semantic search f
98
105
  return data
99
106
  })
100
107
 
101
- module.exports = { search, semantic_search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, compare, get_blob }
108
+ module.exports = { search, semantic_search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, compare, get_blob, get_tree }
@@ -0,0 +1,125 @@
1
+ const { error: { catch_errors } } = require('puffy-core')
2
+ const { get_refs, get_tree, get_blob } = require('../api/repos')
3
+ const { print_help, print_json, print_info } = require('../ui/output')
4
+ const { exit_with_error, UsageError } = require('../utils/errors')
5
+ const { EXIT_CODES } = require('../constants')
6
+ const { extract_version, sort_refs } = require('./versions')
7
+
8
+ const HELP_TEXT = `Usage: happyskills changelog <owner/name> [options]
9
+
10
+ Print a skill's CHANGELOG.md as it exists at the latest version (or at a
11
+ specific version with --version).
12
+
13
+ If the skill has no CHANGELOG.md file, a synthesized changelog is generated
14
+ from the registry's release messages.
15
+
16
+ Arguments:
17
+ owner/name Skill identifier (e.g., acme/deploy-aws)
18
+
19
+ Options:
20
+ --version <ver> Read the changelog at this specific version (default: latest)
21
+ --json Output as JSON
22
+
23
+ Examples:
24
+ happyskills changelog acme/deploy-aws
25
+ happyskills changelog acme/deploy-aws --version 1.2.0
26
+ happyskills changelog acme/deploy-aws --json`
27
+
28
+ const CHANGELOG_FILENAMES = ['CHANGELOG.md', 'changelog.md', 'CHANGELOG', 'Changelog.md']
29
+
30
+ const find_changelog_entry = (entries) => {
31
+ if (!Array.isArray(entries)) return null
32
+ for (const name of CHANGELOG_FILENAMES) {
33
+ const hit = entries.find(e => e.path === name)
34
+ if (hit) return hit
35
+ }
36
+ return null
37
+ }
38
+
39
+ const synthesize_from_refs = (refs, skill) => {
40
+ const sorted = sort_refs(refs)
41
+ const lines = [`# Changelog — ${skill}`, '']
42
+ lines.push('_This skill has no CHANGELOG.md file. The entries below are synthesized from registry release messages._', '')
43
+ for (const r of sorted) {
44
+ const version = extract_version(r.name)
45
+ const date = r.created_at ? r.created_at.slice(0, 10) : ''
46
+ lines.push(`## ${version}${date ? ` — ${date}` : ''}`)
47
+ lines.push('')
48
+ lines.push(r.message || '_(no release message)_')
49
+ lines.push('')
50
+ }
51
+ return lines.join('\n')
52
+ }
53
+
54
+ const normalize_ref = (version_str) => {
55
+ if (!version_str) return null
56
+ if (version_str.startsWith('refs/')) return version_str
57
+ return `refs/tags/v${version_str.replace(/^v/, '')}`
58
+ }
59
+
60
+ const run = (args) => catch_errors('Changelog failed', async () => {
61
+ if (args.flags._show_help) {
62
+ print_help(HELP_TEXT)
63
+ return process.exit(EXIT_CODES.SUCCESS)
64
+ }
65
+
66
+ const skill = args._[0]
67
+ if (!skill) {
68
+ throw new UsageError('Please specify a skill (e.g., happyskills changelog acme/deploy-aws).')
69
+ }
70
+ if (!skill.includes('/')) {
71
+ throw new UsageError('Skill must be in owner/name format (e.g., acme/deploy-aws).')
72
+ }
73
+
74
+ const [owner, name] = skill.split('/')
75
+ const requested_version = args.flags.version && args.flags.version !== true
76
+ ? String(args.flags.version)
77
+ : null
78
+ const requested_ref = normalize_ref(requested_version)
79
+
80
+ const [tree_err, tree] = await get_tree(owner, name, requested_ref)
81
+ if (tree_err) throw tree_err[tree_err.length - 1]
82
+
83
+ const resolved_ref = tree.ref
84
+ const resolved_version = extract_version(resolved_ref)
85
+ const resolved_commit = tree.commit
86
+
87
+ const entry = find_changelog_entry(tree.entries)
88
+
89
+ let content
90
+ let synthesized = false
91
+
92
+ if (entry) {
93
+ const [blob_err, blob] = await get_blob(owner, name, entry.sha)
94
+ if (blob_err) throw blob_err[blob_err.length - 1]
95
+ content = Buffer.from(blob.content || '', 'base64').toString('utf8')
96
+ } else {
97
+ const [refs_err, refs] = await get_refs(owner, name)
98
+ if (refs_err) throw refs_err[refs_err.length - 1]
99
+ content = synthesize_from_refs(refs, skill)
100
+ synthesized = true
101
+ }
102
+
103
+ if (args.flags.json) {
104
+ print_json({
105
+ data: {
106
+ skill,
107
+ version: resolved_version,
108
+ ref: resolved_ref,
109
+ commit: resolved_commit,
110
+ synthesized,
111
+ content
112
+ }
113
+ })
114
+ return
115
+ }
116
+
117
+ if (synthesized) {
118
+ print_info(`No CHANGELOG.md in ${skill}@${resolved_version} — showing release messages from the registry.`)
119
+ process.stdout.write('\n')
120
+ }
121
+ process.stdout.write(content)
122
+ if (!content.endsWith('\n')) process.stdout.write('\n')
123
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
124
+
125
+ module.exports = { run, find_changelog_entry, synthesize_from_refs, normalize_ref }
@@ -0,0 +1,69 @@
1
+ 'use strict'
2
+ const { describe, it } = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const { find_changelog_entry, synthesize_from_refs, normalize_ref } = require('./changelog')
5
+
6
+ describe('changelog — find_changelog_entry', () => {
7
+ it('finds CHANGELOG.md (canonical case)', () => {
8
+ const entries = [
9
+ { path: 'SKILL.md', sha: 'a' },
10
+ { path: 'CHANGELOG.md', sha: 'b' }
11
+ ]
12
+ assert.strictEqual(find_changelog_entry(entries).sha, 'b')
13
+ })
14
+
15
+ it('falls back to lowercase variant', () => {
16
+ const entries = [{ path: 'changelog.md', sha: 'c' }]
17
+ assert.strictEqual(find_changelog_entry(entries).sha, 'c')
18
+ })
19
+
20
+ it('returns null when no changelog file present', () => {
21
+ const entries = [{ path: 'SKILL.md', sha: 'a' }]
22
+ assert.strictEqual(find_changelog_entry(entries), null)
23
+ })
24
+
25
+ it('handles non-array input safely', () => {
26
+ assert.strictEqual(find_changelog_entry(null), null)
27
+ })
28
+ })
29
+
30
+ describe('changelog — normalize_ref', () => {
31
+ it('returns null for empty input', () => {
32
+ assert.strictEqual(normalize_ref(null), null)
33
+ assert.strictEqual(normalize_ref(''), null)
34
+ })
35
+
36
+ it('passes through full refs', () => {
37
+ assert.strictEqual(normalize_ref('refs/tags/v1.2.0'), 'refs/tags/v1.2.0')
38
+ })
39
+
40
+ it('wraps plain semver into a tag ref', () => {
41
+ assert.strictEqual(normalize_ref('1.2.0'), 'refs/tags/v1.2.0')
42
+ })
43
+
44
+ it('strips leading v before re-wrapping', () => {
45
+ assert.strictEqual(normalize_ref('v1.2.0'), 'refs/tags/v1.2.0')
46
+ })
47
+ })
48
+
49
+ describe('changelog — synthesize_from_refs', () => {
50
+ it('produces sections newest first with version + date headings', () => {
51
+ const refs = [
52
+ { name: 'refs/tags/v1.0.0', created_at: '2026-01-01T00:00:00Z', message: 'Initial release' },
53
+ { name: 'refs/tags/v1.1.0', created_at: '2026-02-01T00:00:00Z', message: 'Add feature X' }
54
+ ]
55
+ const out = synthesize_from_refs(refs, 'acme/foo')
56
+ assert.ok(out.includes('# Changelog — acme/foo'))
57
+ assert.ok(out.includes('## 1.1.0 — 2026-02-01'))
58
+ assert.ok(out.includes('Add feature X'))
59
+ const idx_new = out.indexOf('## 1.1.0')
60
+ const idx_old = out.indexOf('## 1.0.0')
61
+ assert.ok(idx_new < idx_old, 'newest version should come first')
62
+ })
63
+
64
+ it('falls back to placeholder when message is empty', () => {
65
+ const refs = [{ name: 'refs/tags/v1.0.0', created_at: null, message: null }]
66
+ const out = synthesize_from_refs(refs, 'acme/foo')
67
+ assert.ok(out.includes('_(no release message)_'))
68
+ })
69
+ })
@@ -0,0 +1,92 @@
1
+ const { error: { catch_errors } } = require('puffy-core')
2
+ const { get_refs } = require('../api/repos')
3
+ const { print_help, print_table, print_json, print_info } = require('../ui/output')
4
+ const { exit_with_error, UsageError } = require('../utils/errors')
5
+ const { EXIT_CODES } = require('../constants')
6
+
7
+ const HELP_TEXT = `Usage: happyskills versions <owner/name> [options]
8
+
9
+ List all published versions of a skill, newest first.
10
+
11
+ Arguments:
12
+ owner/name Skill identifier (e.g., acme/deploy-aws)
13
+
14
+ Options:
15
+ --limit <n> Limit the number of versions shown (default: all)
16
+ --json Output as JSON
17
+
18
+ Examples:
19
+ happyskills versions acme/deploy-aws
20
+ happyskills versions acme/deploy-aws --limit 20
21
+ happyskills versions acme/deploy-aws --json`
22
+
23
+ const extract_version = (ref_name) => {
24
+ const m = (ref_name || '').match(/^refs\/tags\/v?(.+)$/)
25
+ return m ? m[1] : ref_name
26
+ }
27
+
28
+ const sort_refs = (refs) => {
29
+ const copy = [...(refs || [])]
30
+ copy.sort((a, b) => {
31
+ const ta = a.created_at ? Date.parse(a.created_at) : 0
32
+ const tb = b.created_at ? Date.parse(b.created_at) : 0
33
+ return tb - ta
34
+ })
35
+ return copy
36
+ }
37
+
38
+ const run = (args) => catch_errors('Versions failed', async () => {
39
+ if (args.flags._show_help) {
40
+ print_help(HELP_TEXT)
41
+ return process.exit(EXIT_CODES.SUCCESS)
42
+ }
43
+
44
+ const skill = args._[0]
45
+ if (!skill) {
46
+ throw new UsageError('Please specify a skill (e.g., happyskills versions acme/deploy-aws).')
47
+ }
48
+ if (!skill.includes('/')) {
49
+ throw new UsageError('Skill must be in owner/name format (e.g., acme/deploy-aws).')
50
+ }
51
+
52
+ const limit = args.flags.limit ? parseInt(args.flags.limit, 10) : null
53
+ if (args.flags.limit && (Number.isNaN(limit) || limit < 1)) {
54
+ throw new UsageError('--limit must be a positive integer.')
55
+ }
56
+
57
+ const [owner, name] = skill.split('/')
58
+
59
+ const [errors, refs] = await get_refs(owner, name)
60
+ if (errors) throw errors[errors.length - 1]
61
+
62
+ const sorted = sort_refs(refs)
63
+ const limited = limit ? sorted.slice(0, limit) : sorted
64
+
65
+ const versions = limited.map(r => ({
66
+ version: extract_version(r.name),
67
+ ref: r.name,
68
+ commit: r.sha,
69
+ message: r.message || '',
70
+ published_at: r.created_at || null
71
+ }))
72
+
73
+ if (args.flags.json) {
74
+ print_json({ data: { skill, count: versions.length, versions } })
75
+ return
76
+ }
77
+
78
+ if (versions.length === 0) {
79
+ print_info(`No versions published for ${skill}.`)
80
+ return
81
+ }
82
+
83
+ const rows = versions.map(v => [
84
+ v.version,
85
+ v.published_at ? v.published_at.slice(0, 10) : '-',
86
+ (v.commit || '').slice(0, 7),
87
+ v.message
88
+ ])
89
+ print_table(['VERSION', 'PUBLISHED', 'COMMIT', 'MESSAGE'], rows)
90
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
91
+
92
+ module.exports = { run, extract_version, sort_refs }
@@ -0,0 +1,53 @@
1
+ 'use strict'
2
+ const { describe, it } = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const { extract_version, sort_refs } = require('./versions')
5
+
6
+ describe('versions — extract_version', () => {
7
+ it('strips refs/tags/v prefix', () => {
8
+ assert.strictEqual(extract_version('refs/tags/v1.2.0'), '1.2.0')
9
+ })
10
+
11
+ it('handles refs without leading v', () => {
12
+ assert.strictEqual(extract_version('refs/tags/1.2.0'), '1.2.0')
13
+ })
14
+
15
+ it('returns the input when not a tag ref', () => {
16
+ assert.strictEqual(extract_version('main'), 'main')
17
+ })
18
+
19
+ it('returns input for null/undefined safely', () => {
20
+ assert.strictEqual(extract_version(''), '')
21
+ })
22
+ })
23
+
24
+ describe('versions — sort_refs', () => {
25
+ it('sorts newest first by created_at', () => {
26
+ const refs = [
27
+ { name: 'refs/tags/v1.0.0', created_at: '2026-01-01T00:00:00Z' },
28
+ { name: 'refs/tags/v1.2.0', created_at: '2026-03-01T00:00:00Z' },
29
+ { name: 'refs/tags/v1.1.0', created_at: '2026-02-01T00:00:00Z' }
30
+ ]
31
+ const sorted = sort_refs(refs)
32
+ assert.deepStrictEqual(sorted.map(r => r.name), [
33
+ 'refs/tags/v1.2.0',
34
+ 'refs/tags/v1.1.0',
35
+ 'refs/tags/v1.0.0'
36
+ ])
37
+ })
38
+
39
+ it('handles empty/null input', () => {
40
+ assert.deepStrictEqual(sort_refs([]), [])
41
+ assert.deepStrictEqual(sort_refs(null), [])
42
+ })
43
+
44
+ it('does not mutate input array', () => {
45
+ const refs = [
46
+ { name: 'a', created_at: '2026-01-01T00:00:00Z' },
47
+ { name: 'b', created_at: '2026-02-01T00:00:00Z' }
48
+ ]
49
+ const original = [...refs]
50
+ sort_refs(refs)
51
+ assert.deepStrictEqual(refs, original)
52
+ })
53
+ })
package/src/constants.js CHANGED
@@ -70,7 +70,9 @@ const COMMANDS = [
70
70
  'groups',
71
71
  'access',
72
72
  'enable',
73
- 'disable'
73
+ 'disable',
74
+ 'versions',
75
+ 'changelog'
74
76
  ]
75
77
 
76
78
  module.exports = {
package/src/index.js CHANGED
@@ -91,6 +91,8 @@ Commands:
91
91
  visibility <owner/skill> Check or set visibility (alias: vis)
92
92
  list List installed skills (alias: ls)
93
93
  search <query> Search the registry (alias: s)
94
+ versions <owner/skill> List all published versions of a skill
95
+ changelog <owner/skill> Print a skill's CHANGELOG.md
94
96
  check [owner/skill] Check for available updates
95
97
  status [owner/skill] Show divergence status (alias: st)
96
98
  pull <owner/skill> Pull remote changes and merge
@@ -947,3 +947,68 @@ describe('list — enabled column', () => {
947
947
  }
948
948
  })
949
949
  })
950
+
951
+ // ─── versions / changelog commands ────────────────────────────────────────────
952
+
953
+ describe('CLI — versions command', () => {
954
+ it('versions --help exits 0 and shows usage', () => {
955
+ const { stdout, code } = run(['versions', '--help'])
956
+ assert.strictEqual(code, 0)
957
+ assert.ok(stdout.includes('Arguments:'))
958
+ assert.ok(stdout.includes('Options:'))
959
+ assert.ok(stdout.includes('--limit'))
960
+ })
961
+
962
+ it('versions without arguments exits with usage error', () => {
963
+ const { code, stderr } = run(['versions'])
964
+ assert.strictEqual(code, 2)
965
+ assert.ok(stderr.includes('specify a skill'))
966
+ })
967
+
968
+ it('versions with no slash exits with usage error', () => {
969
+ const { code, stderr } = run(['versions', 'deploy-aws'])
970
+ assert.strictEqual(code, 2)
971
+ assert.ok(stderr.includes('owner/name format'))
972
+ })
973
+
974
+ it('versions with bad --limit exits with usage error', () => {
975
+ const { code, stderr } = run(['versions', 'acme/deploy-aws', '--limit', 'abc'])
976
+ assert.strictEqual(code, 2)
977
+ assert.ok(stderr.toLowerCase().includes('limit'))
978
+ })
979
+
980
+ it('versions acme/deploy-aws --json fails with NETWORK_ERROR (no server)', () => {
981
+ const { stdout, code } = run(['versions', 'acme/deploy-aws', '--json'])
982
+ assert.strictEqual(code, 4)
983
+ const out = parse_json_output(stdout, 'versions --json network failure')
984
+ assert.strictEqual(out.error.code, 'NETWORK_ERROR')
985
+ })
986
+ })
987
+
988
+ describe('CLI — changelog command', () => {
989
+ it('changelog --help exits 0 and shows usage', () => {
990
+ const { stdout, code } = run(['changelog', '--help'])
991
+ assert.strictEqual(code, 0)
992
+ assert.ok(stdout.includes('Arguments:'))
993
+ assert.ok(stdout.includes('--version'))
994
+ })
995
+
996
+ it('changelog without arguments exits with usage error', () => {
997
+ const { code, stderr } = run(['changelog'])
998
+ assert.strictEqual(code, 2)
999
+ assert.ok(stderr.includes('specify a skill'))
1000
+ })
1001
+
1002
+ it('changelog with no slash exits with usage error', () => {
1003
+ const { code, stderr } = run(['changelog', 'deploy-aws'])
1004
+ assert.strictEqual(code, 2)
1005
+ assert.ok(stderr.includes('owner/name format'))
1006
+ })
1007
+
1008
+ it('changelog acme/deploy-aws --json fails with NETWORK_ERROR (no server)', () => {
1009
+ const { stdout, code } = run(['changelog', 'acme/deploy-aws', '--json'])
1010
+ assert.strictEqual(code, 4)
1011
+ const out = parse_json_output(stdout, 'changelog --json network failure')
1012
+ assert.strictEqual(out.error.code, 'NETWORK_ERROR')
1013
+ })
1014
+ })
@@ -12,6 +12,9 @@ const FORBIDDEN_CHARS = {
12
12
 
13
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
+ const DESC_SOFT_CAP = 250
16
+ const DESC_TARGET_MIN = 80
17
+ const DESC_TARGET_MAX = 180
15
18
 
16
19
  const result = (field, rule, severity, message, value) => ({
17
20
  file: SKILL_MD, field, rule, severity, message, ...(value !== undefined ? { value } : {})
@@ -89,6 +92,20 @@ const validate_description = (fm) => {
89
92
  })
90
93
  } else {
91
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 Suite 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 Suite Decomposition Workflow.`, desc),
99
+ recommendations: [
100
+ 'CANONICAL — Apply the Suite 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 Suite 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 Suite Pattern reference (orthogonal verb ownership, the five-slot description grammar, failure modes, orthogonality test): happyskills-design references/suite-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
+ }
92
109
  }
93
110
 
94
111
  if (desc === PLACEHOLDER_DESC) {
@@ -145,6 +145,40 @@ describe('validate_skill_md — description', () => {
145
145
  const check = data.results.find(r => r.rule === 'no_forbidden_characters')
146
146
  assert.strictEqual(check.severity, 'pass')
147
147
  })
148
+
149
+ it('warns when description exceeds the 250-char soft cap (still under 1024 hard cap)', async () => {
150
+ const desc = 'a'.repeat(300)
151
+ write_skill_md(tmp, { name: 'my-skill', description: desc })
152
+ const [err, data] = await validate_skill_md(tmp, 'my-skill')
153
+ assert.ifError(err)
154
+ const max_length = data.results.find(r => r.field === 'description' && r.rule === 'max_length')
155
+ assert.strictEqual(max_length.severity, 'pass')
156
+ const soft_cap = data.results.find(r => r.field === 'description' && r.rule === 'soft_cap')
157
+ assert.strictEqual(soft_cap.severity, 'warning')
158
+ assert.ok(soft_cap.message.includes('300 chars'))
159
+ assert.ok(Array.isArray(soft_cap.recommendations))
160
+ assert.ok(soft_cap.recommendations.length > 0)
161
+ })
162
+
163
+ it('passes the soft cap when description is at or under 250 chars', async () => {
164
+ const desc = 'a'.repeat(250)
165
+ write_skill_md(tmp, { name: 'my-skill', description: desc })
166
+ const [err, data] = await validate_skill_md(tmp, 'my-skill')
167
+ assert.ifError(err)
168
+ const soft_cap = data.results.find(r => r.field === 'description' && r.rule === 'soft_cap')
169
+ assert.strictEqual(soft_cap.severity, 'pass')
170
+ })
171
+
172
+ it('does not emit a soft_cap warning when description is over the 1024 hard cap (max_length error dwarfs it)', async () => {
173
+ const desc = 'a'.repeat(1100)
174
+ write_skill_md(tmp, { name: 'my-skill', description: desc })
175
+ const [err, data] = await validate_skill_md(tmp, 'my-skill')
176
+ assert.ifError(err)
177
+ const max_length = data.results.find(r => r.field === 'description' && r.rule === 'max_length')
178
+ assert.strictEqual(max_length.severity, 'error')
179
+ const soft_cap = data.results.find(r => r.field === 'description' && r.rule === 'soft_cap')
180
+ assert.strictEqual(soft_cap, undefined)
181
+ })
148
182
  })
149
183
 
150
184
  describe('validate_skill_md — optional fields', () => {