cyber-aced 0.3.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 (77) hide show
  1. package/.claude-plugin/plugin.json +18 -0
  2. package/.plugin/pins.json +1 -0
  3. package/.plugin/plugin.json +18 -0
  4. package/agents/aced-case-judge.md +165 -0
  5. package/agents/aced-impl-judge.md +55 -0
  6. package/agents/aced-scenario-writer.md +76 -0
  7. package/agents/aced-spec-validator.md +66 -0
  8. package/governances/README.md +52 -0
  9. package/governances/agent-tool-output.md +86 -0
  10. package/governances/cli-resolution.md +102 -0
  11. package/governances/skill-design.md +258 -0
  12. package/governances/skill-repo-structure.md +173 -0
  13. package/package.json +44 -0
  14. package/readme.md +124 -0
  15. package/skills/aced-architect-impl/README.md +9 -0
  16. package/skills/aced-architect-impl/SKILL.md +73 -0
  17. package/skills/aced-builder-impl/README.md +14 -0
  18. package/skills/aced-builder-impl/SKILL.md +46 -0
  19. package/skills/aced-builder-spec/README.md +15 -0
  20. package/skills/aced-builder-spec/SKILL.md +130 -0
  21. package/skills/aced-fit/README.md +21 -0
  22. package/skills/aced-fit/SKILL.md +105 -0
  23. package/skills/aced-impl-producer/SKILL.md +75 -0
  24. package/skills/add-scenario/README.md +25 -0
  25. package/skills/add-scenario/SKILL.md +75 -0
  26. package/skills/bench/README.md +64 -0
  27. package/skills/bench/SKILL.md +199 -0
  28. package/skills/bench/scripts/bench-claude-code.mts +187 -0
  29. package/skills/bench/scripts/bench-compare.mts +369 -0
  30. package/skills/bench/scripts/bench-record.mts +133 -0
  31. package/skills/bench/scripts/bench.mts +930 -0
  32. package/skills/check-freshness/README.md +57 -0
  33. package/skills/check-freshness/SKILL.md +76 -0
  34. package/skills/check-freshness/scripts/check-freshness.mts +399 -0
  35. package/skills/compare/README.md +25 -0
  36. package/skills/compare/SKILL.md +87 -0
  37. package/skills/contribute-skill/README.md +25 -0
  38. package/skills/contribute-skill/SKILL.md +241 -0
  39. package/skills/contribute-skill/scripts/source.mts +188 -0
  40. package/skills/define-agent/README.md +40 -0
  41. package/skills/define-agent/SKILL.md +279 -0
  42. package/skills/define-command/README.md +36 -0
  43. package/skills/define-command/SKILL.md +174 -0
  44. package/skills/define-governance/SKILL.md +151 -0
  45. package/skills/define-skill/README.md +37 -0
  46. package/skills/define-skill/SKILL.md +200 -0
  47. package/skills/extract-situation/README.md +32 -0
  48. package/skills/extract-situation/SKILL.md +54 -0
  49. package/skills/extract-situation/scripts/extract-situation.mts +287 -0
  50. package/skills/improve/README.md +25 -0
  51. package/skills/improve/SKILL.md +100 -0
  52. package/skills/improve-skill/README.md +38 -0
  53. package/skills/improve-skill/SKILL.md +169 -0
  54. package/skills/improve-skill/references/check-definitions.md +215 -0
  55. package/skills/improve-skill/scripts/validate.mts +1033 -0
  56. package/skills/init-aced/README.md +5 -0
  57. package/skills/init-aced/SKILL.md +76 -0
  58. package/skills/init-aced/scripts/ensure-results-ignored.mts +117 -0
  59. package/skills/list-skills/README.md +19 -0
  60. package/skills/list-skills/SKILL.md +55 -0
  61. package/skills/list-skills/scripts/list-skills.mts +210 -0
  62. package/skills/manage/README.md +35 -0
  63. package/skills/manage/SKILL.md +65 -0
  64. package/skills/manage-model-runners/README.md +25 -0
  65. package/skills/manage-model-runners/SKILL.md +113 -0
  66. package/skills/manage-skill-dirs/README.md +17 -0
  67. package/skills/manage-skill-dirs/SKILL.md +54 -0
  68. package/skills/manage-skill-dirs/scripts/manage-skill-dirs.mts +328 -0
  69. package/skills/repair-private-skills/README.md +17 -0
  70. package/skills/repair-private-skills/SKILL.md +59 -0
  71. package/skills/repair-private-skills/scripts/repair-private-skills.mts +279 -0
  72. package/skills/report/README.md +25 -0
  73. package/skills/report/SKILL.md +74 -0
  74. package/skills/run/README.md +25 -0
  75. package/skills/run/SKILL.md +220 -0
  76. package/skills/skillify/README.md +25 -0
  77. package/skills/skillify/SKILL.md +141 -0
