@softize/opus 11.1.1 → 12.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 (41) hide show
  1. package/CHANGELOG.md +51 -1
  2. package/README.md +31 -1
  3. package/bin/cli.mjs +97 -27
  4. package/bin/lib/check.mjs +55 -43
  5. package/bin/lib/copy.mjs +2202 -0
  6. package/bin/lib/create.mjs +227 -39
  7. package/bin/lib/db-migrate-runner.mjs +9 -6
  8. package/bin/lib/db-project-path.mjs +20 -0
  9. package/bin/lib/db-scaffold-runner.mjs +23 -8
  10. package/bin/lib/db.mjs +6 -4
  11. package/bin/lib/gen.mjs +60 -29
  12. package/bin/lib/init.mjs +212 -56
  13. package/bin/lib/introspect.mjs +3 -2
  14. package/bin/lib/materialize.mjs +623 -97
  15. package/bin/lib/postinstall.mjs +6 -5
  16. package/bin/lib/validate-skill.mjs +502 -30
  17. package/docs/code-style.md +142 -7
  18. package/docs/consumer-upgrade-propagation.md +4 -3
  19. package/docs/releasing.md +28 -17
  20. package/package.json +6 -1
  21. package/registry/git/pre-push.d/00-opus-copy +14 -0
  22. package/registry/git/pre-push.d/opus +7 -21
  23. package/registry/git/run-opus-pre-push.mjs +141 -0
  24. package/registry/hooks/opus-check-on-stop.mjs +13 -31
  25. package/registry/instructions/opus.md +11 -5
  26. package/registry/skills/build-opus-ui/SKILL.md +5 -4
  27. package/registry/skills/create-opus-action/SKILL.md +4 -4
  28. package/registry/skills/implement-opus-change/SKILL.md +7 -5
  29. package/registry/skills/upgrade-opus/SKILL.md +8 -4
  30. package/registry/skills/upgrade-opus/references/upgrade-checklist.md +4 -1
  31. package/registry/templates/app/package.json +4 -0
  32. package/registry/templates/app/pnpm-workspace.yaml +3 -2
  33. package/registry/templates/app/src/domains/tasks/actions/list.ts +1 -1
  34. package/registry/templates/monorepo/pnpm-workspace.yaml +3 -1
  35. package/src/ui/docs/content/cli.md +8 -7
  36. package/src/ui/docs/content/communication.md +79 -126
  37. package/src/ui/docs/content/getting-started.md +29 -16
  38. package/registry/skills/write-product-communication/SKILL.md +0 -28
  39. package/registry/skills/write-product-communication/agents/openai.yaml +0 -4
  40. package/registry/templates/app/_npmrc +0 -1
  41. package/registry/templates/monorepo/_npmrc +0 -1
