@softize/opus 8.8.2 → 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.
- package/CHANGELOG.md +18 -0
- package/README.md +1 -1
- package/bin/cli.mjs +45 -15
- package/bin/lib/create.mjs +5 -5
- package/bin/lib/init.mjs +20 -228
- package/bin/lib/materialize.mjs +273 -0
- package/bin/lib/mcp.mjs +3 -3
- package/bin/lib/postinstall.mjs +7 -4
- package/bin/lib/validate-skill.mjs +40 -0
- package/docs/code-style.md +4 -4
- package/docs/releasing.md +12 -14
- package/package.json +16 -14
- package/registry/git/pre-push +8 -0
- package/registry/git/pre-push.d/opus +9 -0
- package/registry/github/opus.yml +22 -0
- package/registry/instructions/opus.md +15 -0
- package/registry/skills/build-opus-ui/SKILL.md +39 -0
- package/registry/skills/build-opus-ui/agents/openai.yaml +4 -0
- package/registry/skills/build-opus-ui/references/evaluations.md +5 -0
- package/registry/skills/build-opus-ui/references/ui-patterns.md +8 -0
- package/registry/skills/create-opus-action/SKILL.md +43 -0
- package/registry/skills/create-opus-action/agents/openai.yaml +4 -0
- package/registry/skills/create-opus-action/references/contract-and-binding.md +12 -0
- package/registry/skills/create-opus-action/references/evaluations.md +5 -0
- package/registry/skills/create-opus-action/scripts/scaffold.mjs +100 -0
- package/registry/skills/implement-opus-change/SKILL.md +43 -0
- package/registry/skills/implement-opus-change/agents/openai.yaml +4 -0
- package/registry/skills/implement-opus-change/references/evaluations.md +5 -0
- package/registry/skills/implement-opus-change/references/protocol-boundaries.md +11 -0
- package/registry/skills/test-opus-action/SKILL.md +38 -0
- package/registry/skills/test-opus-action/agents/openai.yaml +4 -0
- package/registry/skills/test-opus-action/references/evaluations.md +5 -0
- package/registry/skills/test-opus-action/references/harness.md +10 -0
- package/registry/skills/upgrade-opus/SKILL.md +37 -0
- package/registry/skills/upgrade-opus/agents/openai.yaml +4 -0
- package/registry/skills/upgrade-opus/references/evaluations.md +5 -0
- package/registry/skills/upgrade-opus/references/upgrade-checklist.md +8 -0
- package/registry/templates/app/src/domains/tasks/index.ts +1 -1
- package/src/ui/docs/content/actions.md +1 -1
- package/src/ui/docs/content/cli.md +2 -2
- package/src/ui/docs/content/customization.md +1 -1
- package/src/ui/docs/content/data.md +1 -1
- package/src/ui/docs/content/microcopy.md +1 -1
- package/src/ui/docs/content/scroll-area.md +1 -1
- package/src/ui/docs/content/tokens.md +1 -1
- package/src/ui/docs/content/ui.md +1 -1
- package/src/ui/docs/doc-client.tsx +2 -2
- package/src/ui/theme.css +1 -1
- package/registry/hooks/hooks.json +0 -26
- package/registry/hooks/link-memory-on-start.mjs +0 -46
- package/registry/skills/create-action/SKILL.md +0 -49
- 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
|
|
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 {
|
|
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(
|
|
59
|
+
async ({ resource, verb, kind }) => asText(actionFiles({ resource, verb, kind })),
|
|
60
60
|
)
|
|
61
61
|
|
|
62
62
|
server.registerTool(
|
package/bin/lib/postinstall.mjs
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* postinstall do @softize/opus —
|
|
4
|
-
*
|
|
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
|
|
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
|
+
}
|
package/docs/code-style.md
CHANGED
|
@@ -5,12 +5,12 @@ order: 5
|
|
|
5
5
|
|
|
6
6
|
# Code style — padrão softize
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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 **
|
|
9
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
102
|
+
O publish exige o token no **`~/.npmrc` global** do dev:
|
|
105
103
|
|
|
106
104
|
```
|
|
107
|
-
//registry.
|
|
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.
|
|
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,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,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.
|