@softize/opus 8.8.1 → 8.9.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 (53) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +1 -1
  3. package/bin/cli.mjs +45 -15
  4. package/bin/lib/create.mjs +5 -5
  5. package/bin/lib/init.mjs +20 -228
  6. package/bin/lib/materialize.mjs +273 -0
  7. package/bin/lib/mcp.mjs +3 -3
  8. package/bin/lib/postinstall.mjs +7 -4
  9. package/bin/lib/validate-skill.mjs +40 -0
  10. package/docs/code-style.md +4 -4
  11. package/docs/releasing.md +12 -14
  12. package/package.json +16 -14
  13. package/registry/git/pre-push +8 -0
  14. package/registry/git/pre-push.d/opus +9 -0
  15. package/registry/github/opus.yml +22 -0
  16. package/registry/instructions/opus.md +15 -0
  17. package/registry/skills/build-opus-ui/SKILL.md +39 -0
  18. package/registry/skills/build-opus-ui/agents/openai.yaml +4 -0
  19. package/registry/skills/build-opus-ui/references/evaluations.md +5 -0
  20. package/registry/skills/build-opus-ui/references/ui-patterns.md +8 -0
  21. package/registry/skills/create-opus-action/SKILL.md +43 -0
  22. package/registry/skills/create-opus-action/agents/openai.yaml +4 -0
  23. package/registry/skills/create-opus-action/references/contract-and-binding.md +12 -0
  24. package/registry/skills/create-opus-action/references/evaluations.md +5 -0
  25. package/registry/skills/create-opus-action/scripts/scaffold.mjs +100 -0
  26. package/registry/skills/implement-opus-change/SKILL.md +43 -0
  27. package/registry/skills/implement-opus-change/agents/openai.yaml +4 -0
  28. package/registry/skills/implement-opus-change/references/evaluations.md +5 -0
  29. package/registry/skills/implement-opus-change/references/protocol-boundaries.md +11 -0
  30. package/registry/skills/test-opus-action/SKILL.md +38 -0
  31. package/registry/skills/test-opus-action/agents/openai.yaml +4 -0
  32. package/registry/skills/test-opus-action/references/evaluations.md +5 -0
  33. package/registry/skills/test-opus-action/references/harness.md +10 -0
  34. package/registry/skills/upgrade-opus/SKILL.md +37 -0
  35. package/registry/skills/upgrade-opus/agents/openai.yaml +4 -0
  36. package/registry/skills/upgrade-opus/references/evaluations.md +5 -0
  37. package/registry/skills/upgrade-opus/references/upgrade-checklist.md +8 -0
  38. package/registry/templates/app/src/domains/tasks/index.ts +1 -1
  39. package/src/ui/components/primitives/select.tsx +18 -6
  40. package/src/ui/docs/content/actions.md +1 -1
  41. package/src/ui/docs/content/cli.md +2 -2
  42. package/src/ui/docs/content/customization.md +1 -1
  43. package/src/ui/docs/content/data.md +1 -1
  44. package/src/ui/docs/content/microcopy.md +1 -1
  45. package/src/ui/docs/content/scroll-area.md +1 -1
  46. package/src/ui/docs/content/tokens.md +1 -1
  47. package/src/ui/docs/content/ui.md +1 -1
  48. package/src/ui/docs/doc-client.tsx +2 -2
  49. package/src/ui/theme.css +1 -1
  50. package/registry/hooks/hooks.json +0 -26
  51. package/registry/hooks/link-memory-on-start.mjs +0 -46
  52. package/registry/skills/create-action/SKILL.md +0 -49
  53. package/registry/skills/create-action/scaffold.mjs +0 -122
