@givolt/i3-composer-agent 2.0.0 → 2.1.1

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 (158) hide show
  1. package/.agents/plugins/marketplace.json +1 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/README.md +37 -33
  6. package/bin/agent-targets.json +11 -0
  7. package/bin/asset-fingerprint.cjs +38 -0
  8. package/bin/cleanup-backups.cjs +16 -0
  9. package/bin/i3-composer-agent.cjs +28 -12
  10. package/bin/install.cjs +74 -28
  11. package/bin/native-content.cjs +29 -0
  12. package/bin/wizard.cjs +3 -1
  13. package/docs/antigravity-setup.md +1 -1
  14. package/docs/codebuddy-setup.md +16 -3
  15. package/docs/cursor-setup.md +2 -2
  16. package/docs/getting-started.md +0 -2
  17. package/docs/opencode-setup.md +4 -4
  18. package/docs/workbuddy-setup.md +106 -0
  19. package/package.json +36 -20
  20. package/plugin.json +1 -1
  21. package/CHANGELOG.md +0 -51
  22. package/NOTICE.md +0 -15
  23. package/docs/advanced-per-agent-configuration.md +0 -70
  24. package/docs/comparison.md +0 -131
  25. package/docs/developer-onboarding.md +0 -116
  26. package/docs/skill-anatomy.md +0 -203
  27. package/evals/README.md +0 -101
  28. package/evals/cases/i3-api-and-interface-design.json +0 -45
  29. package/evals/cases/i3-browser-testing-with-devtools.json +0 -44
  30. package/evals/cases/i3-ci-cd-and-automation.json +0 -45
  31. package/evals/cases/i3-code-review-and-quality.json +0 -49
  32. package/evals/cases/i3-code-simplification.json +0 -45
  33. package/evals/cases/i3-constraint-driven-development.json +0 -79
  34. package/evals/cases/i3-context-engineering.json +0 -44
  35. package/evals/cases/i3-debugging-and-error-recovery.json +0 -58
  36. package/evals/cases/i3-deprecation-and-migration.json +0 -52
  37. package/evals/cases/i3-documentation-and-adrs.json +0 -44
  38. package/evals/cases/i3-doubt-driven-development.json +0 -43
  39. package/evals/cases/i3-frontend-ui-engineering.json +0 -52
  40. package/evals/cases/i3-git-workflow-and-versioning.json +0 -47
  41. package/evals/cases/i3-idea-refine.json +0 -43
  42. package/evals/cases/i3-incremental-implementation.json +0 -57
  43. package/evals/cases/i3-interview-me.json +0 -42
  44. package/evals/cases/i3-observability-and-instrumentation.json +0 -44
  45. package/evals/cases/i3-performance-optimization.json +0 -53
  46. package/evals/cases/i3-planning-and-task-breakdown.json +0 -45
  47. package/evals/cases/i3-security-and-hardening.json +0 -53
  48. package/evals/cases/i3-shipping-and-launch.json +0 -56
  49. package/evals/cases/i3-source-driven-development.json +0 -44
  50. package/evals/cases/i3-spec-driven-development.json +0 -72
  51. package/evals/cases/i3-test-driven-development.json +0 -73
  52. package/evals/cases/i3-using-agent-skills.json +0 -44
  53. package/evals/fixtures/i3-api-and-interface-design/service-brief.md +0 -19
  54. package/evals/fixtures/i3-browser-testing-with-devtools/README.md +0 -5
  55. package/evals/fixtures/i3-browser-testing-with-devtools/index.html +0 -24
  56. package/evals/fixtures/i3-browser-testing-with-devtools/server.js +0 -15
  57. package/evals/fixtures/i3-ci-cd-and-automation/package.json +0 -8
  58. package/evals/fixtures/i3-ci-cd-and-automation/src/slug.js +0 -3
  59. package/evals/fixtures/i3-ci-cd-and-automation/test/slug.test.js +0 -9
  60. package/evals/fixtures/i3-code-review-and-quality/user-search.diff +0 -16
  61. package/evals/fixtures/i3-code-simplification/config-parser.js +0 -46
  62. package/evals/fixtures/i3-code-simplification/config-parser.test.js +0 -15
  63. package/evals/fixtures/i3-context-engineering/context-audit.md +0 -15
  64. package/evals/fixtures/i3-debugging-and-error-recovery/pagination.js +0 -8
  65. package/evals/fixtures/i3-debugging-and-error-recovery/pagination.test.js +0 -9
  66. package/evals/fixtures/i3-debugging-and-error-recovery/time-pressure.md +0 -6
  67. package/evals/fixtures/i3-deprecation-and-migration/api-inventory.md +0 -9
  68. package/evals/fixtures/i3-documentation-and-adrs/decision-context.md +0 -16
  69. package/evals/fixtures/i3-doubt-driven-development/migration-plan.md +0 -19
  70. package/evals/fixtures/i3-frontend-ui-engineering/Button.tsx +0 -7
  71. package/evals/fixtures/i3-frontend-ui-engineering/design-system.md +0 -11
  72. package/evals/fixtures/i3-git-workflow-and-versioning/.eval/working-tree.patch +0 -21
  73. package/evals/fixtures/i3-git-workflow-and-versioning/app.js +0 -7
  74. package/evals/fixtures/i3-git-workflow-and-versioning/app.test.js +0 -9
  75. package/evals/fixtures/i3-incremental-implementation/reports.js +0 -7
  76. package/evals/fixtures/i3-incremental-implementation/reports.test.js +0 -12
  77. package/evals/fixtures/i3-incremental-implementation/tasks/plan.md +0 -8
  78. package/evals/fixtures/i3-observability-and-instrumentation/operations.md +0 -11
  79. package/evals/fixtures/i3-observability-and-instrumentation/payment-retry.js +0 -14
  80. package/evals/fixtures/i3-performance-optimization/benchmark.js +0 -15
  81. package/evals/fixtures/i3-performance-optimization/products.js +0 -14
  82. package/evals/fixtures/i3-planning-and-task-breakdown/notifications-spec.md +0 -18
  83. package/evals/fixtures/i3-security-and-hardening/webhook.js +0 -11
  84. package/evals/fixtures/i3-security-and-hardening/webhook.test.js +0 -13
  85. package/evals/fixtures/i3-shipping-and-launch/authority-pressure.md +0 -6
  86. package/evals/fixtures/i3-shipping-and-launch/launch-status.md +0 -11
  87. package/evals/fixtures/i3-source-driven-development/framework-task.md +0 -10
  88. package/evals/fixtures/i3-spec-driven-development/billing-brief.md +0 -16
  89. package/evals/fixtures/i3-test-driven-development/BUG.md +0 -10
  90. package/evals/fixtures/i3-test-driven-development/README.md +0 -28
  91. package/evals/fixtures/i3-test-driven-development/package.json +0 -9
  92. package/evals/fixtures/i3-test-driven-development/src/split.js +0 -8
  93. package/evals/fixtures/i3-test-driven-development/test/split.test.js +0 -13
  94. package/evals/fixtures/i3-using-agent-skills/incident.md +0 -6
  95. package/evals/fixtures/incremental-implementation-pressure/draft-export.js +0 -17
  96. package/evals/fixtures/incremental-implementation-pressure/scenario.md +0 -9
  97. package/evals/fixtures/spec-driven-development-decomposition/portal-brief.md +0 -22
  98. package/evals/fixtures/test-driven-development-ecosystem/README.md +0 -9
  99. package/evals/fixtures/test-driven-development-ecosystem/ledger.py +0 -15
  100. package/evals/fixtures/test-driven-development-ecosystem/test_ledger.py +0 -19
  101. package/evals/plugin/code-review-fires/graders/off-by-one.md +0 -7
  102. package/evals/plugin/code-review-fires/graders/severity-labels.md +0 -4
  103. package/evals/plugin/code-review-fires/graders/skill-fired.md +0 -5
  104. package/evals/plugin/code-review-fires/prompt.md +0 -31
  105. package/evals/plugin/code-review-stays-quiet/graders/not-fired.md +0 -8
  106. package/evals/plugin/code-review-stays-quiet/graders/tdd-fired.md +0 -6
  107. package/evals/plugin/code-review-stays-quiet/prompt.md +0 -8
  108. package/evals/plugin/code-review-stays-quiet-on-commit-message/graders/not-fired.md +0 -8
  109. package/evals/plugin/code-review-stays-quiet-on-commit-message/prompt.md +0 -25
  110. package/evals/skill-impact.md +0 -6
  111. package/rebrand/INSTALLATION-SPEC.md +0 -47
  112. package/rebrand/MAINTENANCE.md +0 -114
  113. package/rebrand/NOTICE.md +0 -15
  114. package/rebrand/PR-WORKFLOW.md +0 -34
  115. package/rebrand/PREFIX-SPEC.md +0 -16
  116. package/rebrand/UNIFICATION-SPEC.md +0 -33
  117. package/rebrand/VALIDATION.md +0 -32
  118. package/rebrand/VERSIONING.md +0 -35
  119. package/rebrand/apply.cjs +0 -217
  120. package/rebrand/brand.json +0 -13
  121. package/rebrand/catalog.cjs +0 -70
  122. package/rebrand/fork-patches.json +0 -27
  123. package/rebrand/identifiers.cjs +0 -41
  124. package/rebrand/overlay/.github/workflows/test-plugin-install.yml +0 -177
  125. package/rebrand/overlay/CHANGELOG.md +0 -51
  126. package/rebrand/overlay/bin/agent-targets.json +0 -726
  127. package/rebrand/overlay/bin/i3-composer-agent.cjs +0 -190
  128. package/rebrand/overlay/bin/install.cjs +0 -180
  129. package/rebrand/overlay/bin/skills-registry.LICENSE +0 -21
  130. package/rebrand/overlay/bin/wizard.cjs +0 -66
  131. package/rebrand/overlay/scripts/validate-brand.cjs +0 -44
  132. package/rebrand/overlay/tests/bundle.test.cjs +0 -85
  133. package/rebrand/overlay/tests/installer.test.cjs +0 -241
  134. package/rebrand/package.template.json +0 -82
  135. package/rebrand/rebrand.sh +0 -4
  136. package/rebrand/record-upstream.cjs +0 -37
  137. package/rebrand/templates/README.md +0 -130
  138. package/rebrand/templates/codebuddy-setup.md +0 -89
  139. package/rebrand/templates/codex-setup.md +0 -45
  140. package/rebrand/tests/pipeline.test.cjs +0 -253
  141. package/rebrand/upstream.json +0 -171
  142. package/scripts/floor-guard-reference-test.js +0 -291
  143. package/scripts/lib/skill-lint-test.js +0 -650
  144. package/scripts/lib/skill-lint.js +0 -586
  145. package/scripts/run-evals-test.js +0 -623
  146. package/scripts/run-evals.js +0 -638
  147. package/scripts/validate-artifact-paths-test.js +0 -179
  148. package/scripts/validate-artifact-paths.js +0 -130
  149. package/scripts/validate-brand.cjs +0 -44
  150. package/scripts/validate-commands-test.js +0 -216
  151. package/scripts/validate-commands.js +0 -223
  152. package/scripts/validate-reference-links-test.js +0 -495
  153. package/scripts/validate-reference-links.js +0 -187
  154. package/scripts/validate-skills.js +0 -69
  155. package/scripts/validate-versions-test.js +0 -32
  156. package/scripts/validate-versions.js +0 -35
  157. package/tests/bundle.test.cjs +0 -85
  158. package/tests/installer.test.cjs +0 -241
