@givolt/i3-composer-agent 0.0.0-stage → 1.0.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.
Files changed (231) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude/commands/build.md +44 -0
  3. package/.claude/commands/code-simplify.md +22 -0
  4. package/.claude/commands/constraints.md +32 -0
  5. package/.claude/commands/plan.md +18 -0
  6. package/.claude/commands/review.md +16 -0
  7. package/.claude/commands/ship.md +72 -0
  8. package/.claude/commands/spec.md +17 -0
  9. package/.claude/commands/test.md +19 -0
  10. package/.claude/commands/webperf.md +32 -0
  11. package/.claude-plugin/marketplace.json +27 -0
  12. package/.claude-plugin/plugin.json +19 -0
  13. package/.codex-plugin/plugin.json +28 -0
  14. package/.gemini/commands/build.toml +43 -0
  15. package/.gemini/commands/code-simplify.toml +22 -0
  16. package/.gemini/commands/constraints.toml +32 -0
  17. package/.gemini/commands/planning.toml +18 -0
  18. package/.gemini/commands/review.toml +16 -0
  19. package/.gemini/commands/ship.toml +72 -0
  20. package/.gemini/commands/spec.toml +17 -0
  21. package/.gemini/commands/test.toml +19 -0
  22. package/.gemini/commands/webperf.toml +32 -0
  23. package/CHANGELOG.md +23 -0
  24. package/LICENSE +21 -0
  25. package/NOTICE.md +9 -0
  26. package/README.md +117 -2
  27. package/agents/code-reviewer.md +102 -0
  28. package/agents/security-auditor.md +112 -0
  29. package/agents/test-engineer.md +95 -0
  30. package/agents/web-performance-auditor.md +184 -0
  31. package/bin/i3-composer-agent.cjs +122 -0
  32. package/commands/build.toml +43 -0
  33. package/commands/code-simplify.toml +22 -0
  34. package/commands/constraints.toml +32 -0
  35. package/commands/planning.toml +18 -0
  36. package/commands/review.toml +16 -0
  37. package/commands/ship.toml +72 -0
  38. package/commands/spec.toml +17 -0
  39. package/commands/test.toml +19 -0
  40. package/commands/webperf.toml +32 -0
  41. package/docs/adoption-guide.md +129 -0
  42. package/docs/advanced-per-agent-configuration.md +70 -0
  43. package/docs/agents.md +123 -0
  44. package/docs/antigravity-setup.md +128 -0
  45. package/docs/codebuddy-setup.md +41 -0
  46. package/docs/codex-setup.md +34 -0
  47. package/docs/commandcode-setup.md +69 -0
  48. package/docs/comparison.md +131 -0
  49. package/docs/copilot-cli-setup.md +64 -0
  50. package/docs/copilot-setup.md +162 -0
  51. package/docs/cursor-setup.md +225 -0
  52. package/docs/developer-onboarding.md +116 -0
  53. package/docs/gemini-cli-setup.md +133 -0
  54. package/docs/getting-started.md +208 -0
  55. package/docs/opencode-setup.md +259 -0
  56. package/docs/other-hosts.md +7 -0
  57. package/docs/skill-anatomy.md +203 -0
  58. package/docs/windsurf-setup.md +48 -0
  59. package/evals/README.md +101 -0
  60. package/evals/cases/api-and-interface-design.json +45 -0
  61. package/evals/cases/browser-testing-with-devtools.json +44 -0
  62. package/evals/cases/ci-cd-and-automation.json +45 -0
  63. package/evals/cases/code-review-and-quality.json +49 -0
  64. package/evals/cases/code-simplification.json +45 -0
  65. package/evals/cases/constraint-driven-development.json +79 -0
  66. package/evals/cases/context-engineering.json +44 -0
  67. package/evals/cases/debugging-and-error-recovery.json +58 -0
  68. package/evals/cases/deprecation-and-migration.json +52 -0
  69. package/evals/cases/documentation-and-adrs.json +44 -0
  70. package/evals/cases/doubt-driven-development.json +43 -0
  71. package/evals/cases/frontend-ui-engineering.json +52 -0
  72. package/evals/cases/git-workflow-and-versioning.json +47 -0
  73. package/evals/cases/idea-refine.json +43 -0
  74. package/evals/cases/incremental-implementation.json +57 -0
  75. package/evals/cases/interview-me.json +42 -0
  76. package/evals/cases/observability-and-instrumentation.json +44 -0
  77. package/evals/cases/performance-optimization.json +53 -0
  78. package/evals/cases/planning-and-task-breakdown.json +45 -0
  79. package/evals/cases/security-and-hardening.json +53 -0
  80. package/evals/cases/shipping-and-launch.json +56 -0
  81. package/evals/cases/source-driven-development.json +44 -0
  82. package/evals/cases/spec-driven-development.json +72 -0
  83. package/evals/cases/test-driven-development.json +73 -0
  84. package/evals/cases/using-agent-skills.json +44 -0
  85. package/evals/fixtures/api-and-interface-design/service-brief.md +19 -0
  86. package/evals/fixtures/browser-testing-with-devtools/README.md +5 -0
  87. package/evals/fixtures/browser-testing-with-devtools/index.html +24 -0
  88. package/evals/fixtures/browser-testing-with-devtools/server.js +15 -0
  89. package/evals/fixtures/ci-cd-and-automation/package.json +8 -0
  90. package/evals/fixtures/ci-cd-and-automation/src/slug.js +3 -0
  91. package/evals/fixtures/ci-cd-and-automation/test/slug.test.js +9 -0
  92. package/evals/fixtures/code-review-and-quality/user-search.diff +16 -0
  93. package/evals/fixtures/code-simplification/config-parser.js +46 -0
  94. package/evals/fixtures/code-simplification/config-parser.test.js +15 -0
  95. package/evals/fixtures/context-engineering/context-audit.md +15 -0
  96. package/evals/fixtures/debugging-and-error-recovery/pagination.js +8 -0
  97. package/evals/fixtures/debugging-and-error-recovery/pagination.test.js +9 -0
  98. package/evals/fixtures/debugging-and-error-recovery/time-pressure.md +6 -0
  99. package/evals/fixtures/deprecation-and-migration/api-inventory.md +9 -0
  100. package/evals/fixtures/documentation-and-adrs/decision-context.md +16 -0
  101. package/evals/fixtures/doubt-driven-development/migration-plan.md +19 -0
  102. package/evals/fixtures/frontend-ui-engineering/Button.tsx +7 -0
  103. package/evals/fixtures/frontend-ui-engineering/design-system.md +11 -0
  104. package/evals/fixtures/git-workflow-and-versioning/.eval/working-tree.patch +21 -0
  105. package/evals/fixtures/git-workflow-and-versioning/app.js +7 -0
  106. package/evals/fixtures/git-workflow-and-versioning/app.test.js +9 -0
  107. package/evals/fixtures/incremental-implementation/reports.js +7 -0
  108. package/evals/fixtures/incremental-implementation/reports.test.js +12 -0
  109. package/evals/fixtures/incremental-implementation/tasks/plan.md +8 -0
  110. package/evals/fixtures/incremental-implementation-pressure/draft-export.js +17 -0
  111. package/evals/fixtures/incremental-implementation-pressure/scenario.md +9 -0
  112. package/evals/fixtures/observability-and-instrumentation/operations.md +11 -0
  113. package/evals/fixtures/observability-and-instrumentation/payment-retry.js +14 -0
  114. package/evals/fixtures/performance-optimization/benchmark.js +15 -0
  115. package/evals/fixtures/performance-optimization/products.js +14 -0
  116. package/evals/fixtures/planning-and-task-breakdown/notifications-spec.md +18 -0
  117. package/evals/fixtures/security-and-hardening/webhook.js +11 -0
  118. package/evals/fixtures/security-and-hardening/webhook.test.js +13 -0
  119. package/evals/fixtures/shipping-and-launch/authority-pressure.md +6 -0
  120. package/evals/fixtures/shipping-and-launch/launch-status.md +11 -0
  121. package/evals/fixtures/source-driven-development/framework-task.md +10 -0
  122. package/evals/fixtures/spec-driven-development/billing-brief.md +16 -0
  123. package/evals/fixtures/spec-driven-development-decomposition/portal-brief.md +22 -0
  124. package/evals/fixtures/test-driven-development/BUG.md +10 -0
  125. package/evals/fixtures/test-driven-development/README.md +28 -0
  126. package/evals/fixtures/test-driven-development/package.json +9 -0
  127. package/evals/fixtures/test-driven-development/src/split.js +8 -0
  128. package/evals/fixtures/test-driven-development/test/split.test.js +13 -0
  129. package/evals/fixtures/test-driven-development-ecosystem/README.md +9 -0
  130. package/evals/fixtures/test-driven-development-ecosystem/ledger.py +15 -0
  131. package/evals/fixtures/test-driven-development-ecosystem/test_ledger.py +19 -0
  132. package/evals/fixtures/using-agent-skills/incident.md +6 -0
  133. package/evals/plugin/code-review-fires/graders/off-by-one.md +7 -0
  134. package/evals/plugin/code-review-fires/graders/severity-labels.md +4 -0
  135. package/evals/plugin/code-review-fires/graders/skill-fired.md +5 -0
  136. package/evals/plugin/code-review-fires/prompt.md +31 -0
  137. package/evals/plugin/code-review-stays-quiet/graders/not-fired.md +8 -0
  138. package/evals/plugin/code-review-stays-quiet/graders/tdd-fired.md +6 -0
  139. package/evals/plugin/code-review-stays-quiet/prompt.md +8 -0
  140. package/evals/plugin/code-review-stays-quiet-on-commit-message/graders/not-fired.md +8 -0
  141. package/evals/plugin/code-review-stays-quiet-on-commit-message/prompt.md +25 -0
  142. package/evals/skill-impact.md +6 -0
  143. package/hooks/SDD-CACHE.md +167 -0
  144. package/hooks/SIMPLIFY-IGNORE.md +91 -0
  145. package/hooks/sdd-cache-post.sh +135 -0
  146. package/hooks/sdd-cache-pre.sh +106 -0
  147. package/hooks/sdd-cache-test.sh +246 -0
  148. package/hooks/session-start-test.sh +47 -0
  149. package/hooks/session-start.sh +33 -0
  150. package/hooks/simplify-ignore-test.sh +476 -0
  151. package/hooks/simplify-ignore.sh +352 -0
  152. package/package.json +89 -4
  153. package/plugin.json +7 -0
  154. package/rebrand/MAINTENANCE.md +112 -0
  155. package/rebrand/NOTICE.md +9 -0
  156. package/rebrand/PR-WORKFLOW.md +34 -0
  157. package/rebrand/UNIFICATION-SPEC.md +33 -0
  158. package/rebrand/VALIDATION.md +28 -0
  159. package/rebrand/VERSIONING.md +32 -0
  160. package/rebrand/apply.cjs +194 -0
  161. package/rebrand/brand.json +13 -0
  162. package/rebrand/catalog.cjs +56 -0
  163. package/rebrand/fork-patches.json +12 -0
  164. package/rebrand/overlay/.github/workflows/test-plugin-install.yml +177 -0
  165. package/rebrand/overlay/CHANGELOG.md +23 -0
  166. package/rebrand/overlay/bin/i3-composer-agent.cjs +122 -0
  167. package/rebrand/overlay/scripts/validate-brand.cjs +44 -0
  168. package/rebrand/overlay/tests/bundle.test.cjs +85 -0
  169. package/rebrand/package.template.json +82 -0
  170. package/rebrand/rebrand.sh +4 -0
  171. package/rebrand/record-upstream.cjs +37 -0
  172. package/rebrand/templates/README.md +86 -0
  173. package/rebrand/templates/codebuddy-setup.md +41 -0
  174. package/rebrand/templates/codex-setup.md +34 -0
  175. package/rebrand/tests/pipeline.test.cjs +185 -0
  176. package/rebrand/upstream.json +171 -0
  177. package/references/accessibility-checklist.md +160 -0
  178. package/references/definition-of-done.md +67 -0
  179. package/references/observability-checklist.md +92 -0
  180. package/references/orchestration-patterns.md +370 -0
  181. package/references/performance-checklist.md +236 -0
  182. package/references/security-checklist.md +248 -0
  183. package/references/testing-patterns.md +235 -0
  184. package/scripts/floor-guard-reference-test.js +291 -0
  185. package/scripts/lib/skill-lint-test.js +650 -0
  186. package/scripts/lib/skill-lint.js +584 -0
  187. package/scripts/run-evals-test.js +623 -0
  188. package/scripts/run-evals.js +638 -0
  189. package/scripts/validate-artifact-paths-test.js +179 -0
  190. package/scripts/validate-artifact-paths.js +128 -0
  191. package/scripts/validate-brand.cjs +44 -0
  192. package/scripts/validate-commands-test.js +216 -0
  193. package/scripts/validate-commands.js +222 -0
  194. package/scripts/validate-reference-links-test.js +495 -0
  195. package/scripts/validate-reference-links.js +187 -0
  196. package/scripts/validate-skills.js +69 -0
  197. package/scripts/validate-versions-test.js +32 -0
  198. package/scripts/validate-versions.js +35 -0
  199. package/skills/api-and-interface-design/SKILL.md +367 -0
  200. package/skills/browser-testing-with-devtools/SKILL.md +317 -0
  201. package/skills/ci-cd-and-automation/SKILL.md +390 -0
  202. package/skills/code-review-and-quality/SKILL.md +398 -0
  203. package/skills/code-simplification/SKILL.md +331 -0
  204. package/skills/constraint-driven-development/SKILL.md +311 -0
  205. package/skills/constraint-driven-development/references/floor-guard.md +182 -0
  206. package/skills/context-engineering/SKILL.md +353 -0
  207. package/skills/debugging-and-error-recovery/SKILL.md +300 -0
  208. package/skills/deprecation-and-migration/SKILL.md +247 -0
  209. package/skills/documentation-and-adrs/SKILL.md +288 -0
  210. package/skills/doubt-driven-development/SKILL.md +243 -0
  211. package/skills/frontend-ui-engineering/SKILL.md +340 -0
  212. package/skills/git-workflow-and-versioning/SKILL.md +355 -0
  213. package/skills/idea-refine/SKILL.md +178 -0
  214. package/skills/idea-refine/examples.md +238 -0
  215. package/skills/idea-refine/frameworks.md +99 -0
  216. package/skills/idea-refine/refinement-criteria.md +113 -0
  217. package/skills/idea-refine/scripts/idea-refine.sh +15 -0
  218. package/skills/incremental-implementation/SKILL.md +249 -0
  219. package/skills/interview-me/SKILL.md +235 -0
  220. package/skills/observability-and-instrumentation/SKILL.md +238 -0
  221. package/skills/performance-optimization/SKILL.md +267 -0
  222. package/skills/performance-optimization/references/optimization-patterns.md +250 -0
  223. package/skills/planning-and-task-breakdown/SKILL.md +257 -0
  224. package/skills/security-and-hardening/SKILL.md +216 -0
  225. package/skills/security-and-hardening/references/hardening-patterns.md +324 -0
  226. package/skills/shipping-and-launch/SKILL.md +330 -0
  227. package/skills/source-driven-development/SKILL.md +216 -0
  228. package/skills/spec-driven-development/SKILL.md +259 -0
  229. package/skills/test-driven-development/SKILL.md +398 -0
  230. package/skills/using-agent-skills/SKILL.md +192 -0
  231. package/tests/bundle.test.cjs +85 -0