@@ -0,0 +1,273 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { execFileSync } from 'node:child_process'
3
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
4
+ import { dirname, extname, join, relative, resolve } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ import { validateSkill } from './validate-skill.mjs'
8
+
9
+ export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
10
+ export const PACKAGE_NAME = '@softize/opus'
11
+ export const PACKAGE_VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).version
12
+
13
+ const REGISTRY = join(PACKAGE_ROOT, 'registry')
14
+ const BLOCK_SOURCE = 'registry/instructions/opus.md'
15
+ const BLOCK_END = '<!-- softize-managed:end @softize/opus -->'
16
+ const MANAGED_ROOTS = ['.agents/skills', '.claude/skills', '.claude/hooks', '.github/workflows', '.githooks/pre-push.d']
17
+
18
+ const hash = (content) => createHash('sha256').update(content).digest('hex')
19
+ const readJson = (path) => {
20
+ try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return null }
21
+ }
22
+
23
+ function filesUnder(root, base = root) {
24
+ if (!existsSync(root)) return []
25
+ return readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).flatMap((entry) => {
26
+ const path = join(root, entry.name)
27
+ return entry.isDirectory() ? filesUnder(path, base) : entry.isFile() ? [{ path, relative: relative(base, path) }] : []
28
+ })
29
+ }
30
+
31
+ function sourceSkills() {
32
+ return readdirSync(join(REGISTRY, 'skills'), { withFileTypes: true })
33
+ .filter((entry) => entry.isDirectory() && existsSync(join(REGISTRY, 'skills', entry.name, 'SKILL.md')))
34
+ .map((entry) => entry.name).sort()
35
+ }
36
+
37
+ function marker(path, source, content) {
38
+ const value = `softize-managed: ${JSON.stringify({ package: PACKAGE_NAME, source, sha256: hash(content) })}`
39
+ if (extname(path) === '.md') return `<!-- ${value} -->`
40
+ if (['.js', '.mjs', '.cjs', '.ts', '.tsx'].includes(extname(path))) return `// ${value}`
41
+ return `# ${value}`
42
+ }
43
+
44
+ export function addMarker(path, source, content) {
45
+ const line = marker(path, source, content)
46
+ if (content.startsWith('#!')) {
47
+ const at = content.indexOf('\n') + 1
48
+ return `${content.slice(0, at)}${line}\n${content.slice(at)}`
49
+ }
50
+ if (extname(path) === '.md' && content.startsWith('---\n')) {
51
+ const close = content.indexOf('\n---\n', 4)
52
+ if (close !== -1) {
53
+ const at = close + 5
54
+ return `${content.slice(0, at)}${line}\n${content.slice(at)}`
55
+ }
56
+ }
57
+ return `${line}\n${content}`
58
+ }
59
+
60
+ function metadata(content) {
61
+ const match = /^(?:<!-- |\/\/ |# )softize-managed: (\{[^\n]+\})(?: -->)?$/m.exec(content)
62
+ if (match === null) return null
63
+ try { return JSON.parse(match[1]) } catch { return null }
64
+ }
65
+
66
+ function stripMarker(content) {
67
+ return content.replace(/^(?:<!-- |\/\/ |# )softize-managed: \{[^\n]+\}(?: -->)?\r?\n/m, '')
68
+ }
69
+
70
+ function config(root, mode, errors, changes) {
71
+ const path = join(root, 'base.json')
72
+ const original = existsSync(path) ? readFileSync(path, 'utf8') : ''
73
+ const current = original === '' ? { schemaVersion: 1, packages: {} } : readJson(path)
74
+ if (current === null || current.schemaVersion !== 1 || typeof current.packages !== 'object') {
75
+ errors.push('base.json: formato inválido; esperado schemaVersion 1 e packages.')
76
+ return []
77
+ }
78
+ const previous = current.packages[PACKAGE_NAME] ?? {}
79
+ const exclude = Array.isArray(previous.exclude) ? previous.exclude : []
80
+ if (previous.exclude !== undefined && !Array.isArray(previous.exclude)) errors.push('base.json: exclude deve ser uma lista de slugs.')
81
+ const available = new Set(sourceSkills())
82
+ for (const slug of exclude) if (typeof slug !== 'string' || !available.has(slug)) errors.push(`base.json: skill excluída desconhecida: ${String(slug)}.`)
83
+ if (mode === 'setup') {
84
+ current.packages[PACKAGE_NAME] = { version: PACKAGE_VERSION, exclude }
85
+ const rendered = `${JSON.stringify(current, null, 2)}\n`
86
+ if (rendered !== original) {
87
+ writeFileSync(path, rendered)
88
+ changes.push(`${original === '' ? 'criado' : 'atualizado'} base.json`)
89
+ }
90
+ } else if (previous.version !== PACKAGE_VERSION) errors.push(`base.json: Opus aplicado ${previous.version ?? 'ausente'}, instalado ${PACKAGE_VERSION}.`)
91
+ return exclude.filter((slug) => available.has(slug))
92
+ }
93
+
94
+ function expectedFiles(exclude) {
95
+ const expected = new Map()
96
+ const addTree = (sourceRoot, destinationRoot) => {
97
+ for (const file of filesUnder(join(PACKAGE_ROOT, sourceRoot))) {
98
+ const source = `${sourceRoot}/${file.relative}`
99
+ const destination = `${destinationRoot}/${file.relative}`
100
+ const content = readFileSync(file.path, 'utf8')
101
+ expected.set(destination, { source, content, output: addMarker(destination, source, content) })
102
+ }
103
+ }
104
+ for (const slug of sourceSkills()) {
105
+ if (exclude.includes(slug)) continue
106
+ addTree(`registry/skills/${slug}`, `.agents/skills/${slug}`)
107
+ addTree(`registry/skills/${slug}`, `.claude/skills/${slug}`)
108
+ }
109
+ for (const [source, destination] of [
110
+ ['registry/hooks/opus-check-on-stop.mjs', '.claude/hooks/opus-check-on-stop.mjs'],
111
+ ['registry/git/pre-push.d/opus', '.githooks/pre-push.d/opus'],
112
+ ['registry/github/opus.yml', '.github/workflows/opus.yml'],
113
+ ]) {
114
+ const content = readFileSync(join(PACKAGE_ROOT, source), 'utf8')
115
+ expected.set(destination, { source, content, output: addMarker(destination, source, content) })
116
+ }
117
+ return expected
118
+ }
119
+
120
+ function reconcileFile(root, destination, item, mode, errors, changes) {
121
+ const path = join(root, destination)
122
+ if (!existsSync(path)) {
123
+ if (mode === 'check') errors.push(`${destination}: ausente.`)
124
+ else {
125
+ mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, item.output)
126
+ if (destination.startsWith('.githooks/')) chmodSync(path, 0o755)
127
+ changes.push(`criado ${destination}`)
128
+ }
129
+ return
130
+ }
131
+ const current = readFileSync(path, 'utf8')
132
+ if (current === item.output) return
133
+ const owner = metadata(current)
134
+ if (owner?.package !== PACKAGE_NAME) { errors.push(`${destination}: colisão com arquivo não gerenciado pelo Opus.`); return }
135
+ if (hash(stripMarker(current)) !== owner.sha256) { errors.push(`${destination}: arquivo gerenciado foi editado; preservado.`); return }
136
+ if (mode === 'check') errors.push(`${destination}: desatualizado.`)
137
+ else {
138
+ writeFileSync(path, item.output)
139
+ if (destination.startsWith('.githooks/')) chmodSync(path, 0o755)
140
+ changes.push(`atualizado ${destination}`)
141
+ }
142
+ }
143
+
144
+ function removeObsolete(root, expected, mode, errors, changes) {
145
+ for (const managedRoot of MANAGED_ROOTS) for (const file of filesUnder(join(root, managedRoot), root)) {
146
+ const content = readFileSync(file.path, 'utf8')
147
+ const owner = metadata(content)
148
+ if (owner?.package !== PACKAGE_NAME || expected.has(file.relative)) continue
149
+ if (hash(stripMarker(content)) !== owner.sha256) errors.push(`${file.relative}: obsoleto, mas editado; preservado.`)
150
+ else if (mode === 'check') errors.push(`${file.relative}: artefato obsoleto.`)
151
+ else { rmSync(file.path); changes.push(`removido ${file.relative}`) }
152
+ }
153
+ }
154
+
155
+ function detectLegacyArtifacts(root, errors) {
156
+ for (const destination of ['.agents/skills/create-action', '.claude/skills/create-action']) {
157
+ if (existsSync(join(root, destination))) {
158
+ errors.push(`${destination}: skill legada detectada; remova-a após revisar qualquer edição local. Use create-opus-action.`)
159
+ }
160
+ }
161
+ }
162
+
163
+ function expectedBlock() {
164
+ const body = readFileSync(join(PACKAGE_ROOT, BLOCK_SOURCE), 'utf8').trimEnd()
165
+ const open = `<!-- softize-managed:start ${JSON.stringify({ package: PACKAGE_NAME, source: BLOCK_SOURCE, sha256: hash(body) })} -->`
166
+ return { body, text: `${open}\n${body}\n${BLOCK_END}` }
167
+ }
168
+
169
+ function findBlock(content) {
170
+ const pattern = /<!-- softize-managed:start (\{[^\n]+\}) -->/g
171
+ for (const match of content.matchAll(pattern)) {
172
+ let owner = null
173
+ try { owner = JSON.parse(match[1]) } catch { /* outro marcador inválido */ }
174
+ if (owner?.package !== PACKAGE_NAME) continue
175
+ const start = match.index
176
+ const openEnd = start + match[0].length
177
+ const end = content.indexOf(BLOCK_END, openEnd)
178
+ if (end === -1) return { malformed: true }
179
+ const bodyStart = openEnd + 1
180
+ const bodyEnd = content[end - 1] === '\n' ? end - 1 : end
181
+ return { start, end: end + BLOCK_END.length, body: content.slice(bodyStart, bodyEnd), owner }
182
+ }
183
+ if (/<!-- softize-managed:start [^\n]*@softize\/opus/.test(content)) return { malformed: true }
184
+ return null
185
+ }
186
+
187
+ function reconcileInstructions(root, file, mode, errors, changes) {
188
+ const path = join(root, file)
189
+ const expected = expectedBlock()
190
+ const current = existsSync(path) ? readFileSync(path, 'utf8') : ''
191
+ const block = findBlock(current)
192
+ if (block?.malformed) { errors.push(`${file}: bloco Opus malformado.`); return }
193
+ if (block === null) {
194
+ if (mode === 'check') errors.push(`${file}: bloco Opus ausente.`)
195
+ else { writeFileSync(path, `${current.trimEnd()}${current.trimEnd() === '' ? '' : '\n\n'}${expected.text}\n`); changes.push(`atualizado ${file}`) }
196
+ return
197
+ }
198
+ if (hash(block.body) !== block.owner.sha256) { errors.push(`${file}: bloco Opus foi editado; preservado.`); return }
199
+ const actual = current.slice(block.start, block.end)
200
+ if (actual === expected.text) return
201
+ if (mode === 'check') errors.push(`${file}: bloco Opus desatualizado.`)
202
+ else { writeFileSync(path, `${current.slice(0, block.start)}${expected.text}${current.slice(block.end)}`); changes.push(`atualizado ${file}`) }
203
+ }
204
+
205
+ function isOpusHook(hook) { return typeof hook?.command === 'string' && hook.command.includes('.claude/hooks/opus-check-on-stop.mjs') }
206
+ function reconcileSettings(root, mode, errors, changes) {
207
+ const destination = '.claude/settings.json'
208
+ const path = join(root, destination)
209
+ const current = existsSync(path) ? readJson(path) : {}
210
+ if (current === null || typeof current !== 'object') { errors.push(`${destination}: JSON inválido; preservado.`); return }
211
+ const expected = structuredClone(current); expected.hooks ??= {}
212
+ for (const event of Object.keys(expected.hooks)) expected.hooks[event] = expected.hooks[event]
213
+ .map((group) => ({ ...group, hooks: (group.hooks ?? []).filter((hook) => !isOpusHook(hook)) }))
214
+ .filter((group) => group.hooks.length > 0)
215
+ expected.hooks.Stop = [...(expected.hooks.Stop ?? []), { hooks: [{ type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/opus-check-on-stop.mjs"', timeout: 120 }] }]
216
+ if (JSON.stringify(current) === JSON.stringify(expected)) return
217
+ if (mode === 'check') errors.push(`${destination}: hook Opus ausente ou desatualizado.`)
218
+ else { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(expected, null, 2)}\n`); changes.push(`atualizado ${destination}`) }
219
+ }
220
+
221
+ function reconcileSharedPrePush(root, mode, errors, changes) {
222
+ const destination = '.githooks/pre-push'
223
+ const path = join(root, destination)
224
+ const expected = readFileSync(join(REGISTRY, 'git/pre-push'), 'utf8')
225
+ if (!existsSync(path)) {
226
+ if (mode === 'check') errors.push(`${destination}: dispatcher compartilhado ausente.`)
227
+ else { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, expected); chmodSync(path, 0o755); changes.push(`criado ${destination}`) }
228
+ } else if (readFileSync(path, 'utf8') !== expected) errors.push(`${destination}: dispatcher compartilhado incompatível; preservado.`)
229
+ else if (mode === 'setup' && (statSync(path).mode & 0o111) === 0) { chmodSync(path, 0o755); changes.push(`corrigido modo executável de ${destination}`) }
230
+ }
231
+
232
+ function configureGit(root, mode, errors, changes) {
233
+ if (!existsSync(join(root, '.git'))) return
234
+ try {
235
+ try { execFileSync('git', ['rev-parse', '--git-dir'], { cwd: root, stdio: 'ignore' }) } catch { return }
236
+ let hooksPath = ''
237
+ try { hooksPath = execFileSync('git', ['config', '--local', '--get', 'core.hooksPath'], { cwd: root, encoding: 'utf8' }).trim() } catch { /* ausente */ }
238
+ if (mode === 'setup' && hooksPath === '') execFileSync('git', ['config', '--local', 'core.hooksPath', '.githooks'], { cwd: root })
239
+ else if (hooksPath !== '.githooks') errors.push(`git core.hooksPath ${hooksPath === '' ? 'não configurado' : `aponta para ${hooksPath}`}.`)
240
+
241
+ const excludePath = execFileSync('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd: root, encoding: 'utf8' }).trim()
242
+ const absolute = resolve(root, excludePath)
243
+ if (existsSync(absolute)) {
244
+ const current = readFileSync(absolute, 'utf8')
245
+ const forbidden = new Set(['.claude/', '.claude/skills/', '.agents/', '.agents/skills/'])
246
+ const lines = current.split(/\r?\n/)
247
+ const blocked = lines.filter((line) => forbidden.has(line.trim()))
248
+ if (blocked.length > 0 && mode === 'check') errors.push(`git info/exclude oculta artefatos de agentes: ${blocked.join(', ')}.`)
249
+ else if (blocked.length > 0) {
250
+ const kept = lines.filter((line) => !forbidden.has(line.trim())).join('\n').replace(/\n+$/, '')
251
+ writeFileSync(absolute, kept === '' ? '' : `${kept}\n`)
252
+ changes.push('removidas exclusões locais de artefatos de agentes')
253
+ }
254
+ }
255
+ } catch (error) { errors.push(`git: ${String(error).slice(0, 140)}.`) }
256
+ }
257
+
258
+ export function materializeOpus(root, mode = 'setup') {
259
+ const target = resolve(root)
260
+ const errors = sourceSkills().flatMap((slug) => validateSkill(join(REGISTRY, 'skills', slug)).map((error) => `skills/${slug}: ${error}`))
261
+ const changes = []
262
+ const exclude = config(target, mode, errors, changes)
263
+ const expected = expectedFiles(exclude)
264
+ reconcileSharedPrePush(target, mode, errors, changes)
265
+ for (const [destination, item] of expected) reconcileFile(target, destination, item, mode, errors, changes)
266
+ removeObsolete(target, expected, mode, errors, changes)
267
+ detectLegacyArtifacts(target, errors)
268
+ reconcileInstructions(target, 'AGENTS.md', mode, errors, changes)
269
+ reconcileInstructions(target, 'CLAUDE.md', mode, errors, changes)
270
+ reconcileSettings(target, mode, errors, changes)
271
+ configureGit(target, mode, errors, changes)
272
+ return { ok: errors.length === 0, errors, changes, expected: [...expected.keys()] }
273
+ }
package/bin/lib/mcp.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * opus mcp — server MCP que expõe a base ao agente: introspecção (fonte única) +
3
- * a régua (opus check) + o gerador (create-action). Os agentes apontam pra cá via
3
+ * a régua (opus check) + o gerador usado por `create-opus-action`. Os agentes apontam pra cá via
4
4
  * `mcp_config`. Embrulha os engines já testados (introspect/check/scaffold).
5
5
  *
6
6
  * Tools:
@@ -16,7 +16,7 @@ import { z } from 'zod'
16
16
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
17
17
  import { introspect } from './introspect.mjs'
18
18
  import { scanDir } from './check.mjs'
19
- import { actionTemplate } from '../../registry/skills/create-action/scaffold.mjs'
19
+ import { actionFiles } from '../../registry/skills/create-opus-action/scripts/scaffold.mjs'
20
20
  import { listComponents, getComponent } from './components.mjs'
21
21
 
22
22
  const asText = (data) => ({
@@ -56,7 +56,7 @@ export function buildServer() {
56
56
  kind: z.enum(['simple', 'form', 'list', 'view']).optional(),
57
57
  },
58
58
  },
59
- async ({ resource, verb, kind }) => asText(actionTemplate({ resource, verb, kind })),
59
+ async ({ resource, verb, kind }) => asText(actionFiles({ resource, verb, kind })),
60
60
  )
61
61
 
62
62
  server.registerTool(
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * postinstall do @softize/opus — auto-bootstrap: roda `opus setup` no projeto
4
- * consumidor assim que a base é instalada. Assim a DEPENDÊNCIA é o sinal: quem
5
- * adiciona @softize/opus já nasce conhecendo a base, sem precisar saber do init.
3
+ * postinstall do @softize/opus — tenta rodar o setup no consumidor. É conveniência
4
+ * best-effort; a garantia vem de `opus setup` explícito + `opus check` no CI.
6
5
  *
7
6
  * Blindagem (best-effort, NUNCA falha o install):
8
7
  * • OPUS_SKIP_INIT=1 → pula
@@ -42,11 +41,15 @@ async function main() {
42
41
  if (!deps['@softize/opus']) return // Não é consumidor da base.
43
42
 
44
43
  const r = await initProject(REGISTRY_DIR, projectDir)
44
+ if (!r.materialization.ok) {
45
+ console.log(`\x1b[33m[opus]\x1b[0m setup incompleto: ${r.materialization.errors.join(' | ')}`)
46
+ return
47
+ }
45
48
  const n = r.created.length + r.synced.length
46
49
  console.log(
47
50
  `\x1b[36m[opus]\x1b[0m base v${r.version} — ${r.wasInitialized ? 'sincronizada' : 'inicializada'}` +
48
51
  (n ? ` (${n} arquivo(s))` : '') +
49
- `. Veja CLAUDE.md / opus.json.`,
52
+ `. Veja base.json / opus.json.`,
50
53
  )
51
54
  }
52
55
 
@@ -0,0 +1,40 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
+ import { basename, join } from 'node:path'
3
+
4
+ const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
5
+ const PROHIBITED = new Set(['README.md', 'CHANGELOG.md', 'INSTALLATION_GUIDE.md', 'QUICK_REFERENCE.md'])
6
+
7
+ function frontmatter(content) {
8
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(content)
9
+ 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)
15
+ }
16
+
17
+ export function validateSkill(root) {
18
+ 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.')
25
+ 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}.`)
39
+ return errors
40
+ }
@@ -5,12 +5,12 @@ order: 5
5
5
 
6
6
  # Code style — padrão softize
7
7
 
8
- > **A fonte desta regra migrou pro admin** (metodologia 12/06/2026): skill `code-style`,
9
- > vinculada a todos os papéis e materializada em `.claude/skills/code-style/SKILL.md`
10
- > via `maestro pull`. Edite LÁ (aba Skills do admin) este arquivo é só o ponteiro.
8
+ Este arquivo registra as convenções editoriais da documentação do Opus. Regras
9
+ transversais de engenharia pertencem à `@softize/base`; o Opus mantém apenas o que é
10
+ específico do SDK e seus gates.
11
11
 
12
12
  Resumo de uma linha: **código em inglês; o que humano lê (UI, comentário, doc) em pt-BR;
13
13
  frases com maiúscula e ponto; labels curtos sem ponto.**
14
14
 
15
15
  O que dá, é regra executável no `opus check` (naming `<resource>.<verb>`, ordem dos
16
- campos do spec, registro no runtime); o resto vive na skill.
16
+ campos do spec, registro no runtime); o restante vem das instruções versionadas no repo.
package/docs/releasing.md CHANGED
@@ -5,23 +5,23 @@ order: 6
5
5
 
6
6
  # Publicar o @softize/opus
7
7
 
8
- O Opus é publicado no **registry compartilhado da Softize** (`registry.softize.com.br`),
9
- **não** no npm público. Os clientes consomem de lá.
8
+ O Opus é publicado no **npm público** (`registry.npmjs.org`). O registry próprio foi
9
+ aposentado; clientes instalam sem configuração ou token de leitura.
10
10
 
11
- > O `publishConfig` do `package.json` já aponta pra — `pnpm publish` "só funciona",
11
+ > O `publishConfig` do `package.json` já aponta para o npm — `pnpm publish` "só funciona",
12
12
  > sem `--registry`. Não precisa decorar a URL.
13
13
 
14
14
  ## Release pela estação (o caminho normal)
15
15
 
16
16
  ```bash
17
- pnpm release # patch bump + publica no registry.softize.com.br
17
+ pnpm release # patch bump + publica no npm público
18
18
  pnpm release minor # minor
19
19
  pnpm release major # major
20
20
  pnpm release 2.9.0 # versão explícita
21
21
  pnpm release none # publica sem bump
22
22
  ```
23
23
 
24
- Auth: o token do registry no `~/.npmrc` global (`//registry.softize.com.br/:_authToken=…`),
24
+ Auth: o token do npm no `~/.npmrc` global (`//registry.npmjs.org/:_authToken=…`),
25
25
  NÃO commitado.
26
26
 
27
27
  ## Não existe caminho pela CI (29/jul)
@@ -32,7 +32,7 @@ o `release.yml` (publicava por tag `v*`), o `deploy.yml` (doc, já reserva) e o
32
32
  caminho só, na estação, com os gates junto. Não há gatilho de reserva.
33
33
 
34
34
  O secret **`OPUS_REGISTRY_TOKEN`** ficou órfão — nada mais o lê. Apague no GitHub. O que
35
- autentica hoje é o token do registry no seu `~/.npmrc` global.
35
+ autentica hoje é o token do npm no seu `~/.npmrc` global.
36
36
 
37
37
  **O que o `release.sh` absorveu**, porque sem CI ficaria sem dono:
38
38
 
@@ -70,9 +70,7 @@ pra commitar.
70
70
  ## Verificar
71
71
 
72
72
  ```bash
73
- npm view @softize/opus version --registry https://registry.softize.com.br/
74
- # ou
75
- curl -s https://registry.softize.com.br/@softize%2fopus | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>console.log(JSON.parse(d)['dist-tags'].latest))"
73
+ npm view @softize/opus version --registry https://registry.npmjs.org/
76
74
  ```
77
75
 
78
76
  ## Testar localmente (sem tocar no compartilhado)
@@ -95,16 +93,16 @@ pnpm add @softize/opus@8.7.0-rc.0 --registry http://127.0.0.1:6873/
95
93
  ```
96
94
 
97
95
  Valide o app normalmente. Ao encerrar a sessão, restaure o intervalo de versão do
98
- consumidor para o registry compartilhado e rode `pnpm install`; a prerelease local nunca
99
- é enviada para `registry.softize.com.br`. Para publicar outra tentativa, incremente o
100
- sucesso (`8.6.0-rc.1`, por exemplo) e repita o mesmo fluxo.
96
+ consumidor para o npm e rode `pnpm install`; a prerelease local nunca é enviada ao npm.
97
+ Para publicar outra tentativa, incremente o sucesso (`8.6.0-rc.1`, por exemplo) e repita
98
+ o mesmo fluxo.
101
99
 
102
100
  ## Auth
103
101
 
104
- O publish no compartilhado exige o token no **`~/.npmrc` global** do dev:
102
+ O publish exige o token no **`~/.npmrc` global** do dev:
105
103
 
106
104
  ```
107
- //registry.softize.com.br/:_authToken=<token>
105
+ //registry.npmjs.org/:_authToken=<token>
108
106
  ```
109
107
 
110
108
  Não vai no repo. Leitura (install) é aberta — não precisa de token.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "8.8.1",
3
+ "version": "8.9.0",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,9 @@
20
20
  "./init": {
21
21
  "default": "./bin/lib/init.mjs"
22
22
  },
23
+ "./materialize": {
24
+ "default": "./bin/lib/materialize.mjs"
25
+ },
23
26
  "./docs-include": "./bin/lib/docs-include.mjs",
24
27
  "./core": {
25
28
  "types": "./src/core/index.ts",
@@ -185,16 +188,6 @@
185
188
  "bin": {
186
189
  "opus": "./bin/cli.mjs"
187
190
  },
188
- "scripts": {
189
- "postinstall": "node ./bin/lib/postinstall.mjs",
190
- "typecheck": "tsc --noEmit",
191
- "test": "vitest run",
192
- "test:watch": "vitest",
193
- "test:cov": "vitest run --coverage",
194
- "registry:up": "npx -y verdaccio --config ~/.config/verdaccio/config.yaml",
195
- "release": "bash ./scripts/release.sh",
196
- "release:local": "bash ./scripts/release.sh --local"
197
- },
198
191
  "dependencies": {
199
192
  "@modelcontextprotocol/sdk": "^1.29.0",
200
193
  "@radix-ui/react-checkbox": "^1.1.3",
@@ -329,11 +322,20 @@
329
322
  "vitest": "^2.1.0",
330
323
  "zod": "^3.24.0"
331
324
  },
332
- "packageManager": "pnpm@9.0.0",
333
325
  "repository": {
334
326
  "type": "git",
335
327
  "url": "git+https://github.com/softize-dev/opus.git",
336
328
  "directory": "packages/opus"
337
329
  },
338
- "homepage": "https://opus.softize.com.br"
339
- }
330
+ "homepage": "https://opus.softize.com.br",
331
+ "scripts": {
332
+ "postinstall": "node ./bin/lib/postinstall.mjs",
333
+ "typecheck": "tsc --noEmit",
334
+ "test": "vitest run",
335
+ "test:watch": "vitest",
336
+ "test:cov": "vitest run --coverage",
337
+ "registry:up": "npx -y verdaccio --config ~/.config/verdaccio/config.yaml",
338
+ "release": "bash ./scripts/release.sh",
339
+ "release:local": "bash ./scripts/release.sh --local"
340
+ }
341
+ }
@@ -0,0 +1,8 @@
1
+ #!/bin/sh
2
+
3
+ hook_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
4
+
5
+ for hook in "$hook_dir"/pre-push.d/*; do
6
+ [ -x "$hook" ] || continue
7
+ "$hook" "$@" || exit $?
8
+ done
@@ -0,0 +1,9 @@
1
+ #!/bin/sh
2
+ if [ -f node_modules/@softize/opus/bin/cli.mjs ]; then
3
+ node node_modules/@softize/opus/bin/cli.mjs pre-push
4
+ elif [ -f packages/opus/bin/cli.mjs ]; then
5
+ node packages/opus/bin/cli.mjs pre-push materialization
6
+ else
7
+ echo "opus pre-push: @softize/opus não está instalado." >&2
8
+ exit 1
9
+ fi
@@ -0,0 +1,22 @@
1
+ name: opus
2
+
3
+ on:
4
+ pull_request: {}
5
+ push:
6
+ branches: [main]
7
+ workflow_dispatch: {}
8
+
9
+ jobs:
10
+ check:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: pnpm/action-setup@v4
15
+ with:
16
+ version: 11
17
+ - uses: actions/setup-node@v4
18
+ with:
19
+ node-version: 22
20
+ cache: pnpm
21
+ - run: pnpm install --frozen-lockfile
22
+ - run: pnpm exec opus check
@@ -0,0 +1,15 @@
1
+ # Opus
2
+
3
+ Este repo usa `@softize/opus`, um SDK e protocolo de actions. A versão aplicada aos
4
+ artefatos de agentes fica em `base.json`; cada app Opus mantém seu marcador `opus.json`.
5
+
6
+ ## Regras permanentes
7
+
8
+ - Tratar declarações como fonte de contratos e documentação de negócio; manifest, OpenAPI
9
+ e docs são projeções geradas.
10
+ - Manter contrato compartilhável separado de banco, segredo e driver server-only; usar
11
+ `defineContract` com `bindAction` quando cliente e servidor consomem a mesma action.
12
+ - Não duplicar schemas, tipos de transporte, validação ou fetch que o contrato já fornece.
13
+ - Rodar `opus check` e os gates do projeto depois de alterar actions, bindings ou versão do SDK.
14
+ - Consultar as skills Opus materializadas conforme o workflow; não atribuir ao SDK decisões
15
+ universais de domínio ou arquitetura.
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: build-opus-ui
3
+ description: Constrói interface contract-driven com hooks, forms, listas, views e componentes de @softize/opus/ui. Use ao implementar ou alterar telas que consomem actions Opus.
4
+ ---
5
+
6
+ # Construir UI Opus
7
+
8
+ ## Resultado
9
+
10
+ Entregar uma interface que deriva dados, validação, mensagens e execução dos contratos
11
+ existentes, mantendo navegação observável e componentes reutilizáveis sem layout oculto.
12
+
13
+ ## Procedimento
14
+
15
+ 1. Identificar o contrato e o kind da action; corrigir lacuna do contrato na fonte em vez
16
+ de compensá-la com tipo ou validação na UI.
17
+ 2. Usar os hooks e componentes do catálogo `@softize/opus/ui/react` adequados ao kind.
18
+ 3. Tratar página, filtro, seleção e modal importante como estado navegável por URL quando
19
+ o produto precisa de deep link, back/forward ou refresh.
20
+ 4. Manter margem e posicionamento no consumidor; componente reutilizável controla apenas
21
+ seu interior.
22
+ 5. Evoluir um pattern compartilhado apenas quando a recorrência e o contrato estiverem claros.
23
+ 6. Testar estados de loading, vazio, erro, sucesso, permissão e interação relevante.
24
+
25
+ ## Verificação
26
+
27
+ Rodar testes focados, typecheck e build da superfície. Inspecionar visualmente a rota real
28
+ e validar navegação por URL quando aplicável.
29
+
30
+ ## Limites
31
+
32
+ - Não criar fetch, schema ou tipo paralelo ao contrato.
33
+ - Não copiar componente da lib para customizar sem antes verificar extensão/composição.
34
+ - Não forçar modal roteável quando o estado é efêmero e sem valor de navegação.
35
+
36
+ ## Recursos
37
+
38
+ - Leia [patterns de UI](references/ui-patterns.md) para decisões de estado e composição.
39
+ - Use [avaliações](references/evaluations.md) ao evoluir esta skill.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Construir UI Opus"
3
+ short_description: "Constrói UI contract-driven com os componentes Opus"
4
+ default_prompt: "Use $build-opus-ui para construir esta interface Opus."
@@ -0,0 +1,5 @@
1
+ # Avaliações
2
+
3
+ - Dispara: “Monte a tela de edição usando a form action do Opus.”
4
+ - Não dispara: “Ajuste o CSS de um e-mail estático.”
5
+ - Execução: implementar uma lista com modal roteável e provar loading, erro, vazio e back.
@@ -0,0 +1,8 @@
1
+ # Patterns de UI Opus
2
+
3
+ - O kind da action orienta a primitiva: form, list, view ou trigger simples.
4
+ - Campos, labels, mensagens e invalidações pertencem ao contrato quando são parte da
5
+ operação, não a uma tela isolada.
6
+ - URL representa estado que precisa sobreviver a refresh, deep link ou histórico.
7
+ - Componentes compartilhados não impõem margem externa; páginas e shells compõem layout.
8
+ - Catálogo e API efetivos vêm dos exports da versão instalada, não de memória ou exemplo antigo.