@@ -1,586 +0,0 @@
1
- 'use strict';
2
- /**
3
- * skill-lint.js — the skill validation rules, as a shared library.
4
- *
5
- * This is the single source of truth for what makes a SKILL.md valid
6
- * (docs/skill-anatomy.md). The CLI in scripts/validate-skills.js is a thin
7
- * wrapper over it. Splitting the rules out of the CLI keeps them importable
8
- * and unit-testable without spawning a process or touching the filesystem.
9
- *
10
- * Checks (errors block CI):
11
- * - SKILL.md exists in every skill directory
12
- * - YAML frontmatter present with 'name' and 'description' fields
13
- * - frontmatter 'name' matches the directory name
14
- * - directory name is lowercase-hyphen-separated (skill-anatomy.md: Naming Conventions)
15
- * - description does not exceed 1024 characters
16
- * - description includes a 'when to use' trigger (skill-anatomy.md: Required)
17
- * - required sections are present
18
- * - a skill's subdirectories are non-empty (skill-anatomy.md: Supporting Files)
19
- * - supporting .md filenames are lowercase-hyphen-separated
20
- *
21
- * Checks (warnings, do not block CI):
22
- * - cross-skill references point to known skills
23
- * - SKILL.md stays under the 500-line context budget
24
- */
25
-
26
- const fs = require('fs');
27
- const path = require('path');
28
-
29
- // ─── Config ──────────────────────────────────────────────────────────────────
30
-
31
- const MAX_DESCRIPTION_LENGTH = 1024;
32
-
33
- // SKILL.md loads in full the moment an agent activates the skill, so its length
34
- // is paid for on every activation (docs/skill-anatomy.md -> Context Efficiency).
35
- // A warning, not an error: trimming a skill is an editorial call, and failing
36
- // CI on line count would block unrelated work on an over-budget skill.
37
- const MAX_SKILL_LINES = 500;
38
-
39
- // The only keys the Agent Skills specification allows at the top level of
40
- // SKILL.md frontmatter. Anything else — model hints, tool lists, turn limits,
41
- // host-specific switches — belongs under `metadata` or in a per-agent adapter
42
- // file (docs/advanced-per-agent-configuration.md). Hosts do not promise to
43
- // ignore unknown top-level keys, and a published skill carrying one can fail
44
- // packaging on a strict client, so this is an error rather than a warning.
45
- const SPEC_FRONTMATTER_KEYS = new Set([
46
- 'name',
47
- 'description',
48
- 'license',
49
- 'compatibility',
50
- 'metadata',
51
- 'allowed-tools',
52
- ]);
53
-
54
- // A skill directory name must be lowercase-hyphen-separated
55
- // (docs/skill-anatomy.md → Naming Conventions).
56
- const KEBAB_CASE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
57
-
58
- // A description must state WHEN to use the skill, not just what it does
59
- // (docs/skill-anatomy.md → Required). Accept the canonical "Use when …"
60
- // plus the equivalent "Use before/after/during …" phrasings in use today.
61
- // Reject negated forms ("Do not use when …", "Don't use when …") — those
62
- // describe exclusions, not trigger conditions.
63
- const DESCRIPTION_TRIGGER = /\buse (this )?when\b|\buse (before|after|during)\b/i;
64
- const DESCRIPTION_TRIGGER_NEGATE = /\b(do not|don't|never) use (this )?(when|before|after|during)\b/i;
65
- // Same pattern, global — used to strip *every* negated clause before checking
66
- // whether a positive trigger remains. Without /g only the first is removed, so
67
- // a description carrying two or more negated clauses and no positive one would
68
- // still match DESCRIPTION_TRIGGER on the leftovers and pass. Kept separate from
69
- // the non-global form above because a /g regex carries lastIndex across .test().
70
- const DESCRIPTION_TRIGGER_NEGATE_ALL = new RegExp(DESCRIPTION_TRIGGER_NEGATE.source, 'gi');
71
-
72
- // Sections every standard SKILL.md must contain.
73
- // Each entry is an array of acceptable heading strings — the first
74
- // match wins, so you can list canonical + legacy aliases.
75
- const REQUIRED_SECTIONS = [
76
- ['## Overview'],
77
- ['## When to Use'],
78
- ['## Common Rationalizations'],
79
- ['## Red Flags'],
80
- ['## Verification'],
81
- ];
82
-
83
- // Skills that are intentionally exempt from section checks.
84
- // Exemptions live HERE, not in skill frontmatter, so contributors
85
- // cannot bypass the validator by editing their own skill file.
86
- // Every entry must have a documented reason.
87
- const SECTION_EXEMPT_SKILLS = {
88
- 'i3-using-agent-skills': 'Branded alias of the reviewed upstream meta-skill.',
89
- 'i3-idea-refine': 'Branded alias of the reviewed upstream legacy structure.',
90
- 'using-agent-skills': 'Meta-skill — orchestrates other skills; When-to-Use and Verification are not applicable to a routing document.',
91
- 'idea-refine': 'Legacy structure predating skill-anatomy.md — uses How-It-Works/Usage/Anti-patterns instead of standard headings. Tracked for conformance in https://github.com/addyosmani/agent-skills/issues',
92
- };
93
-
94
- // Regex patterns that indicate an explicit cross-skill reference.
95
- // Only these patterns trigger the dead-reference warning — generic
96
- // backtick strings in code blocks are intentionally excluded.
97
- const SKILL_REF_PATTERNS = [
98
- /\buse the `([a-z][a-z0-9-]+[a-z0-9])` skill/g,
99
- /\bfollow the `([a-z][a-z0-9-]+[a-z0-9])` skill/g,
100
- /\binvoke the `([a-z][a-z0-9-]+[a-z0-9])` skill/g,
101
- /\bcontinue with `([a-z][a-z0-9-]+[a-z0-9])`/g,
102
- /\buse `([a-z][a-z0-9-]+[a-z0-9])` skill/g,
103
- /`([a-z][a-z0-9-]+[a-z0-9])` skill\b/g,
104
- /`([a-z][a-z0-9-]+[a-z0-9])` persona\b/g,
105
- /\bsee `([a-z][a-z0-9-]+[a-z0-9])`/g,
106
- /──→ ([a-z][a-z0-9-]+[a-z0-9])\b/g, // ASCII diagram arrows
107
- /→ `([a-z][a-z0-9-]+[a-z0-9])`/g,
108
- ];
109
-
110
- // ─── Helpers ─────────────────────────────────────────────────────────────────
111
-
112
- /**
113
- * Strip fenced code blocks from markdown content so that headings, references,
114
- * and trigger phrases inside examples or templates are not matched by lint rules.
115
- *
116
- * Scans line by line and follows the CommonMark fence rules rather than a single
117
- * whole-document regex, which only recognised a column-zero backtick fence closed
118
- * by a run of exactly the same length (#437). The forms that regex let through:
119
- *
120
- * - tilde fences (`~~~`)
121
- * - fences indented by one to three spaces (common inside list items)
122
- * - a closing fence longer than its opener
123
- *
124
- * A fence closes only on a run of the *same* marker at least as long as the
125
- * opener; a shorter run, or the other marker, is content. An unterminated fence
126
- * runs to end of file, so everything after it is treated as fenced and any
127
- * required section below it is reported missing rather than silently accepted.
128
- *
129
- * Stripped lines are replaced with empty lines so line numbers are preserved
130
- * for callers that report positions.
131
- */
132
- function stripFencedCodeBlocks(content) {
133
- const out = [];
134
- let open = null; // { marker: '`' | '~', length: number } while inside a fence
135
-
136
- for (const line of content.split(/\r?\n/)) {
137
- if (open) {
138
- const close = line.match(/^ {0,3}(`{3,}|~{3,})[ \t]*$/);
139
- if (close && close[1][0] === open.marker && close[1].length >= open.length) {
140
- open = null;
141
- }
142
- out.push('');
143
- continue;
144
- }
145
-
146
- const start = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
147
- // CommonMark: the info string of a backtick fence may not contain backticks,
148
- // so a line like ````js``` is inline code` is prose, not an opener.
149
- if (start && !(start[1][0] === '`' && start[2].includes('`'))) {
150
- open = { marker: start[1][0], length: start[1].length };
151
- out.push('');
152
- continue;
153
- }
154
-
155
- out.push(line);
156
- }
157
-
158
- return out.join('\n');
159
- }
160
-
161
- /**
162
- * List the top-level keys in the frontmatter block, in file order. Only
163
- * column-zero `key:` lines count: indented lines are children of the key
164
- * above them (e.g. fields under `metadata`), and `- item` lines are list
165
- * entries. Returns [] when there is no frontmatter block.
166
- */
167
- function topLevelFrontmatterKeys(content) {
168
- const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/);
169
- if (!match) return [];
170
- const keys = [];
171
- for (const line of match[1].split(/\r?\n/)) {
172
- const m = line.match(/^([A-Za-z0-9_-]+)\s*:/);
173
- if (m) keys.push(m[1]);
174
- }
175
- return keys;
176
- }
177
-
178
- /**
179
- * Parse YAML-style frontmatter from the top of a markdown file.
180
- * Returns a key→value object, or null if no frontmatter block found.
181
- * Values are stripped of surrounding quotes.
182
- */
183
- function parseFrontmatter(content) {
184
- const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/);
185
- if (!match) return null;
186
-
187
- const result = {};
188
- for (const line of match[1].split(/\r?\n/)) {
189
- const colonIdx = line.indexOf(':');
190
- if (colonIdx === -1) continue;
191
- const key = line.slice(0, colonIdx).trim();
192
- const value = line.slice(colonIdx + 1).trim().replace(/^['"]|['"]$/g, '');
193
- if (key) result[key] = value;
194
- }
195
- return result;
196
- }
197
-
198
- /**
199
- * Report frontmatter that this file's own parser accepts but a real YAML
200
- * parser rejects.
201
- *
202
- * `parseFrontmatter` above splits each line on its first colon. That is enough
203
- * to read `name` and `description`, and it is deliberately forgiving — but it
204
- * means a SKILL.md whose frontmatter is not valid YAML passes every check here.
205
- * The hosts that consume these skills do not share that forgiveness: Cursor
206
- * parses the frontmatter as YAML when a skill is attached to a message, and a
207
- * parse failure there fails the whole request with a generic server error and
208
- * takes the rest of the chat's context with it (#494).
209
- *
210
- * So the repo could ship frontmatter that breaks a host while CI stayed green.
211
- * That was verified once by hand, against all 25 skills, in the #494 thread;
212
- * this makes it a check instead of a memory.
213
- *
214
- * Scope is deliberately narrow — the shapes a strict parser rejects and the
215
- * split-on-first-colon parser does not, each confirmed by measurement against
216
- * both PyYAML and ruby's psych rather than read off the spec:
217
- *
218
- * description: Use this: when X unquoted value with a colon-space: YAML
219
- * reads a nested mapping and errors
220
- * \tkey: value tab indentation: invalid YAML whitespace
221
- * description: "unterminated an unclosed quote
222
- * description: `skill` does X a value opening with a YAML indicator
223
- * (` @ % * ,) or a one-line | / > header
224
- *
225
- * It is not a YAML implementation. This repo has no package.json and therefore
226
- * no parser to depend on, so the check stays a small set of rules aimed at the
227
- * classes actually observed to break a host. No current frontmatter uses block
228
- * scalars or multi-line values, and the check assumes that stays true; add a
229
- * rule here rather than loosening one if it changes.
230
- */
231
- function frontmatterYamlErrors(content) {
232
- const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/);
233
- if (!match) return [];
234
-
235
- const errors = [];
236
- const lines = match[1].split(/\r?\n/);
237
- lines.forEach((line, i) => {
238
- const lineNo = i + 2; // the opening `---` is line 1
239
- if (!line.trim()) return;
240
-
241
- if (/^[ ]*\t/.test(line)) {
242
- errors.push(
243
- `Frontmatter line ${lineNo} indents with a tab — YAML forbids tabs in indentation, ` +
244
- `so a host that parses this frontmatter rejects the whole file`
245
- );
246
- return;
247
- }
248
-
249
- const colonIdx = line.indexOf(':');
250
- if (colonIdx === -1) return;
251
- const value = line.slice(colonIdx + 1).trim();
252
- if (!value) return;
253
-
254
- const quote = value[0] === '"' || value[0] === "'" ? value[0] : null;
255
- if (quote) {
256
- // An opening quote must close. `endsWith` is enough here because a
257
- // trailing comment after a quoted scalar does not appear in this repo.
258
- if (value.length < 2 || !value.endsWith(quote)) {
259
- errors.push(
260
- `Frontmatter line ${lineNo} opens a ${quote === '"' ? 'double' : 'single'} quote that never closes — ` +
261
- `a host parsing this frontmatter as YAML rejects the whole file`
262
- );
263
- }
264
- return;
265
- }
266
-
267
- // Unquoted scalar. A plain YAML scalar may not BEGIN with certain indicator
268
- // characters. The set below was measured against a real parser rather than
269
- // read off the spec, both with and without a following space, and only the
270
- // characters invalid in *both* forms with no legitimate single-line use are
271
- // rejected — a guard that flags valid frontmatter gets switched off.
272
- //
273
- // Deliberately not rejected, because each parses cleanly: `:` or `-` when NOT
274
- // followed by a space (`:platform`, `-hyphenated`), `#`, `&anchor`, and
275
- // `[`/`{`, which begin valid flow collections — `[a, b]` and `{a: b}` both
276
- // parse, so flagging the opening bracket would reject valid frontmatter.
277
- //
278
- // An earlier version of this comment claimed a leading `,`, `]` or `}` also
279
- // parsed cleanly. Measured against PyYAML and psych, all three are rejected;
280
- // `,` is flagged below. `]` and `}` are left out only because no frontmatter
281
- // has ever started with them — say so and they can be added.
282
- //
283
- // The backtick is the one that bites this repo: descriptions routinely name
284
- // other skills, and "`other-skill` designs things" is a natural way to start.
285
- if (/^[`@%*,]/.test(value)) {
286
- errors.push(
287
- `Frontmatter line ${lineNo} starts an unquoted value with '${value[0]}' — YAML reserves ` +
288
- `that character and cannot begin a plain scalar with it, so a host parsing this ` +
289
- `frontmatter rejects the whole file; wrap the value in quotes`
290
- );
291
- return;
292
- }
293
- // `|` and `>` open a block scalar only when nothing follows them on the line.
294
- // `|` with indented lines under it is valid and stays allowed; `|foo` is not
295
- // a block scalar, it is a plain scalar starting with an indicator, and both
296
- // parsers reject it.
297
- if (/^[|>]\S/.test(value)) {
298
- errors.push(
299
- `Frontmatter line ${lineNo} starts an unquoted value with '${value[0]}' followed by text — ` +
300
- `YAML reads that as a block scalar header, which may not carry content on its own line, ` +
301
- `so a host parsing this frontmatter rejects the whole file; wrap the value in quotes`
302
- );
303
- return;
304
- }
305
- if (/^[-?&](\s|$)/.test(value)) {
306
- errors.push(
307
- `Frontmatter line ${lineNo} starts an unquoted value with '${value[0]} ' — YAML reads ` +
308
- `that as an indicator rather than text and rejects the file; wrap the value in quotes`
309
- );
310
- return;
311
- }
312
-
313
- // A colon followed by a space (or ending the line) makes YAML read a nested
314
- // mapping where a plain string was meant.
315
- if (/:(\s|$)/.test(value)) {
316
- errors.push(
317
- `Frontmatter line ${lineNo} has an unquoted value containing ': ' — YAML reads that as a ` +
318
- `nested mapping and rejects the file; wrap the value in quotes`
319
- );
320
- }
321
- });
322
-
323
- return errors;
324
- }
325
-
326
- /**
327
- * Collect all explicit skill cross-references from content.
328
- * Only matches against the SKILL_REF_PATTERNS list to avoid
329
- * false-positives from inline code snippets.
330
- */
331
- function extractSkillReferences(content) {
332
- const refs = new Set();
333
- for (const pattern of SKILL_REF_PATTERNS) {
334
- // Reset lastIndex for global regexes
335
- pattern.lastIndex = 0;
336
- let m;
337
- while ((m = pattern.exec(content)) !== null) {
338
- refs.add(m[1]);
339
- }
340
- }
341
- return refs;
342
- }
343
-
344
- // ─── Linter ──────────────────────────────────────────────────────────────────
345
-
346
- /**
347
- * Lint already-read SKILL.md content. Pure: no filesystem access, so the rules
348
- * can be exercised against crafted fixtures in a unit test.
349
- * Returns { errors, warnings, exempt }.
350
- */
351
- function lintSkillContent(dirName, content, knownSkills) {
352
- const errors = [];
353
- const warnings = [];
354
- let exempt = false;
355
-
356
- // ── Frontmatter ──────────────────────────────────────────────────────────
357
- const fm = parseFrontmatter(content);
358
- if (!fm) {
359
- errors.push('Missing or malformed YAML frontmatter (expected --- block at top of file)');
360
- return { errors, warnings, exempt };
361
- }
362
-
363
- // The parser above is forgiving by design; the hosts that read this
364
- // frontmatter are not (#494).
365
- errors.push(...frontmatterYamlErrors(content));
366
-
367
- // Only the specification's keys may sit at the top level. Vendor and
368
- // runtime fields go under `metadata` or in a per-agent adapter file.
369
- for (const key of topLevelFrontmatterKeys(content)) {
370
- if (!SPEC_FRONTMATTER_KEYS.has(key)) {
371
- errors.push(
372
- `Frontmatter key '${key}' is not an Agent Skills spec field — top level allows only ` +
373
- `${[...SPEC_FRONTMATTER_KEYS].join(', ')}; put vendor or runtime fields under 'metadata' ` +
374
- `or in a per-agent adapter file (docs/advanced-per-agent-configuration.md)`
375
- );
376
- }
377
- }
378
-
379
- if (!fm.name) {
380
- errors.push("Frontmatter missing required field: 'name'");
381
- } else if (fm.name !== dirName) {
382
- errors.push(`Frontmatter name '${fm.name}' does not match directory name '${dirName}'`);
383
- }
384
-
385
- if (!KEBAB_CASE.test(dirName)) {
386
- errors.push(`Directory name '${dirName}' is not lowercase-hyphen-separated (skill-anatomy.md: Naming Conventions)`);
387
- }
388
-
389
- if (!fm.description) {
390
- errors.push("Frontmatter missing required field: 'description'");
391
- } else {
392
- if (fm.description.length > MAX_DESCRIPTION_LENGTH) {
393
- errors.push(
394
- `Description is ${fm.description.length} chars — exceeds the ${MAX_DESCRIPTION_LENGTH}-char limit` +
395
- ` (agents inject this into the system prompt)`
396
- );
397
- }
398
- const hasTrigger = DESCRIPTION_TRIGGER.test(fm.description);
399
- const onlyNegated = hasTrigger && DESCRIPTION_TRIGGER_NEGATE.test(fm.description)
400
- && !fm.description.replace(DESCRIPTION_TRIGGER_NEGATE_ALL, '').match(DESCRIPTION_TRIGGER);
401
- if (!hasTrigger || onlyNegated) {
402
- errors.push(
403
- `Description has no 'when to use' trigger — add a "Use when …" clause ` +
404
- `(skill-anatomy.md: Required — the description must say both what the skill does and when to use it)`
405
- );
406
- }
407
- }
408
-
409
- // ── Exemption guard ──────────────────────────────────────────────────────
410
- // Exemptions are validator-owned (SECTION_EXEMPT_SKILLS above).
411
- // If a skill's frontmatter tries to declare its own exemption, fail loud —
412
- // that's a sign someone is trying to bypass the validator.
413
- if (fm.type === 'meta' || fm.exempt === 'sections') {
414
- if (!Object.hasOwn(SECTION_EXEMPT_SKILLS, dirName)) {
415
- errors.push(
416
- `Frontmatter declares 'type: meta' or 'exempt: sections' but '${dirName}' is not in ` +
417
- `the validator's SECTION_EXEMPT_SKILLS allowlist. ` +
418
- `Add an entry to scripts/lib/skill-lint.js with a documented reason.`
419
- );
420
- }
421
- }
422
-
423
- // ── Required sections ────────────────────────────────────────────────────
424
- // `Object.hasOwn`, not `in`: `in` walks the prototype chain, so a skill
425
- // directory named `constructor` — which passes the kebab-case check — would
426
- // otherwise resolve to Object.prototype.constructor and be silently exempt
427
- // from every required-section check.
428
- exempt = Object.hasOwn(SECTION_EXEMPT_SKILLS, dirName);
429
-
430
- if (!exempt) {
431
- // Strip fenced code blocks so headings inside examples/templates don't
432
- // satisfy the check, and match headings at the start of a line so
433
- // `### Verification` inside a block doesn't satisfy `## Verification`.
434
- const proseContent = stripFencedCodeBlocks(content);
435
- for (const aliases of REQUIRED_SECTIONS) {
436
- const found = aliases.some(heading => {
437
- const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
438
- return new RegExp(`^${escaped}\\s*$`, 'm').test(proseContent);
439
- });
440
- if (!found) {
441
- errors.push(`Missing required section: ${aliases[0]}`);
442
- }
443
- }
444
- }
445
-
446
- // A named workflow that advertises numbered steps must document each step
447
- // before the next level-two section. Otherwise the summary promises a
448
- // process stage that the skill never teaches agents how to perform.
449
- const workflowSections = content.matchAll(
450
- /^## The [^\n]+ Workflow\s*\r?\n([\s\S]*?)(?=^## |(?![\s\S]))/gm
451
- );
452
- for (const match of workflowSections) {
453
- const section = match[1];
454
- const declared = [...section.matchAll(/^\s*(\d+)\.\s+[A-Z][A-Z -]*\s+→/gm)];
455
- if (declared.length < 2) continue;
456
-
457
- const documented = new Set(
458
- [...section.matchAll(/^### Step\s+(\d+):/gm)].map(step => step[1])
459
- );
460
- for (const step of declared) {
461
- if (!documented.has(step[1])) {
462
- errors.push(`Workflow declares Step ${step[1]} but has no matching process section`);
463
- }
464
- }
465
- }
466
-
467
- // ── Context budget ───────────────────────────────────────────────────────
468
- // Counted as newlines so the number matches what `wc -l` reports, which is
469
- // how a contributor would check this by hand.
470
- const lineCount = (content.match(/\n/g) || []).length;
471
- if (lineCount > MAX_SKILL_LINES) {
472
- warnings.push(
473
- `SKILL.md is ${lineCount} lines, over the ${MAX_SKILL_LINES}-line context budget ` +
474
- `(skill-anatomy.md: Context Efficiency) — move detail into a supporting file`,
475
- );
476
- }
477
-
478
- // ── Cross-skill references ───────────────────────────────────────────────
479
- const refs = extractSkillReferences(content);
480
- for (const ref of refs) {
481
- if (!knownSkills.has(ref)) {
482
- warnings.push(`Dead cross-reference: \`${ref}\` is not a known skill`);
483
- }
484
- }
485
-
486
- return { errors, warnings, exempt };
487
- }
488
-
489
- /** True when dir contains no regular file at any depth. */
490
- function isEffectivelyEmpty(dir) {
491
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
492
- if (entry.isDirectory()) {
493
- if (!isEffectivelyEmpty(path.join(dir, entry.name))) return false;
494
- } else {
495
- return false;
496
- }
497
- }
498
- return true;
499
- }
500
-
501
- /**
502
- * Lint the files that travel with a skill, as opposed to the contents of
503
- * SKILL.md. These rules need the filesystem, so they live here rather than in
504
- * the pure content linter. Returns an array of error strings.
505
- *
506
- * Enforced (docs/skill-anatomy.md):
507
- * - Supporting Files: "do not create an empty `scripts/` directory just to
508
- * mirror other skills". Generalized to any subdirectory — an empty
509
- * `references/` is noise for the same reason.
510
- * - Naming Conventions: "Supporting files: lowercase-hyphen-separated.md".
511
- * Scoped to .md, which is what that line covers; `scripts/` helpers carry
512
- * their own conventions under Script Requirements.
513
- */
514
- function lintSkillLayout(skillDir) {
515
- const errors = [];
516
-
517
- const walk = (dir, relBase) => {
518
- for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
519
- const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
520
- const abs = path.join(dir, entry.name);
521
-
522
- if (entry.isDirectory()) {
523
- if (isEffectivelyEmpty(abs)) {
524
- errors.push(
525
- `Empty directory \`${rel}/\` — remove it rather than mirroring another skill's layout ` +
526
- `(skill-anatomy.md: Supporting Files)`,
527
- );
528
- } else {
529
- walk(abs, rel);
530
- }
531
- continue;
532
- }
533
-
534
- if (rel === 'SKILL.md' || !entry.name.endsWith('.md')) continue;
535
- if (!KEBAB_CASE.test(entry.name.slice(0, -'.md'.length))) {
536
- errors.push(
537
- `Supporting file \`${rel}\` is not lowercase-hyphen-separated ` +
538
- `(skill-anatomy.md: Naming Conventions)`,
539
- );
540
- }
541
- }
542
- };
543
-
544
- walk(skillDir, '');
545
- return errors;
546
- }
547
-
548
- /**
549
- * Lint a skill by directory name: reads its SKILL.md, then delegates to
550
- * lintSkillContent. This is the thin filesystem wrapper the CLI uses.
551
- * Returns { errors, warnings, exempt }.
552
- */
553
- function lintSkill(dirName, skillsDir, knownSkills) {
554
- const skillDir = path.join(skillsDir, dirName);
555
- const skillPath = path.join(skillDir, 'SKILL.md');
556
-
557
- if (!fs.existsSync(skillPath)) {
558
- return { errors: ['Missing SKILL.md'], warnings: [], exempt: false };
559
- }
560
-
561
- let content;
562
- try {
563
- content = fs.readFileSync(skillPath, 'utf8');
564
- } catch (err) {
565
- return { errors: [`Unreadable SKILL.md: ${err.message}`], warnings: [], exempt: false };
566
- }
567
-
568
- const result = lintSkillContent(dirName, content, knownSkills);
569
- result.errors.push(...lintSkillLayout(skillDir));
570
- return result;
571
- }
572
-
573
- // Export only functions. The policy collections (REQUIRED_SECTIONS,
574
- // SECTION_EXEMPT_SKILLS, SKILL_REF_PATTERNS, and the regexes) stay private so a
575
- // test or future consumer cannot mutate shared state and change lint results for
576
- // the rest of the process. Exercise the rules through these functions.
577
- module.exports = {
578
- stripFencedCodeBlocks,
579
- parseFrontmatter,
580
- topLevelFrontmatterKeys,
581
- frontmatterYamlErrors,
582
- extractSkillReferences,
583
- lintSkillContent,
584
- lintSkillLayout,
585
- lintSkill,
586
- };