@@ -0,0 +1,650 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ const assert = require('node:assert/strict');
6
+ const { test } = require('node:test');
7
+
8
+ const fs = require('node:fs');
9
+ const os = require('node:os');
10
+ const path = require('node:path');
11
+
12
+ const { lintSkillContent, lintSkillLayout, topLevelFrontmatterKeys } = require('./skill-lint.js');
13
+
14
+ const KNOWN = new Set(['alpha', 'beta']);
15
+
16
+ /**
17
+ * Build a throwaway skill directory. `dirs` are created empty; `files` maps a
18
+ * path within the skill to its contents, creating parents as needed.
19
+ */
20
+ function makeSkillDir({ dirs = [], files = {} } = {}) {
21
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-layout-'));
22
+ for (const d of dirs) fs.mkdirSync(path.join(root, d), { recursive: true });
23
+ for (const [rel, body] of Object.entries(files)) {
24
+ const abs = path.join(root, rel);
25
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
26
+ fs.writeFileSync(abs, body);
27
+ }
28
+ return root;
29
+ }
30
+
31
+ /** A SKILL.md body carrying every required section, so tests can isolate frontmatter. */
32
+ function withAllSections(frontmatter) {
33
+ return [
34
+ frontmatter,
35
+ '',
36
+ '## Overview',
37
+ 'x',
38
+ '## When to Use',
39
+ 'x',
40
+ '## Common Rationalizations',
41
+ 'x',
42
+ '## Red Flags',
43
+ 'x',
44
+ '## Verification',
45
+ 'x',
46
+ '',
47
+ ].join('\n');
48
+ }
49
+
50
+ const VALID_FRONTMATTER = [
51
+ '---',
52
+ 'name: alpha',
53
+ 'description: Designs alphas. Use when building one.',
54
+ '---',
55
+ ].join('\n');
56
+
57
+ // ─── Section exemptions ──────────────────────────────────────────────────────
58
+
59
+ test('a directory named after an Object.prototype key is not exempt from section checks', () => {
60
+ // `constructor` satisfies KEBAB_CASE, and `dirName in SECTION_EXEMPT_SKILLS`
61
+ // finds it on the prototype chain — silently skipping every section check.
62
+ const content = [
63
+ '---',
64
+ 'name: constructor',
65
+ 'description: Does a thing. Use when you need it.',
66
+ '---',
67
+ '',
68
+ 'no sections here',
69
+ '',
70
+ ].join('\n');
71
+
72
+ const { errors, exempt } = lintSkillContent('constructor', content, KNOWN);
73
+
74
+ assert.equal(exempt, false, 'exemptions must come from the allowlist, not the prototype chain');
75
+ assert.equal(errors.filter(e => /Missing required section/.test(e)).length, 5);
76
+ });
77
+
78
+ test('a genuinely allowlisted skill is still exempt', () => {
79
+ const content = [
80
+ '---',
81
+ 'name: using-agent-skills',
82
+ 'description: Routes to other skills. Use when choosing one.',
83
+ '---',
84
+ '',
85
+ 'no sections here',
86
+ '',
87
+ ].join('\n');
88
+
89
+ const { errors, exempt } = lintSkillContent('using-agent-skills', content, KNOWN);
90
+
91
+ assert.equal(exempt, true);
92
+ assert.deepEqual(errors.filter(e => /Missing required section/.test(e)), []);
93
+ });
94
+
95
+ test('a skill claiming its own exemption without being allowlisted fails loud', () => {
96
+ const content = withAllSections(
97
+ ['---', 'name: alpha', 'description: Designs alphas. Use when building one.', 'exempt: sections', '---'].join('\n')
98
+ );
99
+ const { errors } = lintSkillContent('alpha', content, KNOWN);
100
+ // Two errors: 'exempt' is not a spec key, and the exemption itself is refused.
101
+ assert.equal(errors.length, 2);
102
+ assert.equal(errors.filter(e => /not in the validator's SECTION_EXEMPT_SKILLS allowlist/.test(e)).length, 1);
103
+ assert.equal(errors.filter(e => /Frontmatter key 'exempt' is not an Agent Skills spec field/.test(e)).length, 1);
104
+ });
105
+
106
+ // ─── Guardrails on the rules this change sits beside ─────────────────────────
107
+ // Deliberately narrow: #428 rewrites frontmatter parsing and the cross-reference
108
+ // patterns, so asserting their behaviour here would collide with that work.
109
+
110
+ test('a fully valid skill produces no errors', () => {
111
+ const { errors } = lintSkillContent('alpha', withAllSections(VALID_FRONTMATTER), KNOWN);
112
+ assert.deepEqual(errors, []);
113
+ });
114
+
115
+ test('reports a description with no trigger clause', () => {
116
+ const content = withAllSections(
117
+ ['---', 'name: alpha', 'description: Designs alpha things and nothing more.', '---'].join('\n')
118
+ );
119
+ const { errors } = lintSkillContent('alpha', content, KNOWN);
120
+ assert.equal(errors.length, 1);
121
+ assert.match(errors[0], /no 'when to use' trigger/);
122
+ });
123
+
124
+ test('a description whose only triggers are negated is rejected regardless of how many there are', () => {
125
+ const content = withAllSections(
126
+ [
127
+ '---',
128
+ 'name: alpha',
129
+ 'description: Designs alphas. Do not use when building betas. Never use when the input is JSON.',
130
+ '---',
131
+ ].join('\n')
132
+ );
133
+ const { errors } = lintSkillContent('alpha', content, KNOWN);
134
+ assert.equal(errors.length, 1);
135
+ assert.match(errors[0], /no 'when to use' trigger/);
136
+ });
137
+
138
+ test('reports frontmatter name that disagrees with the directory', () => {
139
+ const content = withAllSections(
140
+ ['---', 'name: beta', 'description: Designs alphas. Use when building one.', '---'].join('\n')
141
+ );
142
+ const { errors } = lintSkillContent('alpha', content, KNOWN);
143
+ assert.equal(errors.length, 1);
144
+ assert.match(errors[0], /does not match directory name/);
145
+ });
146
+
147
+ test('reports a workflow step declared without a matching process section', () => {
148
+ const content = withAllSections(VALID_FRONTMATTER).replace(
149
+ '## Common Rationalizations',
150
+ [
151
+ '## The Optimization Workflow',
152
+ '',
153
+ '```',
154
+ '1. MEASURE → Establish a baseline',
155
+ '2. GUARD → Prevent regression',
156
+ '```',
157
+ '',
158
+ '### Step 1: Measure',
159
+ '',
160
+ 'Measure first.',
161
+ '',
162
+ '## Common Rationalizations',
163
+ ].join('\n'),
164
+ );
165
+
166
+ const { errors } = lintSkillContent('alpha', content, KNOWN);
167
+
168
+ assert.equal(errors.length, 1);
169
+ assert.match(errors[0], /Workflow declares Step 2 but has no matching process section/);
170
+ });
171
+
172
+ test('reports a missing frontmatter block', () => {
173
+ const { errors } = lintSkillContent('alpha', '## Overview\nx\n', KNOWN);
174
+ assert.equal(errors.length, 1);
175
+ assert.match(errors[0], /Missing or malformed YAML frontmatter/);
176
+ });
177
+
178
+ // ─── Fenced-block stripping (#437) ───────────────────────────────────────────
179
+ //
180
+ // Observed through the required-section rule: a `## Overview` heading that
181
+ // lives inside a fenced block must NOT satisfy the check, so the presence of
182
+ // "Missing required section: ## Overview" proves the block was stripped, and
183
+ // its absence proves prose outside the block survived.
184
+
185
+ const FENCE_KNOWN = new Set(['fenced']);
186
+
187
+ /** A SKILL.md with every required section except Overview, which the caller supplies. */
188
+ function skillWithOverview(overviewBlock) {
189
+ return [
190
+ '---',
191
+ 'name: fenced',
192
+ 'description: Exercises fence parsing. Use when testing the linter.',
193
+ '---',
194
+ '',
195
+ overviewBlock,
196
+ '',
197
+ '## When to Use',
198
+ 'x',
199
+ '## Common Rationalizations',
200
+ 'x',
201
+ '## Red Flags',
202
+ 'x',
203
+ '## Verification',
204
+ 'x',
205
+ '',
206
+ ].join('\n');
207
+ }
208
+
209
+ const OVERVIEW = '## Overview\nx';
210
+ const overviewMissing = ({ errors }) => errors.includes('Missing required section: ## Overview');
211
+
212
+ test('a real Overview heading satisfies the required-section check', () => {
213
+ const result = lintSkillContent('fenced', skillWithOverview(OVERVIEW), FENCE_KNOWN);
214
+ assert.deepEqual(result.errors, []);
215
+ });
216
+
217
+ for (const [form, block] of [
218
+ ['a backtick fence', '```markdown\n## Overview\n```'],
219
+ ['an unlabeled fence', '```\n## Overview\n```'],
220
+ ['a tilde fence', '~~~markdown\n## Overview\n~~~'],
221
+ ['a fence indented one space', ' ```\n## Overview\n ```'],
222
+ ['a fence indented three spaces', ' ```\n## Overview\n ```'],
223
+ ['a fence with a longer closer', '```\n## Overview\n`````'],
224
+ ['a four-backtick fence', '````\n## Overview\n````'],
225
+ ]) {
226
+ test(`a heading inside ${form} does not satisfy the check`, () => {
227
+ const result = lintSkillContent('fenced', skillWithOverview(block), FENCE_KNOWN);
228
+ assert.equal(overviewMissing(result), true, `heading inside ${form} leaked into prose`);
229
+ });
230
+ }
231
+
232
+ // The closer must be recognised, or every line after it is swallowed.
233
+ for (const [form, block] of [
234
+ ['a same-length closer', '```\nexample\n```\n\n' + OVERVIEW],
235
+ ['a longer closer', '```\nexample\n`````\n\n' + OVERVIEW],
236
+ ['an indented closer', '```\nexample\n ```\n\n' + OVERVIEW],
237
+ ['a tilde fence closer', '~~~\nexample\n~~~\n\n' + OVERVIEW],
238
+ ['a closer with trailing spaces', '```\nexample\n``` \n\n' + OVERVIEW],
239
+ ]) {
240
+ test(`prose after ${form} is still linted`, () => {
241
+ const result = lintSkillContent('fenced', skillWithOverview(block), FENCE_KNOWN);
242
+ assert.equal(overviewMissing(result), false, `prose after ${form} was swallowed`);
243
+ });
244
+ }
245
+
246
+ test('a shorter run of the same marker does not close a longer fence', () => {
247
+ const block = '````\n```\n## Overview\n```\n````';
248
+ const result = lintSkillContent('fenced', skillWithOverview(block), FENCE_KNOWN);
249
+ assert.equal(overviewMissing(result), true);
250
+ });
251
+
252
+ test('a backtick run does not close a tilde fence, and vice versa', () => {
253
+ for (const block of ['~~~\n```\n## Overview\n~~~', '```\n~~~\n## Overview\n```']) {
254
+ const result = lintSkillContent('fenced', skillWithOverview(block), FENCE_KNOWN);
255
+ assert.equal(overviewMissing(result), true);
256
+ }
257
+ });
258
+
259
+ test('a fence indented four spaces is an indented code block, not a fence', () => {
260
+ // Four spaces makes the line indented code in CommonMark; the heading that
261
+ // follows is regular prose and must still satisfy the check.
262
+ const block = ' ```\n' + OVERVIEW;
263
+ const result = lintSkillContent('fenced', skillWithOverview(block), FENCE_KNOWN);
264
+ assert.equal(overviewMissing(result), false);
265
+ });
266
+
267
+ test('a backtick run followed by inline backticks is prose, not an opener', () => {
268
+ // CommonMark forbids backticks in the info string of a backtick fence, so
269
+ // this line is ordinary prose and the heading below it must still count.
270
+ const block = '```js``` is how you write inline code for a fence\n' + OVERVIEW;
271
+ const result = lintSkillContent('fenced', skillWithOverview(block), FENCE_KNOWN);
272
+ assert.equal(overviewMissing(result), false);
273
+ });
274
+
275
+ test('an unterminated fence swallows everything after it and fails loud', () => {
276
+ const block = '```\n' + OVERVIEW;
277
+ const result = lintSkillContent('fenced', skillWithOverview(block), FENCE_KNOWN);
278
+ assert.equal(overviewMissing(result), true);
279
+ assert.equal(result.errors.includes('Missing required section: ## Verification'), true);
280
+ });
281
+
282
+ test('CRLF line endings are handled', () => {
283
+ const content = skillWithOverview('```\n## Overview\n```').replace(/\n/g, '\r\n');
284
+ const result = lintSkillContent('fenced', content, FENCE_KNOWN);
285
+ assert.equal(overviewMissing(result), true);
286
+ });
287
+
288
+ // ── Frontmatter must be valid YAML, not merely splittable ────────────────────
289
+ // `parseFrontmatter` splits each line on its first colon, which is forgiving by
290
+ // design. The hosts that read these skills are not: Cursor parses the
291
+ // frontmatter as YAML when a skill is attached to a message, and a parse
292
+ // failure fails the whole request and takes the chat's context with it (#494).
293
+ // Each shape below was confirmed rejected by a strict parser (ruby psych) while
294
+ // passing every other check in this linter.
295
+
296
+ /** Frontmatter that is otherwise complete, so only YAML validity varies. */
297
+ function fmLines(...lines) {
298
+ return withAllSections(['---', 'name: alpha', ...lines, '---'].join('\n'));
299
+ }
300
+
301
+ const yamlErrors = result => result.errors.filter(e => e.startsWith('Frontmatter line '));
302
+
303
+ test('a valid frontmatter reports no YAML error', () => {
304
+ const result = lintSkillContent('alpha', fmLines('description: Use when you need alpha'), KNOWN);
305
+ assert.deepEqual(yamlErrors(result), []);
306
+ });
307
+
308
+ test('an unquoted value containing a colon is rejected', () => {
309
+ // YAML reads `Use when: X` as a nested mapping and errors; the split-on-first
310
+ // -colon parser reads it as a plain string and never notices.
311
+ const result = lintSkillContent(
312
+ 'alpha',
313
+ fmLines('description: Use when you need alpha: auth, secrets and review'),
314
+ KNOWN,
315
+ );
316
+ assert.equal(yamlErrors(result).length, 1);
317
+ assert.match(yamlErrors(result)[0], /unquoted value containing/);
318
+ });
319
+
320
+ test('quoting the same value makes it valid again', () => {
321
+ const result = lintSkillContent(
322
+ 'alpha',
323
+ fmLines('description: "Use when you need alpha: auth, secrets and review"'),
324
+ KNOWN,
325
+ );
326
+ assert.deepEqual(yamlErrors(result), []);
327
+ });
328
+
329
+ test('a colon with no trailing space is left alone', () => {
330
+ // `https://example.com` is a perfectly good YAML scalar. The rule keys on
331
+ // colon-space, not on colons, so ordinary URLs do not trip it.
332
+ const result = lintSkillContent(
333
+ 'alpha',
334
+ fmLines('description: Use when you need alpha', 'docs: https://example.com/a:b'),
335
+ KNOWN,
336
+ );
337
+ assert.deepEqual(yamlErrors(result), []);
338
+ });
339
+
340
+ test('a tab used for indentation is rejected', () => {
341
+ const result = lintSkillContent(
342
+ 'alpha',
343
+ fmLines('description: Use when you need alpha', 'meta:', '\tlevel: core'),
344
+ KNOWN,
345
+ );
346
+ assert.equal(yamlErrors(result).length, 1);
347
+ assert.match(yamlErrors(result)[0], /indents with a tab/);
348
+ });
349
+
350
+ test('an unterminated quote is rejected', () => {
351
+ const result = lintSkillContent('alpha', fmLines('description: "Use when you need alpha'), KNOWN);
352
+ assert.equal(yamlErrors(result).length, 1);
353
+ assert.match(yamlErrors(result)[0], /never closes/);
354
+ });
355
+
356
+ // A plain (unquoted) YAML scalar may not BEGIN with certain indicator characters.
357
+ // The set below was not read off the spec — it was measured against js-yaml, both
358
+ // with and without a following space, and only characters invalid in *both* forms
359
+ // with no legitimate single-line use are rejected here. Anything ambiguous is left
360
+ // alone on purpose, and the second test pins that so the rule cannot be widened
361
+ // into false positives later.
362
+ //
363
+ // The backtick is the one that actually bites this repo: descriptions routinely
364
+ // name other skills, and `\`alpha\` designs things` is a natural way to start one.
365
+ for (const [label, value] of [
366
+ ['a backtick', '`alpha` designs things. Use when alpha.'],
367
+ ['an at sign', '@team owns this. Use when alpha.'],
368
+ ['a percent sign', '%complete coverage. Use when alpha.'],
369
+ ]) {
370
+ test(`an unquoted value starting with ${label} is rejected`, () => {
371
+ const result = lintSkillContent(
372
+ 'alpha',
373
+ fmLines(`description: ${value}`),
374
+ KNOWN,
375
+ );
376
+ assert.equal(yamlErrors(result).length, 1);
377
+ assert.match(yamlErrors(result)[0], /reserved|cannot begin|indicator/i);
378
+ });
379
+ }
380
+
381
+ for (const [label, value] of [
382
+ ['a dash', '- Designs things. Use when alpha.'],
383
+ ['a question mark', '? Designs things. Use when alpha.'],
384
+ ['an ampersand', '& Designs things. Use when alpha.'],
385
+ ]) {
386
+ test(`an unquoted value starting with ${label} and a space is rejected`, () => {
387
+ const result = lintSkillContent(
388
+ 'alpha',
389
+ fmLines(`description: ${value}`),
390
+ KNOWN,
391
+ );
392
+ assert.equal(yamlErrors(result).length, 1);
393
+ });
394
+ }
395
+
396
+ test('quoting the value makes every reserved start valid again', () => {
397
+ for (const value of ['`alpha` x', '@team x', '%x', '- x', '? x', '& x']) {
398
+ const result = lintSkillContent(
399
+ 'alpha',
400
+ fmLines(`description: "${value}"`),
401
+ KNOWN,
402
+ );
403
+ assert.deepEqual(yamlErrors(result), [], `quoted ${value} must be accepted`);
404
+ }
405
+ });
406
+
407
+ test('starts that YAML accepts are deliberately NOT rejected', () => {
408
+ // Each parses cleanly under PyYAML and psych, so flagging them would be a
409
+ // false positive on valid frontmatter. Pinned so the rule stays narrow.
410
+ //
411
+ // `,leading comma is fine` used to be in this list. It is not fine — both
412
+ // parsers reject it, and this test was pinning a claim I had asserted without
413
+ // measuring. It now appears in the rejected set below instead.
414
+ for (const value of [
415
+ ':platform is fine', // a colon not followed by a space
416
+ '-hyphenated is fine', // a dash not followed by a space
417
+ 'Designs `alpha` things', // a backtick anywhere but the first character
418
+ 'Designs @team things', // an at sign anywhere but the first character
419
+ '#not-a-comment-here',
420
+ '&anchor-like but valid', // `&foo` parses; only `& ` is an indicator
421
+ '[a, b]', // a flow sequence is valid, so `[` stays allowed
422
+ // `{a: b}` is valid YAML too, and `{` is likewise not flagged here — but it
423
+ // trips the pre-existing colon-space rule, so it is not asserted as accepted.
424
+ // That false positive predates this change and is left alone.
425
+ ]) {
426
+ const result = lintSkillContent('alpha', fmLines(`description: ${value}`), KNOWN);
427
+ assert.deepEqual(yamlErrors(result), [], `${value} must be accepted`);
428
+ }
429
+ });
430
+
431
+ test('indicator characters both parsers reject are flagged', () => {
432
+ // Measured, not read off the spec: each of these is rejected by PyYAML and by
433
+ // psych, and none has a legitimate use at the start of a plain scalar.
434
+ for (const value of [
435
+ ',leading comma',
436
+ '*alias-like',
437
+ '`skill` does X',
438
+ '@team owns this',
439
+ '%directive-like',
440
+ ]) {
441
+ const result = lintSkillContent('alpha', fmLines(`description: ${value}`), KNOWN);
442
+ assert.equal(yamlErrors(result).length, 1, `${value} must be rejected`);
443
+ }
444
+ });
445
+
446
+ test('a one-line block scalar header is flagged but a real block scalar is not', () => {
447
+ // `|` with content on the same line is not a block scalar — it is a plain
448
+ // scalar opening with an indicator, and both parsers reject it. `|` alone,
449
+ // with indented lines under it, is valid and must stay allowed.
450
+ for (const value of ['|folded text', '>folded text']) {
451
+ const result = lintSkillContent('alpha', fmLines(`description: ${value}`), KNOWN);
452
+ assert.equal(yamlErrors(result).length, 1, `${value} must be rejected`);
453
+ }
454
+
455
+ const genuine = lintSkillContent('alpha', fmLines('description: |'), KNOWN);
456
+ assert.deepEqual(yamlErrors(genuine), [], 'a bare block-scalar header must be accepted');
457
+ });
458
+
459
+ test('a duplicate key is not reported, because YAML accepts it', () => {
460
+ // Deliberate boundary: `safe_load` accepts duplicate keys, so flagging them
461
+ // here would fail files no host rejects. The rule tracks the parser, not taste.
462
+ const result = lintSkillContent(
463
+ 'alpha',
464
+ fmLines('description: Use when you need alpha', 'description: Use when you need alpha'),
465
+ KNOWN,
466
+ );
467
+ assert.deepEqual(yamlErrors(result), []);
468
+ });
469
+
470
+ test('the error names the line so the fix is obvious', () => {
471
+ const result = lintSkillContent(
472
+ 'alpha',
473
+ fmLines('description: Use when you need alpha', 'owner: team: platform'),
474
+ KNOWN,
475
+ );
476
+ assert.match(yamlErrors(result)[0], /^Frontmatter line 4 /);
477
+ });
478
+
479
+ // ─── Context budget ──────────────────────────────────────────────────────────
480
+
481
+ test('warns when SKILL.md exceeds the 500-line context budget', () => {
482
+ const padded = withAllSections(VALID_FRONTMATTER) + '\n'.repeat(600);
483
+
484
+ const { errors, warnings } = lintSkillContent('alpha', padded, KNOWN);
485
+
486
+ assert.equal(errors.length, 0, 'an over-budget skill must not block CI');
487
+ assert.equal(warnings.length, 1);
488
+ assert.match(warnings[0], /over the 500-line context budget/);
489
+ });
490
+
491
+ test('a SKILL.md at exactly the budget is not flagged', () => {
492
+ const base = withAllSections(VALID_FRONTMATTER);
493
+ const baseLines = (base.match(/\n/g) || []).length;
494
+ const atBudget = base + '\n'.repeat(500 - baseLines);
495
+
496
+ assert.equal((atBudget.match(/\n/g) || []).length, 500, 'fixture must sit exactly on the boundary');
497
+ const { warnings } = lintSkillContent('alpha', atBudget, KNOWN);
498
+
499
+ assert.equal(warnings.length, 0);
500
+ });
501
+
502
+ // ─── Layout ──────────────────────────────────────────────────────────────────
503
+
504
+ test('reports an empty scripts/ directory', () => {
505
+ const dir = makeSkillDir({ dirs: ['scripts'] });
506
+
507
+ const errors = lintSkillLayout(dir);
508
+
509
+ assert.equal(errors.length, 1);
510
+ assert.match(errors[0], /Empty directory `scripts\/`/);
511
+ });
512
+
513
+ test('reports a directory that only nests more empty directories', () => {
514
+ const dir = makeSkillDir({ dirs: ['references', 'references/deep'] });
515
+
516
+ const errors = lintSkillLayout(dir);
517
+
518
+ assert.equal(errors.length, 1, 'the outermost empty directory is named once, not every level');
519
+ assert.match(errors[0], /Empty directory `references\/`/);
520
+ });
521
+
522
+ test('a directory holding a file is not empty', () => {
523
+ const dir = makeSkillDir({ files: { 'scripts/helper.sh': '#!/bin/bash\nset -e\n' } });
524
+
525
+ assert.deepEqual(lintSkillLayout(dir), []);
526
+ });
527
+
528
+ test('reports a supporting .md file that is not lowercase-hyphen-separated', () => {
529
+ const dir = makeSkillDir({ files: { 'Refinement_Criteria.md': 'x\n' } });
530
+
531
+ const errors = lintSkillLayout(dir);
532
+
533
+ assert.equal(errors.length, 1);
534
+ assert.match(errors[0], /Supporting file `Refinement_Criteria\.md` is not lowercase-hyphen-separated/);
535
+ });
536
+
537
+ test('names a badly named supporting file by its path within the skill', () => {
538
+ const dir = makeSkillDir({ files: { 'references/Floor_Guard.md': 'x\n' } });
539
+
540
+ const errors = lintSkillLayout(dir);
541
+
542
+ assert.equal(errors.length, 1);
543
+ assert.match(errors[0], /`references\/Floor_Guard\.md`/);
544
+ });
545
+
546
+ test('SKILL.md is exempt from the supporting-file naming rule', () => {
547
+ const dir = makeSkillDir({ files: { 'SKILL.md': 'x\n', 'examples.md': 'x\n' } });
548
+
549
+ assert.deepEqual(lintSkillLayout(dir), []);
550
+ });
551
+
552
+ test('non-markdown files are left to the Script Requirements conventions', () => {
553
+ const dir = makeSkillDir({ files: { 'scripts/Idea_Refine.sh': '#!/bin/bash\nset -e\n' } });
554
+
555
+ assert.deepEqual(lintSkillLayout(dir), []);
556
+ });
557
+
558
+ // ─── Spec-only top-level frontmatter keys ────────────────────────────────────
559
+ //
560
+ // docs/advanced-per-agent-configuration.md: the specification reserves the top
561
+ // level for name, description, license, compatibility, metadata and
562
+ // allowed-tools. Vendor and runtime fields go under `metadata` or in a
563
+ // per-agent adapter file, never at the top level of a published SKILL.md.
564
+
565
+ function skillWithFrontmatter(lines) {
566
+ return withAllSections(['---', ...lines, '---'].join('\n'));
567
+ }
568
+
569
+ const SPEC_KEY_RE = /is not an Agent Skills spec field/;
570
+
571
+ test('a vendor field at the top level is rejected and the message names the key', () => {
572
+ const { errors } = lintSkillContent('alpha', skillWithFrontmatter([
573
+ 'name: alpha',
574
+ 'description: Designs alphas. Use when building one.',
575
+ 'model: claude-opus-5',
576
+ ]), KNOWN);
577
+ assert.equal(errors.length, 1);
578
+ assert.match(errors[0], /Frontmatter key 'model'/);
579
+ assert.match(errors[0], SPEC_KEY_RE);
580
+ assert.match(errors[0], /advanced-per-agent-configuration\.md/);
581
+ });
582
+
583
+ test('every unknown top-level key is reported, not just the first', () => {
584
+ const { errors } = lintSkillContent('alpha', skillWithFrontmatter([
585
+ 'name: alpha',
586
+ 'description: Designs alphas. Use when building one.',
587
+ 'max_turns: 10',
588
+ 'tools: [read_file]',
589
+ 'context: fork',
590
+ ]), KNOWN);
591
+ const keyErrors = errors.filter(e => SPEC_KEY_RE.test(e));
592
+ assert.deepEqual(keyErrors.map(e => e.match(/key '([^']+)'/)[1]), ['max_turns', 'tools', 'context']);
593
+ });
594
+
595
+ test('all six specification keys are accepted at the top level', () => {
596
+ const { errors } = lintSkillContent('alpha', skillWithFrontmatter([
597
+ 'name: alpha',
598
+ 'description: Designs alphas. Use when building one.',
599
+ 'license: MIT',
600
+ 'compatibility: Requires git and a test runner',
601
+ 'allowed-tools: Read Grep',
602
+ 'metadata:',
603
+ ' author: someone',
604
+ ]), KNOWN);
605
+ assert.deepEqual(errors.filter(e => SPEC_KEY_RE.test(e)), []);
606
+ });
607
+
608
+ test('vendor fields nested under metadata are not top-level keys', () => {
609
+ const { errors } = lintSkillContent('alpha', skillWithFrontmatter([
610
+ 'name: alpha',
611
+ 'description: Designs alphas. Use when building one.',
612
+ 'metadata:',
613
+ ' model: gemini-3-pro',
614
+ ' max_turns: 10',
615
+ ' tools:',
616
+ ' - read_file',
617
+ ]), KNOWN);
618
+ assert.deepEqual(errors.filter(e => SPEC_KEY_RE.test(e)), []);
619
+ });
620
+
621
+ test('topLevelFrontmatterKeys sees only column-zero keys, in order', () => {
622
+ const content = [
623
+ '---',
624
+ 'name: alpha',
625
+ '# a comment: with a colon',
626
+ 'metadata:',
627
+ ' model: x',
628
+ ' tools:',
629
+ ' - a',
630
+ 'allowed-tools: Read',
631
+ '---',
632
+ '',
633
+ 'body: not frontmatter',
634
+ ].join('\n');
635
+ assert.deepEqual(topLevelFrontmatterKeys(content), ['name', 'metadata', 'allowed-tools']);
636
+ });
637
+
638
+ test('topLevelFrontmatterKeys returns [] without a frontmatter block', () => {
639
+ assert.deepEqual(topLevelFrontmatterKeys('## Overview\nx\n'), []);
640
+ });
641
+
642
+ test('the spec-key check tolerates CRLF frontmatter', () => {
643
+ const content = skillWithFrontmatter([
644
+ 'name: alpha',
645
+ 'description: Designs alphas. Use when building one.',
646
+ 'temperature: 0.2',
647
+ ]).replace(/\n/g, '\r\n');
648
+ const { errors } = lintSkillContent('alpha', content, KNOWN);
649
+ assert.equal(errors.filter(e => /key 'temperature'/.test(e)).length, 1);
650
+ });