@@ -14,9 +14,9 @@
14
14
  * ["@softize/opus"]`) ou aprovar. Sob npm roda direto.
15
15
  */
16
16
 
17
- import { promises as fs } from 'node:fs'
18
17
  import path from 'node:path'
19
18
  import { fileURLToPath } from 'node:url'
19
+ import { canonicalProjectDirectory, readProjectFile } from '@softize/base/project-path'
20
20
 
21
21
  import { initProject } from './init.mjs'
22
22
 
@@ -26,19 +26,20 @@ const REGISTRY_DIR = path.join(PACKAGE_ROOT, 'registry')
26
26
  async function main() {
27
27
  if (process.env.OPUS_SKIP_INIT) return
28
28
 
29
- const projectDir = process.env.INIT_CWD || process.cwd()
29
+ const requestedProjectDir = process.env.INIT_CWD || process.cwd()
30
+ const projectDir = canonicalProjectDirectory(requestedProjectDir)
30
31
  // Não inicializa o próprio Opus.
31
32
  if (projectDir === PACKAGE_ROOT || projectDir.startsWith(PACKAGE_ROOT + path.sep)) return
32
33
 
33
34
  let pkg
34
35
  try {
35
- pkg = JSON.parse(await fs.readFile(path.join(projectDir, 'package.json'), 'utf-8'))
36
+ pkg = JSON.parse(readProjectFile(projectDir, 'package.json').content)
36
37
  } catch {
37
38
  return // Sem package.json → não é projeto.
38
39
  }
39
40
  if (pkg.name === '@softize/opus') return
40
41
  const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }
41
- if (!deps['@softize/opus']) return // Não é consumidor da base.
42
+ if (!deps['@softize/opus']) return // Não é consumidor do Opus.
42
43
 
43
44
  const r = await initProject(REGISTRY_DIR, projectDir)
44
45
  if (!r.materialization.ok) {
@@ -47,7 +48,7 @@ async function main() {
47
48
  }
48
49
  const n = r.created.length + r.synced.length
49
50
  console.log(
50
- `\x1b[36m[opus]\x1b[0m base v${r.version} — ${r.wasInitialized ? 'sincronizada' : 'inicializada'}` +
51
+ `\x1b[36m[opus]\x1b[0m Opus v${r.version} — ${r.wasInitialized ? 'sincronizado' : 'inicializado'}` +
51
52
  (n ? ` (${n} arquivo(s))` : '') +
52
53
  `. Veja base.json / opus.json.`,
53
54
  )
@@ -1,40 +1,512 @@
1
- import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
- import { basename, join } from 'node:path'
1
+ import { closeSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync } from 'node:fs'
2
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
3
+ import MarkdownIt from 'markdown-it'
4
+ import parseHtmlInline from 'markdown-it/lib/rules_inline/html_inline.mjs'
3
5
 
4
- const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
5
6
  const PROHIBITED = new Set(['README.md', 'CHANGELOG.md', 'INSTALLATION_GUIDE.md', 'QUICK_REFERENCE.md'])
7
+ const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
8
+ const ROUTE = /^\s*<!--\s+softize-skill-route:\s+\$([a-z0-9]+(?:-[a-z0-9]+)*)\s+-->\s*$/
9
+ const ROUTE_ATTEMPT = /softize-skill-\s*route\b/gu
10
+ const URI_SCHEME = /^[a-z][a-z0-9+.-]*:/iu
11
+ const DRIVE_DESIGNATOR = /^[a-z]:/iu
12
+ const WINDOWS_ROOTED = /^(?:\\|%5c)/iu
13
+ const UNC_PATH = /^(?:\\\\|\/\/)/u
14
+ const ENCODED_SEPARATOR_OR_NUL = /%(?:00|2f|5c)/iu
15
+ const MARKDOWN = new MarkdownIt({ html: true })
16
+ const NATIVE_PATH = { isAbsolute, relative, sep }
17
+ const markdownItValidateLink = MARKDOWN.validateLink.bind(MARKDOWN)
18
+ MARKDOWN.validateLink = (target) => /^file:/iu.test(target) || markdownItValidateLink(target)
19
+ MARKDOWN.inline.ruler.at('html_inline', (state, silent) => {
20
+ const sourceStart = state.pos
21
+ const matched = parseHtmlInline(state, silent)
22
+ if (matched && !silent) {
23
+ const token = state.tokens.at(-1)
24
+ if (token?.type === 'html_inline') token.meta = { ...token.meta, sourceStart }
25
+ }
26
+ return matched
27
+ })
28
+
29
+ function markdownDocument(text) {
30
+ const lines = text.split(/\r?\n/u)
31
+ const possibleFrontmatterEnd = lines[0] === '---' ? lines.indexOf('---', 1) : -1
32
+ const frontmatterEnd =
33
+ possibleFrontmatterEnd > 1 && lines.slice(1, possibleFrontmatterEnd).some((line) => /^[a-zA-Z0-9_-]+:\s*/u.test(line))
34
+ ? possibleFrontmatterEnd
35
+ : -1
36
+ const lineOffset = frontmatterEnd + 1
37
+ const env = {}
38
+ return {
39
+ env,
40
+ lines,
41
+ lineOffset,
42
+ tokens: MARKDOWN.parse(lines.slice(lineOffset).join('\n'), env),
43
+ }
44
+ }
45
+
46
+ function lineAt(content, offset, firstLine) {
47
+ let line = firstLine
48
+ for (let index = 0; index < offset; index += 1) {
49
+ if (content[index] === '\n') line += 1
50
+ }
51
+ return line
52
+ }
53
+
54
+ function attemptLines(content, firstLine) {
55
+ return [...content.matchAll(ROUTE_ATTEMPT)].map((match) => lineAt(content, match.index, firstLine))
56
+ }
57
+
58
+ function analyzeRoutes(text) {
59
+ const { lines, lineOffset, tokens } = markdownDocument(text)
60
+ const references = []
61
+ const errors = new Set()
62
+ const operational = new Set()
63
+
64
+ for (const token of tokens) {
65
+ if (token.map === null) continue
66
+ const firstLine = lineOffset + token.map[0]
67
+ const lastLine = lineOffset + token.map[1]
68
+
69
+ if (token.type === 'html_block') {
70
+ const route = lastLine === firstLine + 1 ? ROUTE.exec(token.content) : null
71
+ const attempts = attemptLines(token.content, firstLine)
72
+ if (route !== null) {
73
+ references.push(route[1])
74
+ operational.add(firstLine)
75
+ } else {
76
+ for (const line of attempts) errors.add(line)
77
+ if (lastLine === firstLine + 1) operational.add(firstLine)
78
+ }
79
+ continue
80
+ }
6
81
 
7
- function frontmatter(content) {
8
- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(content)
82
+ if (token.type !== 'inline') continue
83
+ for (const child of token.children ?? []) {
84
+ if (child.type !== 'html_inline') continue
85
+ const childLine = lineAt(token.content, child.meta.sourceStart, firstLine)
86
+ for (let line = childLine; line <= childLine + child.content.split('\n').length - 1; line += 1) {
87
+ operational.add(line)
88
+ }
89
+ for (const line of attemptLines(child.content, childLine)) errors.add(line)
90
+ }
91
+ }
92
+
93
+ return { errors: [...errors].sort((left, right) => left - right), lines, operational, references }
94
+ }
95
+
96
+ export function operationalMarkdownLines(text) {
97
+ const analysis = analyzeRoutes(text)
98
+ return analysis.lines.map((line, index) => ({ line, index, operational: analysis.operational.has(index) }))
99
+ }
100
+
101
+ function frontmatter(text) {
102
+ const match = /^---\n([\s\S]*?)\n---\n/.exec(text)
9
103
  if (match === null) return null
10
- const entries = match[1].split(/\r?\n/).filter(Boolean).map((line) => {
11
- const at = line.indexOf(':')
12
- return at === -1 ? [line, ''] : [line.slice(0, at).trim(), line.slice(at + 1).trim()]
13
- })
14
- return Object.fromEntries(entries)
104
+ const fields = new Map()
105
+ for (const line of match[1].split('\n')) {
106
+ const field = /^([a-zA-Z0-9_-]+):\s*(.*)$/.exec(line)
107
+ if (field !== null) fields.set(field[1], field[2].replace(/^['"]|['"]$/g, '').trim())
108
+ }
109
+ return fields
110
+ }
111
+
112
+ export function referencedSkills(text) {
113
+ return [...new Set(analyzeRoutes(text).references)]
114
+ }
115
+
116
+ export function skillRouteErrors(text) {
117
+ return analyzeRoutes(text).errors.map((line) => `encaminhamento operacional malformado na linha ${line + 1}.`)
118
+ }
119
+
120
+ export function isPathInside(root, target, pathApi = NATIVE_PATH) {
121
+ const path = pathApi.relative(root, target)
122
+ return path === '' || (path !== '..' && !path.startsWith(`..${pathApi.sep}`) && !pathApi.isAbsolute(path))
123
+ }
124
+
125
+ function referenceTargetKind(target) {
126
+ if (DRIVE_DESIGNATOR.test(target) || WINDOWS_ROOTED.test(target) || UNC_PATH.test(target)) {
127
+ return 'absolute-filesystem'
128
+ }
129
+ if (/^file:/iu.test(target)) return 'unsupported-file-url'
130
+ if (URI_SCHEME.test(target)) return 'external-url'
131
+ return 'local'
132
+ }
133
+
134
+ function sameIdentity(left, right) {
135
+ return left.dev === right.dev && left.ino === right.ino
136
+ }
137
+
138
+ function markdownResourceError(relativePath) {
139
+ return `${relativePath}: recurso Markdown deve ser arquivo regular e contido na skill.`
140
+ }
141
+
142
+ function entryExists(path) {
143
+ try {
144
+ lstatSync(path)
145
+ return true
146
+ } catch {
147
+ return false
148
+ }
149
+ }
150
+
151
+ function inspectRegularResource(root, canonicalRoot, path, error) {
152
+ try {
153
+ const info = lstatSync(path)
154
+ const canonical = realpathSync(path)
155
+ if (
156
+ !info.isFile() ||
157
+ info.isSymbolicLink() ||
158
+ !isPathInside(root, path) ||
159
+ !isPathInside(canonicalRoot, canonical)
160
+ ) {
161
+ return { error }
162
+ }
163
+ return { canonical, info }
164
+ } catch {
165
+ return { error }
166
+ }
167
+ }
168
+
169
+ function inspectMarkdownResource(root, canonicalRoot, path, relativePath) {
170
+ const inspected = inspectRegularResource(root, canonicalRoot, path, markdownResourceError(relativePath))
171
+ if (inspected.error !== undefined) return inspected
172
+ return { resource: { path, relative: relativePath } }
173
+ }
174
+
175
+ function inspectReferenceDirectory(root, canonicalRoot, path, relativePath) {
176
+ try {
177
+ const info = lstatSync(path)
178
+ const canonical = realpathSync(path)
179
+ if (
180
+ !info.isDirectory() ||
181
+ info.isSymbolicLink() ||
182
+ !isPathInside(root, path) ||
183
+ !isPathInside(canonicalRoot, canonical)
184
+ ) {
185
+ return { error: `${relativePath}: diretório de referências deve ser real e contido na skill.` }
186
+ }
187
+ return { canonical, info }
188
+ } catch {
189
+ return { error: `${relativePath}: diretório de referências deve ser real e contido na skill.` }
190
+ }
191
+ }
192
+
193
+ function collectReferenceResources(root, canonicalRoot, current, resources, errors) {
194
+ const currentRelative = relative(root, current)
195
+ const before = inspectReferenceDirectory(root, canonicalRoot, current, currentRelative)
196
+ if (before.error !== undefined) {
197
+ errors.push(before.error)
198
+ return
199
+ }
200
+
201
+ let entries
202
+ try {
203
+ entries = readdirSync(current, { withFileTypes: true })
204
+ } catch {
205
+ errors.push(`${currentRelative}: diretório de referências não pôde ser lido com segurança.`)
206
+ return
207
+ }
208
+
209
+ const after = inspectReferenceDirectory(root, canonicalRoot, current, currentRelative)
210
+ if (
211
+ after.error !== undefined ||
212
+ !sameIdentity(before.info, after.info) ||
213
+ before.canonical !== after.canonical
214
+ ) {
215
+ errors.push(after.error ?? `${currentRelative}: diretório de referências mudou durante a leitura.`)
216
+ return
217
+ }
218
+
219
+ for (const entry of entries) {
220
+ const path = join(current, entry.name)
221
+ const relativePath = relative(root, path)
222
+ if (entry.isSymbolicLink()) {
223
+ errors.push(
224
+ extname(entry.name) === '.md'
225
+ ? markdownResourceError(relativePath)
226
+ : `${relativePath}: entrada simbólica não é permitida em references.`,
227
+ )
228
+ } else if (entry.isDirectory()) {
229
+ collectReferenceResources(root, canonicalRoot, path, resources, errors)
230
+ } else if (extname(entry.name) === '.md') {
231
+ const inspected = inspectMarkdownResource(root, canonicalRoot, path, relativePath)
232
+ if (inspected.error !== undefined) errors.push(inspected.error)
233
+ else resources.push(inspected.resource)
234
+ }
235
+ }
15
236
  }
16
237
 
17
- export function validateSkill(root) {
238
+ function markdownResources(root) {
239
+ const resources = []
18
240
  const errors = []
19
- const slug = basename(root)
20
- const path = join(root, 'SKILL.md')
21
- if (!existsSync(path)) return ['SKILL.md ausente.']
22
- const content = readFileSync(path, 'utf8')
23
- const metadata = frontmatter(content)
24
- if (metadata === null) errors.push('frontmatter ausente ou inválido.')
241
+ let canonicalRoot
242
+ try {
243
+ const rootInfo = lstatSync(root)
244
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
245
+ return { errors: ['raiz da skill deve ser um diretório real.'], resources }
246
+ }
247
+ canonicalRoot = realpathSync(root)
248
+ } catch {
249
+ return { errors: ['raiz da skill deve ser um diretório real.'], resources }
250
+ }
251
+
252
+ const skill = join(root, 'SKILL.md')
253
+ if (!entryExists(skill)) errors.push('SKILL.md ausente.')
25
254
  else {
26
- if (Object.keys(metadata).sort().join(',') !== 'description,name') errors.push('frontmatter deve conter somente name e description.')
27
- if (metadata.name !== slug) errors.push(`name deve ser igual à pasta (${slug}).`)
28
- if (!NAME.test(metadata.name ?? '') || slug.length > 64) errors.push('name deve usar lowercase kebab-case e no máximo 64 caracteres.')
29
- if (!/(use|usar|quando)/i.test(metadata.description ?? '')) errors.push('description deve declarar quando usar a skill.')
30
- }
31
- if (content.split(/\r?\n/).length > 500) errors.push('SKILL.md excede 500 linhas.')
32
- if (/\bTODO\b|\bTBD\b|\[placeholder\]/i.test(content)) errors.push('skill contém placeholder.')
33
- for (const name of readdirSync(root)) if (PROHIBITED.has(name)) errors.push(`${name} não pertence a uma skill.`)
34
- const links = [...content.matchAll(/\[[^\]]+\]\(([^)]+)\)/g)].map((match) => match[1]).filter((link) => !/^[a-z]+:|^#/.test(link))
35
- for (const link of links) if (!existsSync(join(root, link))) errors.push(`recurso inexistente: ${link}.`)
36
- const ui = join(root, 'agents/openai.yaml')
37
- if (!existsSync(ui)) errors.push('agents/openai.yaml ausente.')
38
- else if (!readFileSync(ui, 'utf8').includes(`$${slug}`)) errors.push(`default_prompt deve mencionar $${slug}.`)
255
+ const inspected = inspectMarkdownResource(root, canonicalRoot, skill, 'SKILL.md')
256
+ if (inspected.error !== undefined) errors.push(inspected.error)
257
+ else resources.push(inspected.resource)
258
+ }
259
+
260
+ const references = join(root, 'references')
261
+ if (entryExists(references)) collectReferenceResources(root, canonicalRoot, references, resources, errors)
262
+ return { canonicalRoot, errors, resources }
263
+ }
264
+
265
+ function readRegularResource(root, canonicalRoot, resource, error) {
266
+ let descriptor
267
+ try {
268
+ const before = lstatSync(resource.path)
269
+ const canonicalBefore = realpathSync(resource.path)
270
+ if (
271
+ !before.isFile() ||
272
+ before.isSymbolicLink() ||
273
+ !isPathInside(root, resource.path) ||
274
+ !isPathInside(canonicalRoot, canonicalBefore)
275
+ ) {
276
+ return { error }
277
+ }
278
+
279
+ descriptor = openSync(resource.path, 'r')
280
+ const opened = fstatSync(descriptor)
281
+ if (!opened.isFile() || !sameIdentity(before, opened)) {
282
+ return { error }
283
+ }
284
+ const text = readFileSync(descriptor, 'utf8')
285
+ const after = fstatSync(descriptor)
286
+ const atPath = lstatSync(resource.path)
287
+ const canonicalAfter = realpathSync(resource.path)
288
+ if (
289
+ !after.isFile() ||
290
+ atPath.isSymbolicLink() ||
291
+ !sameIdentity(opened, after) ||
292
+ !sameIdentity(opened, atPath) ||
293
+ opened.size !== after.size ||
294
+ opened.mtimeMs !== after.mtimeMs ||
295
+ canonicalBefore !== canonicalAfter ||
296
+ !isPathInside(canonicalRoot, canonicalAfter)
297
+ ) {
298
+ return { error }
299
+ }
300
+ return { text }
301
+ } catch {
302
+ return { error }
303
+ } finally {
304
+ if (descriptor !== undefined) closeSync(descriptor)
305
+ }
306
+ }
307
+
308
+ function readMarkdownResource(root, canonicalRoot, resource) {
309
+ return readRegularResource(root, canonicalRoot, resource, markdownResourceError(resource.relative))
310
+ }
311
+
312
+ function readOpenaiMetadata(root, canonicalRoot) {
313
+ if (canonicalRoot === undefined) return {}
314
+ const agents = join(root, 'agents')
315
+ if (!entryExists(agents)) return { error: 'agents/openai.yaml ausente.' }
316
+ const directoryError = 'agents: diretório deve ser real e contido na skill.'
317
+ const before = inspectReferenceDirectory(root, canonicalRoot, agents, 'agents')
318
+ if (before.error !== undefined) return { error: directoryError }
319
+
320
+ const openai = join(agents, 'openai.yaml')
321
+ if (!entryExists(openai)) return { error: 'agents/openai.yaml ausente.' }
322
+ const fileError = 'agents/openai.yaml deve ser arquivo regular e contido na skill.'
323
+ const inspected = inspectRegularResource(root, canonicalRoot, openai, fileError)
324
+ if (inspected.error !== undefined) return inspected
325
+ const read = readRegularResource(root, canonicalRoot, { path: openai }, fileError)
326
+ if (read.error !== undefined) return read
327
+
328
+ const after = inspectReferenceDirectory(root, canonicalRoot, agents, 'agents')
329
+ if (
330
+ after.error !== undefined ||
331
+ !sameIdentity(before.info, after.info) ||
332
+ before.canonical !== after.canonical
333
+ ) {
334
+ return { error: directoryError }
335
+ }
336
+ return { text: read.text }
337
+ }
338
+
339
+ function readMarkdownResources(root, inventory) {
340
+ const errors = [...inventory.errors]
341
+ const resources = []
342
+ if (inventory.canonicalRoot === undefined) return { errors, resources }
343
+ for (const resource of inventory.resources) {
344
+ const read = readMarkdownResource(root, inventory.canonicalRoot, resource)
345
+ if (read.error !== undefined) errors.push(read.error)
346
+ else resources.push({ ...resource, text: read.text })
347
+ }
348
+ return { errors, resources }
349
+ }
350
+
351
+ function htmlWhitespace(code) {
352
+ return code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d || code === 0x20
353
+ }
354
+
355
+ function containsOnlyHtmlComments(content) {
356
+ let cursor = 0
357
+ let comments = 0
358
+
359
+ while (cursor < content.length) {
360
+ while (cursor < content.length && htmlWhitespace(content.charCodeAt(cursor))) cursor += 1
361
+ if (cursor === content.length) break
362
+ if (!content.startsWith('<!--', cursor)) return false
363
+ const end = content.indexOf('-->', cursor + 4)
364
+ if (end === -1) return false
365
+ comments += 1
366
+ cursor = end + 3
367
+ }
368
+
369
+ return comments > 0
370
+ }
371
+
372
+ function markdownLinks(text) {
373
+ const { env, tokens } = markdownDocument(text)
374
+ const targets = new Set()
375
+ const errors = new Set()
376
+ const visit = (token) => {
377
+ if (token.type === 'link_open') targets.add(token.attrGet('href'))
378
+ else if (token.type === 'image') targets.add(token.attrGet('src'))
379
+ else if (
380
+ (token.type === 'html_inline' || token.type === 'html_block') &&
381
+ !containsOnlyHtmlComments(token.content)
382
+ ) errors.add('HTML ou SVG cru renderizável não é permitido; use Markdown ou um recurso referenciado.')
383
+ for (const child of token.children ?? []) visit(child)
384
+ }
385
+ for (const token of tokens) visit(token)
386
+ for (const reference of Object.values(env.references ?? {})) targets.add(reference.href)
387
+ return {
388
+ errors: [...errors],
389
+ targets: [...targets].filter((target) => typeof target === 'string'),
390
+ }
391
+ }
392
+
393
+ function validLinkTarget(root, source, renderedTarget) {
394
+ if (
395
+ renderedTarget.trim() !== renderedTarget ||
396
+ /[\u0000-\u001f\u007f]/u.test(renderedTarget) ||
397
+ !markdownItValidateLink(renderedTarget)
398
+ ) {
399
+ return false
400
+ }
401
+ const kind = referenceTargetKind(renderedTarget)
402
+ if (kind === 'external-url') return true
403
+ if (kind !== 'local') return false
404
+ const end = renderedTarget.search(/[?#]/u)
405
+ const encodedPath = end === -1 ? renderedTarget : renderedTarget.slice(0, end)
406
+ if (encodedPath === '') return true
407
+ if (encodedPath.includes('\\') || ENCODED_SEPARATOR_OR_NUL.test(encodedPath)) return false
408
+ let pathname
409
+ try {
410
+ pathname = decodeURIComponent(encodedPath)
411
+ } catch {
412
+ return false
413
+ }
414
+ if (pathname.includes('\0') || referenceTargetKind(pathname) !== 'local') return false
415
+ const path = resolve(dirname(source), pathname)
416
+ if (!isPathInside(root, path) || !existsSync(path)) return false
417
+ try {
418
+ return isPathInside(realpathSync(root), realpathSync(path))
419
+ } catch {
420
+ return false
421
+ }
422
+ }
423
+
424
+ function printableLinkTarget(target) {
425
+ return target.replace(/[\u0000-\u001f\u007f]/gu, (character) =>
426
+ `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`,
427
+ )
428
+ }
429
+
430
+ export function skillReferences(dir) {
431
+ const root = resolve(dir)
432
+ const loaded = readMarkdownResources(root, markdownResources(root))
433
+ return loaded.resources
434
+ .filter((resource) => basename(resource.path) !== 'evaluations.md')
435
+ .flatMap((resource) =>
436
+ referencedSkills(resource.text).map((dependency) => ({
437
+ dependency,
438
+ source: resource.relative,
439
+ })),
440
+ )
441
+ }
442
+
443
+ export function validateSkill(dir) {
444
+ const root = resolve(dir)
445
+ const inventory = markdownResources(root)
446
+ const loaded = readMarkdownResources(root, inventory)
447
+ const openai = readOpenaiMetadata(root, inventory.canonicalRoot)
448
+ const errors = [...loaded.errors]
449
+ if (openai.error !== undefined) errors.push(openai.error)
450
+ if (inventory.canonicalRoot !== undefined) {
451
+ try {
452
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
453
+ if (PROHIBITED.has(entry.name)) errors.push(`${entry.name} não pertence a uma skill.`)
454
+ }
455
+ } catch {
456
+ errors.push('raiz da skill não pôde ser lida com segurança.')
457
+ }
458
+ }
459
+ const skillResource = loaded.resources.find((resource) => resource.relative === 'SKILL.md')
460
+ if (skillResource === undefined) return errors
461
+
462
+ const text = skillResource.text
463
+ const meta = frontmatter(text)
464
+ if (meta === null) errors.push('frontmatter YAML ausente ou inválido.')
465
+ else {
466
+ const keys = [...meta.keys()]
467
+ if (keys.length !== 2 || !meta.has('name') || !meta.has('description')) {
468
+ errors.push('frontmatter deve conter somente name e description.')
469
+ }
470
+ const name = meta.get('name') ?? ''
471
+ const description = meta.get('description') ?? ''
472
+ if (!NAME.test(name) || name.length > 64) errors.push('name deve ser lowercase kebab-case e ter até 64 caracteres.')
473
+ if (name !== basename(root)) errors.push(`name deve ser igual à pasta (${basename(root)}).`)
474
+ if (description.length === 0 || description.length > 1024) errors.push('description deve ter entre 1 e 1024 caracteres.')
475
+ if (!/\b(use|usar|quando|ao)\b/i.test(description)) errors.push('description deve declarar quando usar a skill.')
476
+ }
477
+ if (text.split('\n').length > 500) errors.push('SKILL.md deve ter no máximo 500 linhas.')
478
+ if (/\b(TODO|PLACEHOLDER)\b/.test(text)) errors.push('SKILL.md contém placeholder.')
479
+
480
+ for (const resource of loaded.resources.filter((resource) => basename(resource.path) !== 'evaluations.md')) {
481
+ for (const error of skillRouteErrors(resource.text)) {
482
+ errors.push(`${resource.relative}: ${error}`)
483
+ }
484
+ }
485
+
486
+ if (openai.text !== undefined) {
487
+ const ui = openai.text
488
+ const name = meta?.get('name') ?? basename(root)
489
+ if (!ui.includes('display_name:') || !ui.includes('short_description:')) errors.push('openai.yaml sem metadados de interface.')
490
+ if (!ui.includes(`$${name}`)) errors.push(`default_prompt deve mencionar $${name}.`)
491
+ }
492
+
493
+ for (const resource of loaded.resources) {
494
+ const links = markdownLinks(resource.text)
495
+ for (const error of links.errors) errors.push(`${resource.relative}: ${error}`)
496
+ for (const target of links.targets) {
497
+ if (!validLinkTarget(root, resource.path, target)) {
498
+ errors.push(`${resource.relative}: referência local inválida: ${printableLinkTarget(target)}.`)
499
+ }
500
+ }
501
+ }
502
+
503
+ for (const resource of loaded.resources.filter((resource) => basename(resource.path) !== 'evaluations.md')) {
504
+ for (const dependency of referencedSkills(resource.text)) {
505
+ if (!existsSync(join(dirname(root), dependency, 'SKILL.md'))) {
506
+ errors.push(`${resource.relative}: skill encaminhada ausente: $${dependency}.`)
507
+ }
508
+ }
509
+ }
510
+
39
511
  return errors
40
512
  }