@@ -0,0 +1,1033 @@
1
+ #!/usr/bin/env node
2
+ // validate — the deterministic mechanical-check engine for the ACED improve-skill node. Ported
3
+ // from the cyberplace CLI's `audit validate` (packages/cyberplace/src/audit/{validate,cli}.ts +
4
+ // skill/manifest.ts, inlined here for self-containment).
5
+ //
6
+ // Runs ONLY the mechanical check subset: S1-S9, Q2-Q5, Q10-Q11, Q17, Q18, E1-E2, E6, E9. Everything
7
+ // else (Q1, Q6-Q9, Q12-Q16, E3-E5, E7-E8, P1-P3) is agent-only quality review and is NOT run here —
8
+ // that is judged separately by the improve-skill agent skill / the ACED impl-judge.
9
+ //
10
+ // CLI:
11
+ // --path <path> validate a single skill directory or SKILL.md file (default: whole-project scan)
12
+ // --dir <glob> add a one-off scan location for this run (repeatable; ignored when --path is set)
13
+ // --root <path> repo root to scan from (default: cwd)
14
+ // --format <fmt> text (default, human-readable) or json (machine-readable findings)
15
+ //
16
+ // Whole-project scans (no --path) union the built-in defaults (skills/, .agents/skills/) with any
17
+ // extra locations declared in .agents/aced/skill-dirs.toml's `anchors` array (ADR-0019-style, opt-in)
18
+ // plus --dir globs. * globs one path segment; ** globs zero or more segments at any depth.
19
+ //
20
+ // Exit codes: 0 = no CRITICAL findings (including "no SKILL.md files found"); 1 = at least one
21
+ // CRITICAL finding, OR --path named a target with no SKILL.md.
22
+ //
23
+ // Pure functions are exported for node:test; running the file directly drives the CLI. No deps
24
+ // (the repo's node-≥23.6 convention) — imports ONLY node:* builtins.
25
+
26
+ import * as fs from 'node:fs'
27
+ import * as path from 'node:path'
28
+ import { pathToFileURL } from 'node:url'
29
+
30
+ // ── types ──
31
+
32
+ type Severity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'
33
+
34
+ export interface Finding {
35
+ severity: Severity
36
+ checkId: string
37
+ name: string
38
+ evidence: string
39
+ fix: string
40
+ }
41
+
42
+ export interface CheckResult {
43
+ criticals: Finding[]
44
+ warnings: Finding[]
45
+ }
46
+
47
+ // ── skill.json manifest (inlined from cyberplace src/skill/manifest.ts) ──
48
+
49
+ interface SkillManifest {
50
+ distribution?: {
51
+ install_via: string
52
+ package?: { name: string; bin?: string }
53
+ }
54
+ }
55
+
56
+ function readSkillManifest(skillDir: string): SkillManifest | null {
57
+ const filePath = path.join(skillDir, 'skill.json')
58
+ if (!fs.existsSync(filePath)) return null
59
+ try {
60
+ return JSON.parse(fs.readFileSync(filePath, 'utf8')) as SkillManifest
61
+ } catch {
62
+ return null
63
+ }
64
+ }
65
+
66
+ // ── scan scope ──
67
+
68
+ export const SKILL_DIRS = ['skills', '.agents/skills']
69
+
70
+ const SKILL_DIRS_CONFIG = '.agents/aced/skill-dirs.toml'
71
+ const SCAN_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.turbo', '.next', 'coverage'])
72
+
73
+ // Minimal `anchors = [ … ]` TOML string-array parser (mirrors manage-spec-anchors.mts'
74
+ // parseAnchorsToml — no toml dependency). Malformed/absent config => no extra locations.
75
+ export function parseSkillDirsToml(text: string): string[] {
76
+ const m = /(^|\n)\s*anchors\s*=\s*\[([\s\S]*?)\]/.exec(text)
77
+ if (!m) return []
78
+ const out: string[] = []
79
+ for (const q of m[2]!.matchAll(/"([^"]*)"|'([^']*)'/g)) out.push((q[1] ?? q[2] ?? '').trim())
80
+ return out.filter((s) => s !== '')
81
+ }
82
+
83
+ // Read .agents/aced/skill-dirs.toml's `anchors` array. Absent/unreadable/malformed => [] so the
84
+ // scan falls back to the built-in defaults unchanged; never throws.
85
+ export function readSkillDirsConfig(cwd: string): string[] {
86
+ const file = path.join(cwd, SKILL_DIRS_CONFIG)
87
+ if (!fs.existsSync(file)) return []
88
+ try {
89
+ return parseSkillDirsToml(fs.readFileSync(file, 'utf8'))
90
+ } catch {
91
+ return []
92
+ }
93
+ }
94
+
95
+ // Every dir reachable from `startDir` by descending zero or more levels (startDir itself
96
+ // included), skipping SCAN_SKIP_DIRS. Backs the `**` segment (any-depth glob) below.
97
+ function collectDescendants(cwd: string, startDir: string): string[] {
98
+ const out = [startDir]
99
+ let entries: fs.Dirent[]
100
+ try {
101
+ entries = fs.readdirSync(path.join(cwd, startDir), { withFileTypes: true })
102
+ } catch {
103
+ return out
104
+ }
105
+ for (const entry of entries) {
106
+ if (!entry.isDirectory() || SCAN_SKIP_DIRS.has(entry.name)) continue
107
+ out.push(...collectDescendants(cwd, startDir ? `${startDir}/${entry.name}` : entry.name))
108
+ }
109
+ return out
110
+ }
111
+
112
+ // Expand one skill-dir pattern against the filesystem under cwd. `*` globs exactly one path
113
+ // segment; `**` globs zero or more segments at any depth; every other segment is literal. Returns
114
+ // concrete repo-relative directories (no <project> capture token here — only * and **).
115
+ export function expandSkillDirPattern(cwd: string, pattern: string): string[] {
116
+ const segs = pattern
117
+ .trim()
118
+ .replace(/\\/g, '/')
119
+ .replace(/^\/+|\/+$/g, '')
120
+ .split('/')
121
+ .filter(Boolean)
122
+
123
+ let frontier: string[] = ['']
124
+ for (const seg of segs) {
125
+ const next: string[] = []
126
+ if (seg === '**') {
127
+ for (const dir of frontier) next.push(...collectDescendants(cwd, dir))
128
+ } else if (seg === '*') {
129
+ for (const dir of frontier) {
130
+ let entries: fs.Dirent[]
131
+ try {
132
+ entries = fs.readdirSync(path.join(cwd, dir), { withFileTypes: true })
133
+ } catch {
134
+ continue
135
+ }
136
+ for (const entry of entries) {
137
+ if (!entry.isDirectory() || SCAN_SKIP_DIRS.has(entry.name)) continue
138
+ next.push(dir ? `${dir}/${entry.name}` : entry.name)
139
+ }
140
+ }
141
+ } else {
142
+ for (const dir of frontier) next.push(dir ? `${dir}/${seg}` : seg)
143
+ }
144
+ frontier = next
145
+ }
146
+ return frontier
147
+ }
148
+
149
+ // The scan locations for a whole-project run: the two built-in defaults, unioned with the
150
+ // configured .agents/aced/skill-dirs.toml anchors and any --dir globs, each expanded against the
151
+ // filesystem. A glob matching nothing contributes no locations and never errors.
152
+ export function resolveScanDirs(cwd: string, dirGlobs: string[] = []): string[] {
153
+ const dirs = new Set<string>(SKILL_DIRS)
154
+ for (const pattern of [...readSkillDirsConfig(cwd), ...dirGlobs]) {
155
+ for (const dir of expandSkillDirPattern(cwd, pattern)) dirs.add(dir)
156
+ }
157
+ return [...dirs]
158
+ }
159
+
160
+ // The recognized scan roots as absolute realpaths — a directory a correctly-nested skill sits one
161
+ // named level below. S1 keys on membership here (in addition to the universal `skills` convention),
162
+ // so a skill under a configured non-`skills` scan location is still recognized as correctly placed.
163
+ export function recognizedScanRoots(cwd: string, dirGlobs: string[] = []): Set<string> {
164
+ const roots = new Set<string>()
165
+ for (const dir of resolveScanDirs(cwd, dirGlobs)) {
166
+ try {
167
+ roots.add(fs.realpathSync(path.join(cwd, dir)))
168
+ } catch {
169
+ // a configured location that does not exist on disk contributes no root
170
+ }
171
+ }
172
+ return roots
173
+ }
174
+
175
+ export function findSkillFiles(dirs: string[], cwd: string): string[] {
176
+ const seen = new Set<string>()
177
+ const results: string[] = []
178
+
179
+ for (const dir of dirs) {
180
+ const base = path.join(cwd, dir)
181
+ if (!fs.existsSync(base)) continue
182
+
183
+ for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
184
+ const skillFile = path.join(base, entry.name, 'SKILL.md')
185
+ if (!fs.existsSync(skillFile)) continue
186
+ try {
187
+ const real = fs.realpathSync(skillFile)
188
+ if (!seen.has(real)) {
189
+ seen.add(real)
190
+ results.push(real)
191
+ }
192
+ } catch {
193
+ // skip unresolvable symlinks
194
+ }
195
+ }
196
+ }
197
+
198
+ return results.sort()
199
+ }
200
+
201
+ // ── check constants ──
202
+
203
+ const GENERIC_PHRASES = [
204
+ 'helps with',
205
+ 'general purpose',
206
+ 'handles tasks',
207
+ 'use this skill when the user asks anything',
208
+ 'does things',
209
+ ]
210
+
211
+ // Catastrophic command shapes: always CRITICAL regardless of an `rm` flag/target analysis. `rm`
212
+ // itself is graded separately below by recursion flag and target shape (classifyRmSeverity).
213
+ const E1_CRITICAL_PATTERNS: RegExp[] = [
214
+ /\bsudo\s+rm\b/,
215
+ /(curl|wget)[^|\n]*\|\s*(ba|z)?sh\b/,
216
+ /\bdd\s+if=/,
217
+ /\b(mkfs|fdisk|parted)\b/,
218
+ /kill\s+-9\s+1\b/,
219
+ /:\(\)\{\s*:\|:&\s*\}/,
220
+ /chmod\s+-R\s+777\s+/,
221
+ ]
222
+
223
+ // Grade an `rm` invocation by blast radius. Not flagged at all (no -r/-R, no -f) => null (E1
224
+ // doesn't fire). Any recursion flag => CRITICAL. A forced (-f), non-recursive remove is CRITICAL
225
+ // when any target token escapes a single named relative file (glob, absolute, or home/var path),
226
+ // else HIGH (scoped forced delete of named relative file(s) — surfaced, not blocking).
227
+ export function classifyRmSeverity(line: string): 'CRITICAL' | 'HIGH' | null {
228
+ const m = /\brm\s+(.+)/.exec(line)
229
+ if (!m) return null
230
+ const tokens = m[1]!.trim().split(/\s+/)
231
+ const flagTokens = tokens.filter((t) => /^-\w+$/.test(t))
232
+ const targetTokens = tokens.filter((t) => !t.startsWith('-'))
233
+ const flags = flagTokens.join('')
234
+ const hasRecursion = /[rR]/.test(flags)
235
+ const hasForce = /f/.test(flags)
236
+
237
+ if (!hasRecursion && !hasForce) return null
238
+ if (hasRecursion) return 'CRITICAL'
239
+
240
+ const escapesScope =
241
+ targetTokens.length === 0 ||
242
+ targetTokens.some((t) => /[*?[]/.test(t) || t.startsWith('/') || t.startsWith('~') || t.includes('$'))
243
+ return escapesScope ? 'CRITICAL' : 'HIGH'
244
+ }
245
+
246
+ // Classify one line for E1. Checks the unconditional-CRITICAL patterns first, then falls back to
247
+ // the rm-specific flag/target grading.
248
+ function classifyE1Line(line: string): 'CRITICAL' | 'HIGH' | null {
249
+ for (const pat of E1_CRITICAL_PATTERNS) {
250
+ if (pat.test(line)) return 'CRITICAL'
251
+ }
252
+ return classifyRmSeverity(line)
253
+ }
254
+
255
+ const E2_PATTERNS: RegExp[] = [
256
+ /[Ii]gnore (previous|all|prior) instructions/,
257
+ /[Yy]ou are now [A-Z][a-zA-Z]/,
258
+ /[Ff]rom now on you are /,
259
+ /[Dd]isregard your (guidelines|rules)/,
260
+ /[Ff]orget your (guidelines|training)/,
261
+ /[Yy]our new instructions are/,
262
+ ]
263
+
264
+ const INVISIBLE_UNICODE = new Map<number, string>([
265
+ [0x00ad, 'SOFT HYPHEN'],
266
+ [0x034f, 'COMBINING GRAPHEME JOINER'],
267
+ [0x061c, 'ARABIC LETTER MARK'],
268
+ [0x180e, 'MONGOLIAN VOWEL SEPARATOR'],
269
+ [0x200b, 'ZERO WIDTH SPACE'],
270
+ [0x200c, 'ZERO WIDTH NON-JOINER'],
271
+ [0x200d, 'ZERO WIDTH JOINER'],
272
+ [0x200e, 'LEFT-TO-RIGHT MARK'],
273
+ [0x200f, 'RIGHT-TO-LEFT MARK'],
274
+ [0x202a, 'LEFT-TO-RIGHT EMBEDDING'],
275
+ [0x202b, 'RIGHT-TO-LEFT EMBEDDING'],
276
+ [0x202c, 'POP DIRECTIONAL FORMATTING'],
277
+ [0x202d, 'LEFT-TO-RIGHT OVERRIDE'],
278
+ [0x202e, 'RIGHT-TO-LEFT OVERRIDE'],
279
+ [0x2060, 'WORD JOINER'],
280
+ [0x2061, 'FUNCTION APPLICATION'],
281
+ [0x2062, 'INVISIBLE TIMES'],
282
+ [0x2063, 'INVISIBLE SEPARATOR'],
283
+ [0x2064, 'INVISIBLE PLUS'],
284
+ [0x2066, 'LEFT-TO-RIGHT ISOLATE'],
285
+ [0x2067, 'RIGHT-TO-LEFT ISOLATE'],
286
+ [0x2068, 'FIRST STRONG ISOLATE'],
287
+ [0x2069, 'POP DIRECTIONAL ISOLATE'],
288
+ [0xfeff, 'ZERO WIDTH NO-BREAK SPACE / BOM'],
289
+ ])
290
+
291
+ // Q17 — objective operational-detail markers in a partial-skill description.
292
+ // A named/pathed artifact file (stem required so bare domain-noun ".feature" is not flagged):
293
+ const OP_DETAIL_FILEREF = /[\w-]+\.(?:mts|mjs|feature|jsonl|toml|sddignore)\b|[\w.-]+\/[\w./-]+\.(?:md|js|ts|json|sh)\b/
294
+ // A known operational directory reference:
295
+ const OP_DETAIL_DIR = /\.agents\/|\.github\/|(?<![\w.])scripts\//
296
+ // A validate-engine check-ID (S1, Q5, E9, P1-P3, S1-S6):
297
+ const OP_DETAIL_CHECKID = /\b[SQEP]\d{1,2}(?:[-–]\d{1,2})?\b/
298
+
299
+ const STDOUT_AS_DATA_PATTERNS: RegExp[] = [
300
+ /\bparse (?:the )?(?:script )?output\b/i,
301
+ /\bread (?:the )?(?:summary )?table\b/i,
302
+ /\bshow (?:the )?(?:script )?output\b/i,
303
+ /\bfrom (?:the )?script output\b/i,
304
+ ]
305
+
306
+ function hasStdoutAsDataMitigation(content: string): boolean {
307
+ return (
308
+ /(--json|--format json|--format agent)/.test(content) ||
309
+ /parse stdout json/i.test(content) ||
310
+ /read `<[^`]+>`/i.test(content) ||
311
+ /read the (?:artifact|file|report)/i.test(content)
312
+ )
313
+ }
314
+
315
+ // ── frontmatter / body parsing ──
316
+
317
+ function parseFrontmatter(content: string): { name: string; description: string; internal: boolean } {
318
+ const lines = content.split('\n')
319
+ let fmCount = 0
320
+ let name = ''
321
+ let description = ''
322
+ let internal = false
323
+
324
+ for (const line of lines) {
325
+ if (line.trim() === '---') {
326
+ fmCount++
327
+ if (fmCount === 2) break
328
+ continue
329
+ }
330
+ if (fmCount !== 1) continue
331
+
332
+ const nameMatch = line.match(/^name:\s*(.+)/)
333
+ if (nameMatch) name = nameMatch[1]!.trim().replace(/^["']|["']$/g, '')
334
+
335
+ const descMatch = line.match(/^description:\s*(.+)/)
336
+ if (descMatch) description = descMatch[1]!.trim().replace(/^["']|["']$/g, '')
337
+
338
+ if (/^user-invocable:\s*false\s*$/i.test(line)) {
339
+ internal = true
340
+ }
341
+ }
342
+
343
+ return { name, description, internal }
344
+ }
345
+
346
+ function extractBody(content: string): string {
347
+ const lines = content.split('\n')
348
+ let fmCount = 0
349
+ const bodyLines: string[] = []
350
+
351
+ for (const line of lines) {
352
+ if (line.trim() === '---') {
353
+ fmCount++
354
+ continue
355
+ }
356
+ if (fmCount >= 2) bodyLines.push(line)
357
+ }
358
+
359
+ return bodyLines.join('\n')
360
+ }
361
+
362
+ function extractCodeBlocks(content: string): string {
363
+ const lines = content.split('\n')
364
+ let inBlock = false
365
+ const out: string[] = []
366
+
367
+ for (let i = 0; i < lines.length; i++) {
368
+ if (lines[i]!.startsWith('```')) {
369
+ inBlock = !inBlock
370
+ continue
371
+ }
372
+ if (inBlock) out.push(`${i + 1}: ${lines[i]}`)
373
+ }
374
+
375
+ return out.join('\n')
376
+ }
377
+
378
+ function stripExamples(content: string): string {
379
+ return content.replace(/`[^`]*`/g, '').replace(/"[^"]*"/g, '')
380
+ }
381
+
382
+ function isShellExpandedReference(source: string, matchIndex: number): boolean {
383
+ const previousChar = source[matchIndex - 1]
384
+ const previousTwoChars = source.slice(Math.max(0, matchIndex - 2), matchIndex)
385
+ return previousChar === '$' || previousTwoChars === ')/' || previousTwoChars === '}/'
386
+ }
387
+
388
+ function findInvisibleUnicode(
389
+ source: string,
390
+ ): { line: number; column: number; codePoint: number; name: string } | null {
391
+ let line = 1
392
+ let column = 1
393
+
394
+ for (const char of source) {
395
+ if (char === '\n') {
396
+ line++
397
+ column = 1
398
+ continue
399
+ }
400
+
401
+ const codePoint = char.codePointAt(0)
402
+ if (codePoint !== undefined) {
403
+ const name = INVISIBLE_UNICODE.get(codePoint)
404
+ if (name) return { line, column, codePoint, name }
405
+ }
406
+
407
+ column++
408
+ }
409
+
410
+ return null
411
+ }
412
+
413
+ const ROOT_LOCAL_PATH_PREFIXES = ['docs/', 'governances/', 'skills/', 'src/', 'apps/', 'packages/']
414
+
415
+ // S7–S9: agentskills.io directory structure checks
416
+ const ALLOWED_SKILL_SUBDIRS = new Set(['scripts', 'references', 'assets'])
417
+ const STATIC_RESOURCE_EXTENSIONS = new Set([
418
+ '.png',
419
+ '.jpg',
420
+ '.jpeg',
421
+ '.gif',
422
+ '.svg',
423
+ '.ico',
424
+ '.webp', // images
425
+ '.json',
426
+ '.yaml',
427
+ '.yml',
428
+ '.toml',
429
+ '.csv',
430
+ '.xml', // data files (json excluded for skill.json below)
431
+ ])
432
+ const TEMPLATE_PATTERNS = [/\.template\./i, /\.tmpl$/i, /^template\./i]
433
+
434
+ function normalizeLinkTarget(target: string): string {
435
+ const trimmed = target.trim().replace(/^<|>$/g, '')
436
+ const withoutTitle = trimmed.split(/\s+"/)[0] ?? trimmed
437
+ return withoutTitle
438
+ }
439
+
440
+ function findPublicSkillExternalRefs(
441
+ content: string,
442
+ skillDir: string,
443
+ repoRoot: string,
444
+ ): Array<{ ref: string; reason: string }> {
445
+ const findings = new Map<string, string>()
446
+ const add = (ref: string, reason: string) => {
447
+ if (!findings.has(ref)) findings.set(ref, reason)
448
+ }
449
+
450
+ const markdownLinkPattern = /\[[^\]]+\]\(([^)]+)\)/g
451
+ let match: RegExpExecArray | null
452
+ while ((match = markdownLinkPattern.exec(content)) !== null) {
453
+ const target = normalizeLinkTarget(match[1] ?? '')
454
+ if (!target || /^(https?:|mailto:|#)/i.test(target)) continue
455
+ if (target.startsWith('../')) {
456
+ add(target, 'parent-directory traversal escapes the skill folder')
457
+ continue
458
+ }
459
+ if (ROOT_LOCAL_PATH_PREFIXES.some((prefix) => target.startsWith(prefix) && target.length > prefix.length)) {
460
+ add(target, 'repo-local link points outside the skill folder')
461
+ continue
462
+ }
463
+ if (path.isAbsolute(target) && target.startsWith(repoRoot)) {
464
+ add(target, 'absolute path points to a repo file outside the skill folder')
465
+ }
466
+ }
467
+
468
+ const prosePathPattern =
469
+ /(^|[\s(<'"`])(\.\.\/[^\s)"'`<>]+|(?:docs|governances|skills|src|apps|packages)\/[^\s)"'`<>]+)(?![A-Za-z0-9_-])/gm
470
+
471
+ while ((match = prosePathPattern.exec(content)) !== null) {
472
+ const ref = match[2] ?? ''
473
+ if (!ref) continue
474
+ if (ref.startsWith('../')) {
475
+ add(ref, 'parent-directory traversal escapes the skill folder')
476
+ } else {
477
+ const resolved = path.resolve(repoRoot, ref)
478
+ if (resolved.startsWith(skillDir)) continue
479
+ if (fs.existsSync(resolved)) add(ref, 'repo-local path points outside the skill folder')
480
+ }
481
+ }
482
+
483
+ return Array.from(findings, ([ref, reason]) => ({ ref, reason }))
484
+ }
485
+
486
+ // ── the mechanical check engine: S1-S6, Q1-Q5, Q10-Q11, Q17, Q18, E1-E2, E6, E9 ──
487
+
488
+ export function runChecks(filePath: string, scanRoots?: Set<string>): CheckResult {
489
+ const criticals: Finding[] = []
490
+ const warnings: Finding[] = []
491
+
492
+ const crit = (checkId: string, name: string, evidence: string, fix: string) =>
493
+ criticals.push({ severity: 'CRITICAL', checkId, name, evidence, fix })
494
+
495
+ const warn = (severity: Severity, checkId: string, name: string, evidence: string, fix: string) =>
496
+ warnings.push({ severity, checkId, name, evidence, fix })
497
+
498
+ const content = fs.readFileSync(filePath, 'utf8')
499
+ const skillDir = path.dirname(filePath)
500
+ const skillBaseDir = path.dirname(skillDir)
501
+ const skillBaseParent = path.basename(path.dirname(skillBaseDir))
502
+ const repoRoot = skillBaseParent === '.agents' ? path.dirname(path.dirname(skillBaseDir)) : path.dirname(skillBaseDir)
503
+ const dirName = path.basename(skillDir)
504
+ const parent = path.basename(skillBaseDir)
505
+
506
+ const { name: fmName, description: fmDesc, internal: fmInternal } = parseFrontmatter(content)
507
+ const body = extractBody(content)
508
+ const codeBlocks = extractCodeBlocks(content)
509
+ const stripped = stripExamples(content)
510
+ const invisibleInSkill = findInvisibleUnicode(content)
511
+ const isPartialSkill = fmInternal
512
+ const isPublicShippedSkill = parent === 'skills' && skillBaseParent !== '.agents' && !fmInternal
513
+
514
+ // S1: the SKILL.md must sit in its own named subdirectory directly under a recognized scan root.
515
+ // "Recognized" is the universal `skills` convention (holds anywhere, incl. an external/temp skill
516
+ // audited via --path) OR any configured scan root passed in — never a literal-`skills`-dirname
517
+ // requirement, which would false-positive a skill under a configured non-`skills` location (#149).
518
+ let containerReal = skillBaseDir
519
+ try {
520
+ containerReal = fs.realpathSync(skillBaseDir)
521
+ } catch {
522
+ // unresolvable container (e.g. broken symlink) — fall back to the raw path
523
+ }
524
+ const nestedUnderScanRoot = parent === 'skills' || (scanRoots?.has(containerReal) ?? false)
525
+ if (!nestedUnderScanRoot) {
526
+ crit(
527
+ 'S1',
528
+ 'SKILL.md in own directory',
529
+ `path: ${filePath}`,
530
+ 'Move SKILL.md into its own named subdirectory directly under a recognized skill-scan root',
531
+ )
532
+ }
533
+
534
+ if (!fmName) {
535
+ crit(
536
+ 'S2',
537
+ 'Required frontmatter: name',
538
+ 'name: field missing or empty',
539
+ `Add 'name: ${dirName}' to the YAML frontmatter block`,
540
+ )
541
+ }
542
+ if (!fmDesc) {
543
+ crit(
544
+ 'S2',
545
+ 'Required frontmatter: description',
546
+ 'description: field missing or empty',
547
+ 'Add a description: field to the YAML frontmatter block',
548
+ )
549
+ }
550
+
551
+ if (fmName && fmName !== dirName) {
552
+ warn(
553
+ 'HIGH',
554
+ 'S3',
555
+ 'name matches directory',
556
+ `name: '${fmName}' but directory is '${dirName}'`,
557
+ `Set name: to '${dirName}'`,
558
+ )
559
+ }
560
+
561
+ const s4RefPattern = /([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_./-]+\.[a-zA-Z]+)/g
562
+ const s4Skip = /[<>*]|^(https?:\/\/|~|\/|skills\/|\.agents\/|\.github\/|\.cursor\/|\.vscode\/)/
563
+ const s4Ext = /\.(md|sh|mts|mjs|js|ts|py|json|yaml|yml)$/
564
+ const s4Refs = new Set<string>()
565
+ let m: RegExpExecArray | null
566
+ while ((m = s4RefPattern.exec(codeBlocks)) !== null) {
567
+ const ref = m[1]!
568
+ if (isShellExpandedReference(codeBlocks, m.index)) continue
569
+ if (m.index > 0 && codeBlocks[m.index - 1] === '/') continue
570
+ if (!s4Skip.test(ref) && s4Ext.test(ref)) s4Refs.add(ref)
571
+ }
572
+ for (const ref of s4Refs) {
573
+ if (!fs.existsSync(path.join(skillDir, ref))) {
574
+ warn(
575
+ 'HIGH',
576
+ 'S4',
577
+ 'Referenced file does not exist in skill directory',
578
+ `${ref} (looked for ${path.join(skillDir, ref)})`,
579
+ 'Create the file inside the skill directory or remove the reference',
580
+ )
581
+ }
582
+ }
583
+
584
+ if (isPublicShippedSkill) {
585
+ for (const finding of findPublicSkillExternalRefs(content, skillDir, repoRoot)) {
586
+ warn(
587
+ 'HIGH',
588
+ 'S4',
589
+ 'Public skill references files outside its skill directory',
590
+ `${finding.ref} (${finding.reason})`,
591
+ 'Keep shipped skill references inside the skill folder or replace with external URLs / generic prose',
592
+ )
593
+ }
594
+ }
595
+
596
+ const anchorPattern = /\[([^\]]+)\]\(#([^)]+)\)/g
597
+ while ((m = anchorPattern.exec(stripped)) !== null) {
598
+ const anchor = m[2]!
599
+ const headingPat = anchor.replace(/-/g, '[- ]')
600
+ if (!new RegExp(`^#{1,6}.*${headingPat}`, 'im').test(content)) {
601
+ warn(
602
+ 'MEDIUM',
603
+ 'S5',
604
+ 'Internal anchor link does not resolve',
605
+ `#${anchor}`,
606
+ `Add a heading matching '${anchor.replace(/-/g, ' ')}' or fix the link target`,
607
+ )
608
+ }
609
+ }
610
+
611
+ if (fmDesc) {
612
+ if (!isPartialSkill) {
613
+ const wordCount = fmDesc.split(/\s+/).filter(Boolean).length
614
+ if (wordCount < 12) {
615
+ warn(
616
+ 'HIGH',
617
+ 'Q2',
618
+ 'Description too short (specificity)',
619
+ `${wordCount} words: ${fmDesc}`,
620
+ 'Expand the description to at least 12 words with specific trigger conditions',
621
+ )
622
+ }
623
+ }
624
+ const genericHit = GENERIC_PHRASES.find((p) => new RegExp(p, 'i').test(fmDesc))
625
+ if (genericHit) {
626
+ warn(
627
+ 'HIGH',
628
+ 'Q2',
629
+ 'Description uses vague generic phrase',
630
+ `matched '${genericHit}' in: ${fmDesc}`,
631
+ 'Replace with specific trigger conditions and outcomes',
632
+ )
633
+ }
634
+ }
635
+
636
+ if (
637
+ isPartialSkill &&
638
+ fmDesc &&
639
+ (OP_DETAIL_FILEREF.test(fmDesc) || OP_DETAIL_DIR.test(fmDesc) || OP_DETAIL_CHECKID.test(fmDesc))
640
+ ) {
641
+ warn(
642
+ 'MEDIUM',
643
+ 'Q17',
644
+ 'Partial skill description carries operational detail',
645
+ `description: ${fmDesc}`,
646
+ 'Trim to identity + named caller only; move paths, directories, check-IDs, and artifact filenames to the body or README',
647
+ )
648
+ }
649
+
650
+ if (isPartialSkill && fmDesc && !/^Partial Skill:/i.test(fmDesc)) {
651
+ warn(
652
+ 'MEDIUM',
653
+ 'Q3',
654
+ 'Partial skill description missing the "Partial Skill:" prefix',
655
+ `description: ${fmDesc}`,
656
+ 'Lead the description with "Partial Skill: invoke by name only — <identity>. <caller>."',
657
+ )
658
+ }
659
+
660
+ if (isPartialSkill && fmDesc && /use this skill when|when to use/i.test(fmDesc)) {
661
+ warn(
662
+ 'MEDIUM',
663
+ 'Q18',
664
+ 'Trigger language on a partial (by-name) skill',
665
+ `description: ${fmDesc}`,
666
+ 'Remove "Use this skill when"/"when to use" phrasing — a partial skill is invoked by name, and trigger-shaped text invites spurious harness auto-matching',
667
+ )
668
+ }
669
+
670
+ if (!body.trim()) {
671
+ warn(
672
+ 'MEDIUM',
673
+ 'Q4',
674
+ 'No actionable instruction body',
675
+ 'body is empty after frontmatter',
676
+ 'Add numbered steps, decision logic, or ## section headers',
677
+ )
678
+ } else if (!/^\d+\.|^#{1,4} /m.test(body)) {
679
+ warn(
680
+ 'MEDIUM',
681
+ 'Q4',
682
+ 'No actionable instruction body',
683
+ 'body has no numbered steps or section headers',
684
+ 'Add numbered steps, decision logic, or ## section headers',
685
+ )
686
+ }
687
+
688
+ if (fmDesc && fmDesc.length > 1024) {
689
+ warn(
690
+ 'HIGH',
691
+ 'Q5',
692
+ 'Description exceeds 1024 characters (spec hard limit)',
693
+ `description: ${fmDesc.slice(0, 80)}… (${fmDesc.length} chars)`,
694
+ 'Trim to ≤1024 chars; move example phrases to the skill body',
695
+ )
696
+ }
697
+
698
+ const scriptsDir = path.join(skillDir, 'scripts')
699
+ const hasScripts = fs.existsSync(scriptsDir)
700
+
701
+ for (const pat of STDOUT_AS_DATA_PATTERNS) {
702
+ if (pat.test(body) && !hasStdoutAsDataMitigation(content)) {
703
+ warn(
704
+ 'HIGH',
705
+ 'Q10',
706
+ 'SKILL.md instructs parsing stdout prose as data',
707
+ body.match(pat)?.[0] ?? 'stdout-as-data pattern',
708
+ 'Prefer artifact file paths, read stdout with --format agent, or parse with --format json for non-LLM consumers',
709
+ )
710
+ break
711
+ }
712
+ }
713
+
714
+ if (hasScripts) {
715
+ const scriptFiles = fs.readdirSync(scriptsDir).filter((f) => !f.startsWith('.'))
716
+ const hasInteractive = scriptFiles.some((f) => {
717
+ const src = fs.readFileSync(path.join(scriptsDir, f), 'utf8')
718
+ return /readline|createInterface|\.question\(/.test(src)
719
+ })
720
+ if (hasInteractive && !/--yes|-y/.test(content)) {
721
+ warn(
722
+ 'HIGH',
723
+ 'Q11',
724
+ 'Interactive script missing non-interactive agent path',
725
+ 'scripts/ uses readline/prompt but SKILL.md does not document --yes or -y',
726
+ 'Document --yes (or equivalent) in SKILL.md for autonomous agent runs',
727
+ )
728
+ }
729
+ }
730
+
731
+ for (const line of codeBlocks.split('\n')) {
732
+ const severity = classifyE1Line(line)
733
+ if (!severity) continue
734
+ if (severity === 'CRITICAL') {
735
+ crit(
736
+ 'E1',
737
+ 'Dangerous shell command (in code block)',
738
+ line.trim(),
739
+ 'Remove or rewrite; never embed destructive commands in a skill',
740
+ )
741
+ } else {
742
+ warn(
743
+ 'HIGH',
744
+ 'E1',
745
+ 'Dangerous shell command (in code block)',
746
+ line.trim(),
747
+ 'Remove or rewrite; never embed destructive commands in a skill',
748
+ )
749
+ }
750
+ break
751
+ }
752
+
753
+ for (const pat of E2_PATTERNS) {
754
+ const hit = stripped.split('\n').find((l) => pat.test(l))
755
+ if (hit) {
756
+ crit(
757
+ 'E2',
758
+ 'Prompt injection pattern',
759
+ hit.trim(),
760
+ 'Remove prompt-injection content; treat skill body as untrusted data',
761
+ )
762
+ break
763
+ }
764
+ }
765
+
766
+ if (invisibleInSkill) {
767
+ crit(
768
+ 'E9',
769
+ 'Invisible Unicode control character',
770
+ `SKILL.md:${invisibleInSkill.line}:${invisibleInSkill.column} U+${invisibleInSkill.codePoint.toString(16).toUpperCase().padStart(4, '0')} ${invisibleInSkill.name}`,
771
+ 'Remove the hidden character or replace it with visible ASCII text',
772
+ )
773
+ }
774
+
775
+ const e6Hit = stripped.split('\n').find((l) => /git push.*(--force|-f )/.test(l) && /(main|master)/.test(l))
776
+ if (e6Hit) {
777
+ warn(
778
+ 'HIGH',
779
+ 'E6',
780
+ 'Silent permission escalation: force-push to main/master',
781
+ e6Hit.trim(),
782
+ 'Add an explicit user-confirmation step before the force-push',
783
+ )
784
+ }
785
+
786
+ if (hasScripts) {
787
+ for (const f of fs.readdirSync(scriptsDir).filter((name) => !name.startsWith('.'))) {
788
+ const src = fs.readFileSync(path.join(scriptsDir, f), 'utf8')
789
+ const invisibleInScript = findInvisibleUnicode(src)
790
+ if (invisibleInScript) {
791
+ crit(
792
+ 'E9',
793
+ 'Invisible Unicode control character',
794
+ `scripts/${f}:${invisibleInScript.line}:${invisibleInScript.column} U+${invisibleInScript.codePoint.toString(16).toUpperCase().padStart(4, '0')} ${invisibleInScript.name}`,
795
+ 'Remove the hidden character or replace it with visible ASCII text',
796
+ )
797
+ }
798
+ }
799
+ }
800
+
801
+ const manifest = readSkillManifest(skillDir)
802
+ if (manifest !== null) {
803
+ const dist = manifest.distribution
804
+ if (dist !== undefined) {
805
+ const knownInstallVia = ['package_manager']
806
+ if (!dist.install_via || !knownInstallVia.includes(dist.install_via)) {
807
+ crit(
808
+ 'S6',
809
+ 'skill.json: invalid distribution.install_via',
810
+ `install_via: ${dist.install_via ?? '(missing)'}`,
811
+ `Set distribution.install_via to one of: ${knownInstallVia.join(', ')}`,
812
+ )
813
+ } else if (dist.install_via === 'package_manager' && !dist.package?.name) {
814
+ crit(
815
+ 'S6',
816
+ 'skill.json: distribution.package.name required when install_via is package_manager',
817
+ 'distribution.package.name is missing or empty',
818
+ 'Add distribution.package.name with the npm package name that provides the skill binary',
819
+ )
820
+ }
821
+ }
822
+ }
823
+
824
+ // S7: Non-standard directories
825
+ try {
826
+ const entries = fs.readdirSync(skillDir, { withFileTypes: true })
827
+ const nonStandardDirs = entries
828
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !ALLOWED_SKILL_SUBDIRS.has(e.name))
829
+ .map((e) => e.name)
830
+ if (nonStandardDirs.length > 0) {
831
+ warn(
832
+ 'MEDIUM',
833
+ 'S7',
834
+ 'Non-standard directory in skill folder',
835
+ `found: ${nonStandardDirs.join(', ')}`,
836
+ 'Move contents to scripts/, references/, or assets/ per agentskills.io spec',
837
+ )
838
+ }
839
+
840
+ // S8: Static resources in root
841
+ const staticInRoot = entries
842
+ .filter((e) => {
843
+ if (!e.isFile()) return false
844
+ const name = e.name
845
+ const ext = path.extname(name).toLowerCase()
846
+ // Exclude skill.json from data file check
847
+ if (name === 'skill.json') return false
848
+ // Check for template patterns
849
+ if (TEMPLATE_PATTERNS.some((pat) => pat.test(name))) return true
850
+ // Check for static resource extensions
851
+ return STATIC_RESOURCE_EXTENSIONS.has(ext)
852
+ })
853
+ .map((e) => e.name)
854
+ if (staticInRoot.length > 0) {
855
+ warn(
856
+ 'LOW',
857
+ 'S8',
858
+ 'Static resources in skill root instead of assets/',
859
+ `found: ${staticInRoot.join(', ')}`,
860
+ 'Move to assets/ per agentskills.io spec',
861
+ )
862
+ }
863
+
864
+ // S9: Extra documentation in root
865
+ const extraDocs = entries
866
+ .filter((e) => {
867
+ if (!e.isFile()) return false
868
+ const name = e.name.toLowerCase()
869
+ if (!name.endsWith('.md')) return false
870
+ // SKILL.md and README.md are allowed in root
871
+ if (name === 'skill.md' || name === 'readme.md') return false
872
+ // Template files belong in assets/ (S8), not references/
873
+ if (TEMPLATE_PATTERNS.some((pat) => pat.test(name))) return false
874
+ return true
875
+ })
876
+ .map((e) => e.name)
877
+ if (extraDocs.length > 0) {
878
+ warn(
879
+ 'LOW',
880
+ 'S9',
881
+ 'Extra documentation files in skill root instead of references/',
882
+ `found: ${extraDocs.join(', ')}`,
883
+ 'Move to references/ with explicit load conditions per agentskills.io spec',
884
+ )
885
+ }
886
+ } catch {
887
+ // If we can't read the skill directory, skip S7-S9 checks
888
+ }
889
+
890
+ return { criticals, warnings }
891
+ }
892
+
893
+ // ── CLI: scan resolution, report, exit code ──
894
+
895
+ function flag(argv: string[], name: string): string | undefined {
896
+ const i = argv.indexOf(name)
897
+ return i === -1 ? undefined : argv[i + 1]
898
+ }
899
+
900
+ // Every value passed for a repeatable flag (e.g. --dir <glob> --dir <glob>).
901
+ function flagAll(argv: string[], name: string): string[] {
902
+ const out: string[] = []
903
+ for (let i = 0; i < argv.length; i++) {
904
+ if (argv[i] === name && argv[i + 1] !== undefined) out.push(argv[i + 1]!)
905
+ }
906
+ return out
907
+ }
908
+
909
+ export interface ScanOutcome {
910
+ ok: boolean
911
+ exitCode: number
912
+ message?: string
913
+ results: Array<{ filePath: string; dirName: string; criticals: Finding[]; warnings: Finding[] }>
914
+ }
915
+
916
+ // Resolve --path (single skill dir or SKILL.md file) or the whole-project scan (SKILL_DIRS),
917
+ // run the mechanical check subset over each resolved SKILL.md, and roll up the exit code:
918
+ // a --path target with no SKILL.md errors non-zero; a project scan with zero SKILL.md files
919
+ // exits zero; any CRITICAL finding anywhere exits non-zero; otherwise exits zero.
920
+ export function scan(cwd: string, pathArg?: string, dirGlobs: string[] = []): ScanOutcome {
921
+ let skillFiles: string[]
922
+
923
+ if (pathArg) {
924
+ const resolved = path.resolve(cwd, pathArg)
925
+ const skillMd = resolved.endsWith('SKILL.md') ? resolved : path.join(resolved, 'SKILL.md')
926
+ if (!fs.existsSync(skillMd)) {
927
+ return { ok: false, exitCode: 1, message: `No SKILL.md found at ${skillMd}`, results: [] }
928
+ }
929
+ skillFiles = [skillMd]
930
+ } else {
931
+ skillFiles = findSkillFiles(resolveScanDirs(cwd, dirGlobs), cwd)
932
+ }
933
+
934
+ if (skillFiles.length === 0) {
935
+ return { ok: true, exitCode: 0, message: 'No SKILL.md files found.', results: [] }
936
+ }
937
+
938
+ const scanRoots = recognizedScanRoots(cwd, dirGlobs)
939
+ const results = skillFiles.map((filePath) => ({
940
+ filePath,
941
+ dirName: path.basename(path.dirname(filePath)),
942
+ ...runChecks(filePath, scanRoots),
943
+ }))
944
+
945
+ const totalCriticals = results.reduce((n, r) => n + r.criticals.length, 0)
946
+ return { ok: totalCriticals === 0, exitCode: totalCriticals === 0 ? 0 : 1, results }
947
+ }
948
+
949
+ function printFinding(w: (s: string) => void, f: Finding): void {
950
+ const icon = f.severity === 'CRITICAL' ? '❌' : '⚠️ '
951
+ w(` ${icon} [${f.severity}] ${f.checkId} — ${f.name}`)
952
+ w(` Evidence: ${f.evidence}`)
953
+ w(` Fix: ${f.fix}`)
954
+ }
955
+
956
+ function printReport(w: (s: string) => void, outcome: ScanOutcome): void {
957
+ if (outcome.results.length === 0) {
958
+ w(outcome.message ?? '')
959
+ return
960
+ }
961
+
962
+ w(`Validating ${outcome.results.length} skill(s)…`)
963
+
964
+ let totalCriticals = 0
965
+ let totalWarnings = 0
966
+
967
+ for (const { dirName, criticals, warnings } of outcome.results) {
968
+ w(`\n── ${dirName} ─────────────────────────`)
969
+ totalCriticals += criticals.length
970
+ totalWarnings += warnings.length
971
+
972
+ for (const f of criticals) printFinding(w, f)
973
+ for (const f of warnings) printFinding(w, f)
974
+
975
+ if (criticals.length === 0) {
976
+ w(' ✅ no CRITICAL findings')
977
+ } else {
978
+ w(' 🚨 DO NOT commit or install until all CRITICAL findings are resolved.')
979
+ }
980
+ }
981
+
982
+ w('\n══════════════════════════════════════')
983
+ w(`Results: ${totalCriticals} critical failure(s), ${totalWarnings} warning(s)`)
984
+
985
+ if (totalCriticals > 0) {
986
+ w('❌ Fix all CRITICAL findings before merging.')
987
+ } else {
988
+ w('✅ All checks passed (S1–S9, Q2–Q5, Q10–Q11, Q17, Q18, E1–E2, E6, E9).')
989
+ w(' Run the improve-skill agent skill for full quality review (Q1, Q6–Q16, E3–E5, E7–E8, P1–P3).')
990
+ }
991
+ }
992
+
993
+ const HELP = `usage: validate.mts [--path <path>] [--dir <glob>]... [--root <path>] [--format text|json]
994
+
995
+ Validate skills against the mechanical check subset (S1-S9, Q2-Q5, Q10-Q11, Q17, Q18, E1-E2, E6, E9).
996
+
997
+ --path <path> validate a single skill directory or SKILL.md file (default: whole-project scan)
998
+ --dir <glob> add a one-off scan location for this run (repeatable; ignored when --path is set)
999
+ --root <path> repo root to scan from (default: cwd)
1000
+ --format <fmt> text (default) or json
1001
+ --help show this message
1002
+ `
1003
+
1004
+ export function main(argv: string[]): number {
1005
+ if (argv.includes('--help') || argv.includes('-h')) {
1006
+ process.stdout.write(HELP)
1007
+ return 0
1008
+ }
1009
+
1010
+ const cwd = path.resolve(flag(argv, '--root') ?? '.')
1011
+ const pathArg = flag(argv, '--path')
1012
+ const dirGlobs = flagAll(argv, '--dir')
1013
+ const format = flag(argv, '--format') ?? (argv.includes('--json') ? 'json' : 'text')
1014
+
1015
+ const outcome = scan(cwd, pathArg, dirGlobs)
1016
+
1017
+ if (format === 'json') {
1018
+ process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`)
1019
+ return outcome.exitCode
1020
+ }
1021
+
1022
+ if (!outcome.ok && outcome.results.length === 0) {
1023
+ process.stderr.write(`${outcome.message}\n`)
1024
+ return outcome.exitCode
1025
+ }
1026
+
1027
+ printReport((s) => process.stdout.write(`${s}\n`), outcome)
1028
+ return outcome.exitCode
1029
+ }
1030
+
1031
+ if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) {
1032
+ process.exit(main(process.argv.slice(2)))
1033
+ }