@softize/opus 11.1.0 → 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 (44) hide show
  1. package/CHANGELOG.md +59 -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/DocBrowser.tsx +10 -2
  36. package/src/ui/docs/content/cli.md +8 -7
  37. package/src/ui/docs/content/communication.md +83 -0
  38. package/src/ui/docs/content/getting-started.md +29 -16
  39. package/src/ui/docs/registry.tsx +2 -2
  40. package/registry/skills/write-product-communication/SKILL.md +0 -28
  41. package/registry/skills/write-product-communication/agents/openai.yaml +0 -4
  42. package/registry/templates/app/_npmrc +0 -1
  43. package/registry/templates/monorepo/_npmrc +0 -1
  44. package/src/ui/docs/content/microcopy.md +0 -130
@@ -1,11 +1,23 @@
1
1
  import { createHash } from 'node:crypto'
2
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'
3
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
4
+ import { dirname, extname, isAbsolute, join, posix, relative, resolve, sep, win32 } from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
6
 
7
- import { validateSkill } from './validate-skill.mjs'
7
+ import {
8
+ canonicalProjectDirectory,
9
+ ensureProjectDirectory,
10
+ projectPathErrorMessage,
11
+ readProjectDirectory,
12
+ readProjectFile,
13
+ removeProjectFileIfUnchanged,
14
+ safeProjectPath,
15
+ writeProjectFileAtomically,
16
+ } from '@softize/base/project-path'
17
+ import { validGitDirectory } from '@softize/base/project-root'
18
+
8
19
  import { expandDocIncludes } from './docs-include.mjs'
20
+ import { referencedSkills, skillReferences, skillRouteErrors, validateSkill } from './validate-skill.mjs'
9
21
 
10
22
  export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
11
23
  export const PACKAGE_NAME = '@softize/opus'
@@ -16,7 +28,25 @@ const BLOCK_SOURCE = 'registry/instructions/opus.md'
16
28
  const BLOCK_END = '<!-- softize-managed:end @softize/opus -->'
17
29
  const MANAGED_ROOTS = ['.agents/skills', '.claude/skills', '.claude/hooks', '.github/workflows', '.githooks/pre-push.d']
18
30
 
19
- const hash = (content) => createHash('sha256').update(content).digest('hex')
31
+ const normalizedLineEndings = (content) => content.replace(/\r\n?/gu, '\n')
32
+ const hash = (content) => createHash('sha256').update(normalizedLineEndings(content)).digest('hex')
33
+
34
+ function lineEndingStyle(content) {
35
+ const withoutCrlf = content.replace(/\r\n/gu, '')
36
+ const hasCrlf = content.includes('\r\n')
37
+ const hasBareLf = withoutCrlf.includes('\n')
38
+ const hasBareCr = withoutCrlf.includes('\r')
39
+ if (hasBareCr || (hasCrlf && hasBareLf)) return 'mixed'
40
+ return hasCrlf ? 'crlf' : 'lf'
41
+ }
42
+
43
+ function managedTextEquivalent(left, right) {
44
+ return lineEndingStyle(left) !== 'mixed' && normalizedLineEndings(left) === normalizedLineEndings(right)
45
+ }
46
+
47
+ function withProjectLineEndings(content, projectContent) {
48
+ return lineEndingStyle(projectContent) === 'crlf' ? content.replace(/\n/gu, '\r\n') : content
49
+ }
20
50
  const readJson = (path) => {
21
51
  try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return null }
22
52
  }
@@ -59,39 +89,193 @@ export function addMarker(path, source, content) {
59
89
  }
60
90
 
61
91
  function metadata(content) {
62
- const match = /^(?:<!-- |\/\/ |# )softize-managed: (\{[^\n]+\})(?: -->)?$/m.exec(content)
92
+ const match = /^(?:<!-- |\/\/ |# )softize-managed: (\{[^\r\n]+\})(?: -->)?\r?$/m.exec(content)
63
93
  if (match === null) return null
64
94
  try { return JSON.parse(match[1]) } catch { return null }
65
95
  }
66
96
 
67
97
  function stripMarker(content) {
68
- return content.replace(/^(?:<!-- |\/\/ |# )softize-managed: \{[^\n]+\}(?: -->)?\r?\n/m, '')
98
+ return content.replace(/^(?:<!-- |\/\/ |# )softize-managed: \{[^\r\n]+\}(?: -->)?\r?\n/m, '')
99
+ }
100
+
101
+ function inside(root, candidate) {
102
+ const local = relative(root, candidate)
103
+ return local !== '..' && !local.startsWith(`..${sep}`) && !isAbsolute(local)
104
+ }
105
+
106
+ function canonicalRelativeDirectory(value) {
107
+ if (typeof value !== 'string' || value === '' || value.trim() !== value) return false
108
+ if (value !== value.normalize('NFC') || value.includes('\\') || /[\u0000-\u001f\u007f]/u.test(value)) return false
109
+ if (value === '.') return true
110
+ if (posix.isAbsolute(value) || win32.isAbsolute(value) || /^[A-Za-z]:/u.test(value)) return false
111
+ if (value === '..' || posix.normalize(value) !== value) return false
112
+ return value.split('/').every((part) => part !== '' && part !== '.' && part !== '..')
113
+ }
114
+
115
+ function stableDirectory(root, value) {
116
+ if (!canonicalRelativeDirectory(value)) return null
117
+ try {
118
+ const checked = safeProjectPath(root, value, { mustExist: true })
119
+ if (checked.kind !== 'directory') return null
120
+ return checked.path
121
+ } catch { return null }
122
+ }
123
+
124
+ function declaresOpus(root, directory) {
125
+ let packageJson
126
+ try {
127
+ const local = relative(resolve(root), resolve(directory)) || '.'
128
+ const file = readProjectFile(root, join(local, 'package.json'), { allowMissing: true })
129
+ packageJson = file.exists ? JSON.parse(file.content) : null
130
+ } catch {
131
+ return false
132
+ }
133
+ return packageJson?.name === PACKAGE_NAME || ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
134
+ .some((field) => typeof packageJson?.[field]?.[PACKAGE_NAME] === 'string')
69
135
  }
70
136
 
71
- function config(root, mode, errors, changes) {
72
- const path = join(root, 'base.json')
73
- const original = existsSync(path) ? readFileSync(path, 'utf8') : ''
74
- const current = original === '' ? { schemaVersion: 1, packages: {} } : readJson(path)
75
- if (current === null || current.schemaVersion !== 1 || typeof current.packages !== 'object') {
137
+ function inferredResolveFrom(root, invocationDirectory) {
138
+ const source = join(root, 'packages', 'opus')
139
+ if (declaresOpus(root, source)) return 'packages/opus'
140
+ let cursor = resolve(invocationDirectory ?? root)
141
+ if (!inside(root, cursor)) cursor = root
142
+ for (;;) {
143
+ if (declaresOpus(root, cursor)) return relative(root, cursor).split(sep).join('/') || '.'
144
+ if (cursor === root) break
145
+ cursor = dirname(cursor)
146
+ }
147
+ return relative(root, resolve(invocationDirectory ?? root)).split(sep).join('/') || '.'
148
+ }
149
+
150
+ function config(root, mode, errors, changes, required, invocationDirectory, mutate = true) {
151
+ const errorsBeforeConfiguration = errors.length
152
+ const configurationChanges = []
153
+ let file
154
+ try {
155
+ file = readProjectFile(root, 'base.json', { allowMissing: true })
156
+ } catch (error) {
157
+ errors.push(`base.json: ${projectPathErrorMessage(error)}`)
158
+ return []
159
+ }
160
+ const original = file.exists ? file.content : ''
161
+ let current
162
+ try {
163
+ current = file.exists ? JSON.parse(original) : { schemaVersion: 1, packages: {} }
164
+ } catch {
165
+ current = null
166
+ }
167
+ if (
168
+ current === null ||
169
+ current.schemaVersion !== 1 ||
170
+ current.packages === null ||
171
+ typeof current.packages !== 'object' ||
172
+ Array.isArray(current.packages)
173
+ ) {
76
174
  errors.push('base.json: formato inválido; esperado schemaVersion 1 e packages.')
77
175
  return []
78
176
  }
79
- const previous = current.packages[PACKAGE_NAME] ?? {}
80
- const exclude = Array.isArray(previous.exclude) ? previous.exclude : []
177
+ const configured = current.packages[PACKAGE_NAME]
178
+ const validPackageConfig =
179
+ configured === undefined || (configured !== null && typeof configured === 'object' && !Array.isArray(configured))
180
+ if (!validPackageConfig) errors.push(`base.json: ${PACKAGE_NAME} deve ser um objeto; preservado.`)
181
+ const previous = validPackageConfig ? (configured ?? {}) : {}
182
+ let exclude = Array.isArray(previous.exclude) ? previous.exclude : []
81
183
  if (previous.exclude !== undefined && !Array.isArray(previous.exclude)) errors.push('base.json: exclude deve ser uma lista de slugs.')
82
184
  const available = new Set(sourceSkills())
83
185
  for (const slug of exclude) if (typeof slug !== 'string' || !available.has(slug)) errors.push(`base.json: skill excluída desconhecida: ${String(slug)}.`)
186
+ const blocked = exclude.filter((slug) => required.has(slug))
187
+ if (blocked.length > 0) {
188
+ if (mode === 'setup') {
189
+ exclude = exclude.filter((slug) => !required.has(slug))
190
+ configurationChanges.push(`removidas exclusões obrigatórias (${blocked.join(', ')})`)
191
+ } else {
192
+ errors.push(`base.json: skills exigidas por rota permanente foram excluídas: ${blocked.join(', ')}.`)
193
+ }
194
+ }
195
+ if (current.copy === undefined) {
196
+ if (mode === 'setup') current.copy = { inventory: '.base/copy-inventory.json', allowedUppercase: [] }
197
+ else errors.push('base.json: seção copy ausente; rode `opus setup`.')
198
+ } else if (current.copy === null || typeof current.copy !== 'object' || Array.isArray(current.copy)) {
199
+ errors.push('base.json: copy deve ser um objeto; preservado.')
200
+ } else if (current.copy.inventory === undefined) {
201
+ if (mode === 'setup') current.copy.inventory = '.base/copy-inventory.json'
202
+ else errors.push('base.json: copy.inventory ausente; rode `opus setup`.')
203
+ } else if (typeof current.copy.inventory !== 'string' || current.copy.inventory.trim() === '') {
204
+ errors.push('base.json: copy.inventory deve ser um caminho não vazio; preservado.')
205
+ } else {
206
+ try {
207
+ safeProjectPath(root, current.copy.inventory)
208
+ } catch (error) {
209
+ errors.push(`base.json: copy.inventory inseguro: ${projectPathErrorMessage(error)}`)
210
+ }
211
+ }
212
+ if (current.copy !== null && typeof current.copy === 'object' && !Array.isArray(current.copy)) {
213
+ for (const field of ['allowedUppercase', 'exclude', 'exemptions', 'transforms']) {
214
+ if (current.copy[field] !== undefined && !Array.isArray(current.copy[field])) {
215
+ errors.push(`base.json: copy.${field} deve ser uma lista; preservado.`)
216
+ }
217
+ }
218
+ }
219
+ const configuredResolveFrom = previous.resolveFrom
220
+ const validConfiguredDirectory = stableDirectory(root, configuredResolveFrom)
221
+ const inferred = inferredResolveFrom(root, invocationDirectory)
222
+ // Em migrações manuais/fixtures sem package.json, o setup histórico registrava o
223
+ // próprio diretório de invocação. Ele continua estável quando coincide exatamente
224
+ // com a inferência atual; valores fornecidos que apontam para outro lugar falham.
225
+ const validConfiguredWorkspace = validConfiguredDirectory !== null && (
226
+ declaresOpus(root, validConfiguredDirectory) || configuredResolveFrom === inferred
227
+ )
228
+ if (configuredResolveFrom !== undefined && !validConfiguredWorkspace) {
229
+ errors.push('base.json: resolveFrom do Opus deve apontar para o workspace que declara @softize/opus; preservado.')
230
+ }
231
+ const resolveFrom = configuredResolveFrom === undefined
232
+ ? inferred
233
+ : configuredResolveFrom
84
234
  if (mode === 'setup') {
85
- current.packages[PACKAGE_NAME] = { version: PACKAGE_VERSION, exclude }
86
- const rendered = `${JSON.stringify(current, null, 2)}\n`
87
- if (rendered !== original) {
88
- writeFileSync(path, rendered)
89
- changes.push(`${original === '' ? 'criado' : 'atualizado'} base.json`)
235
+ if (stableDirectory(root, resolveFrom) === null) errors.push('base.json: resolveFrom do Opus não aponta para um diretório interno regular.')
236
+ if (errors.length > errorsBeforeConfiguration) return []
237
+ current.packages[PACKAGE_NAME] = { version: PACKAGE_VERSION, exclude, resolveFrom }
238
+ const canonical = `${JSON.stringify(current, null, 2)}\n`
239
+ const rendered = original === '' ? canonical : withProjectLineEndings(canonical, original)
240
+ if (rendered !== original && mutate) {
241
+ try {
242
+ writeProjectFileAtomically(root, 'base.json', rendered, { exists: file.exists, content: original })
243
+ changes.push(...configurationChanges)
244
+ changes.push(`${file.exists ? 'atualizado' : 'criado'} base.json`)
245
+ } catch (error) {
246
+ errors.push(`base.json: ${projectPathErrorMessage(error)}`)
247
+ }
248
+ } else {
249
+ changes.push(...configurationChanges)
250
+ }
251
+ } else {
252
+ if (previous.version !== PACKAGE_VERSION) errors.push(`base.json: Opus aplicado ${previous.version ?? 'ausente'}, instalado ${PACKAGE_VERSION}.`)
253
+ if (validConfiguredDirectory === null || !declaresOpus(root, validConfiguredDirectory)) {
254
+ errors.push('base.json: resolveFrom do Opus deve apontar para o workspace que declara @softize/opus.')
90
255
  }
91
- } else if (previous.version !== PACKAGE_VERSION) errors.push(`base.json: Opus aplicado ${previous.version ?? 'ausente'}, instalado ${PACKAGE_VERSION}.`)
256
+ }
92
257
  return exclude.filter((slug) => available.has(slug))
93
258
  }
94
259
 
260
+ function requiredSkills(available, errors) {
261
+ const instructions = readFileSync(join(REGISTRY, 'instructions/opus.md'), 'utf8')
262
+ for (const error of skillRouteErrors(instructions)) errors.push(`registry/instructions/opus.md: ${error}`)
263
+ const required = new Set()
264
+ const visit = (slug, source) => {
265
+ if (!available.has(slug)) {
266
+ errors.push(`${source}: skill encaminhada ausente: $${slug}.`)
267
+ return
268
+ }
269
+ if (required.has(slug)) return
270
+ required.add(slug)
271
+ for (const reference of skillReferences(join(REGISTRY, 'skills', slug))) {
272
+ visit(reference.dependency, `registry/skills/${slug}/${reference.source}`)
273
+ }
274
+ }
275
+ for (const slug of referencedSkills(instructions)) visit(slug, 'registry/instructions/opus.md')
276
+ return required
277
+ }
278
+
95
279
  function expectedFiles(exclude) {
96
280
  const expected = new Map()
97
281
  const addTree = (sourceRoot, destinationRoot) => {
@@ -110,7 +294,9 @@ function expectedFiles(exclude) {
110
294
  }
111
295
  for (const [source, destination] of [
112
296
  ['registry/hooks/opus-check-on-stop.mjs', '.claude/hooks/opus-check-on-stop.mjs'],
297
+ ['registry/git/pre-push.d/00-opus-copy', '.githooks/pre-push.d/00-opus-copy'],
113
298
  ['registry/git/pre-push.d/opus', '.githooks/pre-push.d/opus'],
299
+ ['registry/git/run-opus-pre-push.mjs', '.githooks/run-opus-pre-push.mjs'],
114
300
  ]) {
115
301
  const content = readFileSync(join(PACKAGE_ROOT, source), 'utf8')
116
302
  expected.set(destination, { source, content, output: addMarker(destination, source, content) })
@@ -118,45 +304,161 @@ function expectedFiles(exclude) {
118
304
  return expected
119
305
  }
120
306
 
307
+ function portableProjectPath(value) {
308
+ return value.split(sep).join('/')
309
+ }
310
+
311
+ function projectPathFailure(errors, destination, error) {
312
+ errors.push(`${portableProjectPath(destination)}: ${projectPathErrorMessage(error)}`)
313
+ }
314
+
315
+ function readMaterializedFile(root, destination, errors, label = destination) {
316
+ try {
317
+ return readProjectFile(root, destination, { allowMissing: true })
318
+ } catch (error) {
319
+ projectPathFailure(errors, label, error)
320
+ return null
321
+ }
322
+ }
323
+
324
+ function ensureMaterializedParents(root, destination) {
325
+ const pending = []
326
+ let parent = dirname(destination)
327
+ while (parent !== '.') {
328
+ pending.unshift(parent)
329
+ const next = dirname(parent)
330
+ if (next === parent) throw new Error('diretório de destino não é relativo ao projeto.')
331
+ parent = next
332
+ }
333
+ for (const directory of pending) ensureProjectDirectory(root, directory)
334
+ }
335
+
336
+ function replaceMaterializedFile(root, destination, output, current, errors, options = {}, label = destination) {
337
+ try {
338
+ ensureMaterializedParents(root, destination)
339
+ writeProjectFileAtomically(
340
+ root,
341
+ destination,
342
+ output,
343
+ { exists: current.exists, content: current.content },
344
+ options,
345
+ )
346
+ return true
347
+ } catch (error) {
348
+ projectPathFailure(errors, label, error)
349
+ return false
350
+ }
351
+ }
352
+
121
353
  function reconcileFile(root, destination, item, mode, errors, changes) {
122
- const path = join(root, destination)
123
- if (!existsSync(path)) {
354
+ const currentFile = readMaterializedFile(root, destination, errors)
355
+ if (currentFile === null) return
356
+ const executable = destination.startsWith('.githooks/')
357
+ const requiredMode = executable ? 0o755 : undefined
358
+ const modeIsCurrent = !executable || (currentFile.mode & 0o777) === requiredMode
359
+ const contentIsCurrent =
360
+ currentFile.exists &&
361
+ (executable ? currentFile.content === item.output : managedTextEquivalent(currentFile.content, item.output))
362
+
363
+ if (!currentFile.exists) {
124
364
  if (mode === 'check') errors.push(`${destination}: ausente.`)
125
- else {
126
- mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, item.output)
127
- if (destination.startsWith('.githooks/')) chmodSync(path, 0o755)
365
+ else if (replaceMaterializedFile(root, destination, item.output, currentFile, errors, { mode: requiredMode })) {
128
366
  changes.push(`criado ${destination}`)
129
367
  }
130
368
  return
131
369
  }
132
- const current = readFileSync(path, 'utf8')
133
- if (current === item.output) return
370
+
371
+ const current = currentFile.content
372
+ if (contentIsCurrent && modeIsCurrent) return
134
373
  const owner = metadata(current)
135
- if (owner?.package !== PACKAGE_NAME) { errors.push(`${destination}: colisão com arquivo não gerenciado pelo Opus.`); return }
136
- if (hash(stripMarker(current)) !== owner.sha256) { errors.push(`${destination}: arquivo gerenciado foi editado; preservado.`); return }
137
- if (mode === 'check') errors.push(`${destination}: desatualizado.`)
138
- else {
139
- writeFileSync(path, item.output)
140
- if (destination.startsWith('.githooks/')) chmodSync(path, 0o755)
141
- changes.push(`atualizado ${destination}`)
374
+ if (owner?.package !== PACKAGE_NAME) {
375
+ errors.push(`${destination}: colisão com arquivo não gerenciado pelo Opus.`)
376
+ return
377
+ }
378
+ if (hash(stripMarker(current)) !== owner.sha256) {
379
+ errors.push(`${destination}: arquivo gerenciado foi editado; preservado.`)
380
+ return
381
+ }
382
+ if (mode === 'check') {
383
+ errors.push(`${destination}: ${contentIsCurrent ? 'modo executável incorreto' : 'desatualizado'}.`)
384
+ } else if (
385
+ replaceMaterializedFile(
386
+ root,
387
+ destination,
388
+ executable ? item.output : withProjectLineEndings(item.output, current),
389
+ currentFile,
390
+ errors,
391
+ { mode: requiredMode },
392
+ )
393
+ ) {
394
+ changes.push(`${contentIsCurrent ? 'corrigido modo executável de' : 'atualizado'} ${destination}`)
395
+ }
396
+ }
397
+
398
+ function projectFilesUnder(root, directory, errors) {
399
+ const files = []
400
+
401
+ function visit(current) {
402
+ let listing
403
+ try {
404
+ listing = readProjectDirectory(root, current, { allowMissing: true })
405
+ } catch (error) {
406
+ projectPathFailure(errors, current, error)
407
+ return
408
+ }
409
+ if (!listing.exists) return
410
+
411
+ for (const entry of listing.entries.sort((left, right) => left.name.localeCompare(right.name))) {
412
+ const path = join(current, entry.name)
413
+ let inspected
414
+ try {
415
+ inspected = safeProjectPath(root, path, { mustExist: true })
416
+ } catch (error) {
417
+ projectPathFailure(errors, path, error)
418
+ continue
419
+ }
420
+ if (inspected.kind === 'directory') visit(path)
421
+ else if (inspected.kind === 'file') files.push(portableProjectPath(path))
422
+ else errors.push(`${portableProjectPath(path)}: entrada gerenciada deve ser arquivo regular ou diretório.`)
423
+ }
142
424
  }
425
+
426
+ visit(directory)
427
+ return files
143
428
  }
144
429
 
145
430
  function removeObsolete(root, expected, mode, errors, changes) {
146
- for (const managedRoot of MANAGED_ROOTS) for (const file of filesUnder(join(root, managedRoot), root)) {
147
- const content = readFileSync(file.path, 'utf8')
148
- const owner = metadata(content)
149
- if (owner?.package !== PACKAGE_NAME || expected.has(file.relative)) continue
150
- if (hash(stripMarker(content)) !== owner.sha256) errors.push(`${file.relative}: obsoleto, mas editado; preservado.`)
151
- else if (mode === 'check') errors.push(`${file.relative}: artefato obsoleto.`)
152
- else { rmSync(file.path); changes.push(`removido ${file.relative}`) }
431
+ for (const managedRoot of MANAGED_ROOTS) {
432
+ for (const destination of projectFilesUnder(root, managedRoot, errors)) {
433
+ const file = readMaterializedFile(root, destination, errors)
434
+ if (file === null || !file.exists) continue
435
+ const content = file.content
436
+ const owner = metadata(content)
437
+ if (owner?.package !== PACKAGE_NAME || expected.has(destination)) continue
438
+ if (hash(stripMarker(content)) !== owner.sha256) {
439
+ errors.push(`${destination}: obsoleto, mas editado; preservado.`)
440
+ } else if (mode === 'check') {
441
+ errors.push(`${destination}: artefato obsoleto.`)
442
+ } else {
443
+ try {
444
+ removeProjectFileIfUnchanged(root, destination, file)
445
+ changes.push(`removido ${destination}`)
446
+ } catch (error) {
447
+ projectPathFailure(errors, destination, error)
448
+ }
449
+ }
450
+ }
153
451
  }
154
452
  }
155
453
 
156
454
  function detectLegacyArtifacts(root, errors) {
157
455
  for (const destination of ['.agents/skills/create-action', '.claude/skills/create-action']) {
158
- if (existsSync(join(root, destination))) {
159
- errors.push(`${destination}: skill legada detectada; remova-a após revisar qualquer edição local. Use create-opus-action.`)
456
+ try {
457
+ if (safeProjectPath(root, destination).exists) {
458
+ errors.push(`${destination}: skill legada detectada; remova-a após revisar qualquer edição local. Use create-opus-action.`)
459
+ }
460
+ } catch (error) {
461
+ projectPathFailure(errors, destination, error)
160
462
  }
161
463
  }
162
464
  }
@@ -169,46 +471,100 @@ function expectedBlock() {
169
471
 
170
472
  function findBlock(content) {
171
473
  const pattern = /<!-- softize-managed:start (\{[^\n]+\}) -->/g
474
+ const starts = []
172
475
  for (const match of content.matchAll(pattern)) {
173
476
  let owner = null
174
477
  try { owner = JSON.parse(match[1]) } catch { /* outro marcador inválido */ }
175
- if (owner?.package !== PACKAGE_NAME) continue
176
- const start = match.index
177
- const openEnd = start + match[0].length
178
- const end = content.indexOf(BLOCK_END, openEnd)
179
- if (end === -1) return { malformed: true }
180
- const bodyStart = openEnd + 1
181
- const bodyEnd = content[end - 1] === '\n' ? end - 1 : end
182
- return { start, end: end + BLOCK_END.length, body: content.slice(bodyStart, bodyEnd), owner }
478
+ if (owner?.package === PACKAGE_NAME) starts.push({ start: match.index, openEnd: match.index + match[0].length, owner })
479
+ }
480
+ const ends = []
481
+ let endAt = content.indexOf(BLOCK_END)
482
+ while (endAt !== -1) {
483
+ ends.push(endAt)
484
+ endAt = content.indexOf(BLOCK_END, endAt + BLOCK_END.length)
183
485
  }
184
- if (/<!-- softize-managed:start [^\n]*@softize\/opus/.test(content)) return { malformed: true }
185
- return null
486
+ const attempts = content
487
+ .split(/\r?\n/)
488
+ .filter((line) => line.includes('softize-managed:') && line.includes(PACKAGE_NAME)).length
489
+
490
+ if (starts.length === 0 && ends.length === 0 && attempts === 0) return null
491
+ if (starts.length !== 1 || ends.length !== 1 || attempts !== 2) return { malformed: true }
492
+ const [{ start, openEnd, owner }] = starts
493
+ const [end] = ends
494
+ if (end <= openEnd || content.slice(openEnd, end).includes('softize-managed:start')) return { malformed: true }
495
+ const openingBreak = content.startsWith('\r\n', openEnd) ? 2 : content[openEnd] === '\n' ? 1 : 0
496
+ if (openingBreak === 0) return { malformed: true }
497
+ const bodyStart = openEnd + openingBreak
498
+ const bodyEnd = content.slice(Math.max(0, end - 2), end) === '\r\n' ? end - 2 : end > 0 && content[end - 1] === '\n' ? end - 1 : end
499
+ return { start, end: end + BLOCK_END.length, body: content.slice(bodyStart, bodyEnd), owner }
186
500
  }
187
501
 
188
502
  function reconcileInstructions(root, file, mode, errors, changes) {
189
- const path = join(root, file)
190
503
  const expected = expectedBlock()
191
- const current = existsSync(path) ? readFileSync(path, 'utf8') : ''
504
+ const currentFile = readMaterializedFile(root, file, errors)
505
+ if (currentFile === null) return
506
+ const current = currentFile.exists ? currentFile.content : ''
192
507
  const block = findBlock(current)
193
508
  if (block?.malformed) { errors.push(`${file}: bloco Opus malformado.`); return }
194
509
  if (block === null) {
195
510
  if (mode === 'check') errors.push(`${file}: bloco Opus ausente.`)
196
- else { writeFileSync(path, `${current.trimEnd()}${current.trimEnd() === '' ? '' : '\n\n'}${expected.text}\n`); changes.push(`atualizado ${file}`) }
511
+ else {
512
+ const suffix = current.trimEnd()
513
+ const newline = lineEndingStyle(current) === 'crlf' ? '\r\n' : '\n'
514
+ const renderedBlock = withProjectLineEndings(expected.text, current)
515
+ const output = `${suffix === '' ? '' : `${suffix}${newline}${newline}`}${renderedBlock}${newline}`
516
+ if (replaceMaterializedFile(root, file, output, currentFile, errors)) changes.push(`atualizado ${file}`)
517
+ }
518
+ return
519
+ }
520
+ if (block.owner?.package !== PACKAGE_NAME || hash(block.body) !== block.owner.sha256) {
521
+ errors.push(`${file}: bloco Opus foi editado ou perdeu sua identificação de origem; preservado.`)
197
522
  return
198
523
  }
199
- if (hash(block.body) !== block.owner.sha256) { errors.push(`${file}: bloco Opus foi editado; preservado.`); return }
200
524
  const actual = current.slice(block.start, block.end)
201
- if (actual === expected.text) return
525
+ if (managedTextEquivalent(actual, expected.text)) return
202
526
  if (mode === 'check') errors.push(`${file}: bloco Opus desatualizado.`)
203
- else { writeFileSync(path, `${current.slice(0, block.start)}${expected.text}${current.slice(block.end)}`); changes.push(`atualizado ${file}`) }
527
+ else {
528
+ const output = `${current.slice(0, block.start)}${withProjectLineEndings(expected.text, actual)}${current.slice(block.end)}`
529
+ if (replaceMaterializedFile(root, file, output, currentFile, errors)) changes.push(`atualizado ${file}`)
530
+ }
204
531
  }
205
532
 
206
533
  function isOpusHook(hook) { return typeof hook?.command === 'string' && hook.command.includes('.claude/hooks/opus-check-on-stop.mjs') }
534
+
535
+ function isPlainObject(value) {
536
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
537
+ const prototype = Object.getPrototypeOf(value)
538
+ return prototype === Object.prototype || prototype === null
539
+ }
540
+
541
+ function validSettings(settings) {
542
+ if (!isPlainObject(settings)) return false
543
+ if (settings.hooks === undefined) return true
544
+ if (!isPlainObject(settings.hooks)) return false
545
+ return Object.values(settings.hooks).every(
546
+ (groups) =>
547
+ Array.isArray(groups) &&
548
+ groups.every(
549
+ (group) =>
550
+ isPlainObject(group) &&
551
+ Array.isArray(group.hooks) &&
552
+ group.hooks.every((hook) => isPlainObject(hook)),
553
+ ),
554
+ )
555
+ }
556
+
207
557
  function reconcileSettings(root, mode, errors, changes) {
208
558
  const destination = '.claude/settings.json'
209
- const path = join(root, destination)
210
- const current = existsSync(path) ? readJson(path) : {}
211
- if (current === null || typeof current !== 'object') { errors.push(`${destination}: JSON inválido; preservado.`); return }
559
+ const currentFile = readMaterializedFile(root, destination, errors)
560
+ if (currentFile === null) return
561
+ let current
562
+ try {
563
+ current = currentFile.exists ? JSON.parse(currentFile.content) : {}
564
+ } catch {
565
+ current = null
566
+ }
567
+ if (!validSettings(current)) { errors.push(`${destination}: JSON ou estrutura de hooks inválida; preservado.`); return }
212
568
  const expected = structuredClone(current); expected.hooks ??= {}
213
569
  for (const event of Object.keys(expected.hooks)) expected.hooks[event] = expected.hooks[event]
214
570
  .map((group) => ({ ...group, hooks: (group.hooks ?? []).filter((hook) => !isOpusHook(hook)) }))
@@ -216,57 +572,217 @@ function reconcileSettings(root, mode, errors, changes) {
216
572
  expected.hooks.Stop = [...(expected.hooks.Stop ?? []), { hooks: [{ type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/opus-check-on-stop.mjs"', timeout: 120 }] }]
217
573
  if (JSON.stringify(current) === JSON.stringify(expected)) return
218
574
  if (mode === 'check') errors.push(`${destination}: hook Opus ausente ou desatualizado.`)
219
- else { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(expected, null, 2)}\n`); changes.push(`atualizado ${destination}`) }
575
+ else {
576
+ const canonical = `${JSON.stringify(expected, null, 2)}\n`
577
+ const rendered = currentFile.exists ? withProjectLineEndings(canonical, currentFile.content) : canonical
578
+ if (replaceMaterializedFile(root, destination, rendered, currentFile, errors)) changes.push(`atualizado ${destination}`)
579
+ }
220
580
  }
221
581
 
222
582
  function reconcileSharedPrePush(root, mode, errors, changes) {
223
583
  const destination = '.githooks/pre-push'
224
- const path = join(root, destination)
225
584
  const expected = readFileSync(join(REGISTRY, 'git/pre-push'), 'utf8')
226
- if (!existsSync(path)) {
585
+ const current = readMaterializedFile(root, destination, errors)
586
+ if (current === null) return
587
+ if (!current.exists) {
227
588
  if (mode === 'check') errors.push(`${destination}: dispatcher compartilhado ausente.`)
228
- else { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, expected); chmodSync(path, 0o755); changes.push(`criado ${destination}`) }
229
- } else if (readFileSync(path, 'utf8') !== expected) errors.push(`${destination}: dispatcher compartilhado incompatível; preservado.`)
230
- else if (mode === 'setup' && (statSync(path).mode & 0o111) === 0) { chmodSync(path, 0o755); changes.push(`corrigido modo executável de ${destination}`) }
589
+ else if (replaceMaterializedFile(root, destination, expected, current, errors, { mode: 0o755 })) {
590
+ changes.push(`criado ${destination}`)
591
+ }
592
+ return
593
+ }
594
+ if (current.content !== expected) {
595
+ if (!managedTextEquivalent(current.content, expected)) {
596
+ errors.push(`${destination}: dispatcher compartilhado incompatível; preservado.`)
597
+ } else if (mode === 'check') {
598
+ errors.push(`${destination}: desatualizado; executáveis Git exigem LF.`)
599
+ } else if (replaceMaterializedFile(root, destination, expected, current, errors, { mode: 0o755 })) {
600
+ changes.push(`atualizado ${destination}`)
601
+ }
602
+ return
603
+ }
604
+ if ((current.mode & 0o777) !== 0o755) {
605
+ if (mode === 'check') errors.push(`${destination}: modo executável incorreto.`)
606
+ else if (replaceMaterializedFile(root, destination, expected, current, errors, { mode: 0o755 })) {
607
+ changes.push(`corrigido modo executável de ${destination}`)
608
+ }
609
+ }
231
610
  }
232
611
 
233
- function configureGit(root, mode, errors, changes) {
234
- if (!existsSync(join(root, '.git'))) return
612
+ function sameProjectFile(left, right) {
613
+ return (
614
+ left.exists === right.exists &&
615
+ (!left.exists ||
616
+ (left.identity.dev === right.identity.dev &&
617
+ left.identity.ino === right.identity.ino &&
618
+ left.mode === right.mode &&
619
+ left.modifiedAtMs === right.modifiedAtMs &&
620
+ left.content === right.content))
621
+ )
622
+ }
623
+
624
+ function gitAdministrativeRoots(root, errors) {
625
+ let dotGit
235
626
  try {
236
- try { execFileSync('git', ['rev-parse', '--git-dir'], { cwd: root, stdio: 'ignore' }) } catch { return }
237
- let hooksPath = ''
238
- try { hooksPath = execFileSync('git', ['config', '--local', '--get', 'core.hooksPath'], { cwd: root, encoding: 'utf8' }).trim() } catch { /* ausente */ }
239
- if (mode === 'setup' && hooksPath === '') execFileSync('git', ['config', '--local', 'core.hooksPath', '.githooks'], { cwd: root })
240
- else if (hooksPath !== '.githooks') errors.push(`git core.hooksPath ${hooksPath === '' ? 'não configurado' : `aponta para ${hooksPath}`}.`)
241
-
242
- const excludePath = execFileSync('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd: root, encoding: 'utf8' }).trim()
243
- const absolute = resolve(root, excludePath)
244
- if (existsSync(absolute)) {
245
- const current = readFileSync(absolute, 'utf8')
246
- const forbidden = new Set([
247
- '.claude/',
248
- '.claude/skills/',
249
- '.claude/hooks/opus-check-on-stop.mjs',
250
- '.agents/',
251
- '.agents/skills/',
252
- ])
253
- const lines = current.split(/\r?\n/)
254
- const blocked = lines.filter((line) => forbidden.has(line.trim()))
255
- if (blocked.length > 0 && mode === 'check') errors.push(`git info/exclude oculta artefatos de agentes: ${blocked.join(', ')}.`)
256
- else if (blocked.length > 0) {
257
- const kept = lines.filter((line) => !forbidden.has(line.trim())).join('\n').replace(/\n+$/, '')
258
- writeFileSync(absolute, kept === '' ? '' : `${kept}\n`)
259
- changes.push('removidas exclusões locais de artefatos de agentes')
627
+ dotGit = safeProjectPath(root, '.git')
628
+ } catch (error) {
629
+ projectPathFailure(errors, '.git', error)
630
+ return null
631
+ }
632
+ if (!dotGit.exists) return undefined
633
+
634
+ let administrativeRoot
635
+ if (dotGit.kind === 'directory') {
636
+ try {
637
+ administrativeRoot = canonicalProjectDirectory(dotGit.path)
638
+ } catch (error) {
639
+ projectPathFailure(errors, '.git', error)
640
+ return null
641
+ }
642
+ } else if (dotGit.kind === 'file') {
643
+ let gitFile
644
+ try {
645
+ gitFile = readProjectFile(root, '.git')
646
+ } catch (error) {
647
+ projectPathFailure(errors, '.git', error)
648
+ return null
649
+ }
650
+ const match = /^gitdir: ([^\0\r\n]+)\r?\n?$/u.exec(gitFile.content)
651
+ if (match === null || match[1].trim() !== match[1]) {
652
+ errors.push('.git: gitfile inválido; esperado um único caminho gitdir.')
653
+ return null
654
+ }
655
+ try {
656
+ administrativeRoot = canonicalProjectDirectory(resolve(root, match[1]))
657
+ } catch (error) {
658
+ projectPathFailure(errors, '.git', error)
659
+ return null
660
+ }
661
+ } else {
662
+ errors.push('.git: deve ser diretório administrativo ou gitfile regular.')
663
+ return null
664
+ }
665
+ if (!validGitDirectory(administrativeRoot)) {
666
+ errors.push('.git: diretório administrativo inválido ou HEAD inseguro.')
667
+ return null
668
+ }
669
+
670
+ let commonDirectory
671
+ try {
672
+ commonDirectory = readProjectFile(administrativeRoot, 'commondir', { allowMissing: true })
673
+ } catch (error) {
674
+ projectPathFailure(errors, '.git/commondir', error)
675
+ return null
676
+ }
677
+ if (!commonDirectory.exists) return { administrativeRoot, commonRoot: administrativeRoot }
678
+ const commonMatch = /^([^\0\r\n]+)\r?\n?$/u.exec(commonDirectory.content)
679
+ if (commonMatch === null || commonMatch[1].trim() !== commonMatch[1]) {
680
+ errors.push('.git/commondir: caminho inválido.')
681
+ return null
682
+ }
683
+ try {
684
+ return { administrativeRoot, commonRoot: canonicalProjectDirectory(resolve(administrativeRoot, commonMatch[1])) }
685
+ } catch (error) {
686
+ projectPathFailure(errors, '.git/commondir', error)
687
+ return null
688
+ }
689
+ }
690
+
691
+ function appendGitHooksPath(content) {
692
+ const newline = lineEndingStyle(content) === 'crlf' ? '\r\n' : '\n'
693
+ const separator = content === '' ? '' : content.endsWith('\n') ? newline : `${newline}${newline}`
694
+ return `${content}${separator}[core]${newline}\thooksPath = .githooks${newline}`
695
+ }
696
+
697
+ function effectiveGitHooksPath(root, git, commonConfig, worktreeConfig, errors) {
698
+ let effective
699
+ try {
700
+ const output = execFileSync('git', ['config', '--get', 'core.hooksPath'], { cwd: root, encoding: 'utf8' })
701
+ const parsed = /^([^\0\r\n]*)(?:\r?\n)?$/u.exec(output)
702
+ if (parsed === null) {
703
+ errors.push('git core.hooksPath efetivo: valor multilinha ou malformado.')
704
+ return null
705
+ }
706
+ effective = parsed[1]
707
+ } catch (error) {
708
+ if (error?.status === 1) effective = ''
709
+ else {
710
+ errors.push(`git core.hooksPath efetivo: ${String(error).slice(0, 160)}.`)
711
+ return null
712
+ }
713
+ }
714
+ const commonAfter = readMaterializedFile(git.commonRoot, 'config', errors, '.git/config')
715
+ const worktreeAfter = readMaterializedFile(git.administrativeRoot, 'config.worktree', errors, '.git/config.worktree')
716
+ if (commonAfter === null || worktreeAfter === null) return null
717
+ if (!sameProjectFile(commonConfig, commonAfter) || !sameProjectFile(worktreeConfig, worktreeAfter)) {
718
+ errors.push('git core.hooksPath efetivo: configuração mudou durante a leitura.')
719
+ return null
720
+ }
721
+ return effective
722
+ }
723
+
724
+ function configureGit(root, mode, errors, changes) {
725
+ const git = gitAdministrativeRoots(root, errors)
726
+ if (git === undefined || git === null) return
727
+
728
+ const config = readMaterializedFile(git.commonRoot, 'config', errors, '.git/config')
729
+ if (config === null) return
730
+ const worktreeConfig = readMaterializedFile(git.administrativeRoot, 'config.worktree', errors, '.git/config.worktree')
731
+ if (worktreeConfig === null) return
732
+ const current = effectiveGitHooksPath(root, git, config, worktreeConfig, errors)
733
+ if (current === null) return
734
+ if (current === '') {
735
+ if (mode === 'check') errors.push('git core.hooksPath não configurado.')
736
+ else {
737
+ const output = appendGitHooksPath(config.exists ? config.content : '')
738
+ if (replaceMaterializedFile(git.commonRoot, 'config', output, config, errors, {}, '.git/config')) {
739
+ changes.push('configurado git core.hooksPath')
740
+ const updated = readMaterializedFile(git.commonRoot, 'config', errors, '.git/config')
741
+ if (updated === null) return
742
+ const effective = effectiveGitHooksPath(root, git, updated, worktreeConfig, errors)
743
+ if (effective !== null && effective !== '.githooks') {
744
+ errors.push(`git core.hooksPath efetivo é ${effective || 'ausente'} após a configuração.`)
745
+ }
260
746
  }
261
747
  }
262
- } catch (error) { errors.push(`git: ${String(error).slice(0, 140)}.`) }
748
+ } else if (current !== '.githooks') {
749
+ errors.push(`git core.hooksPath já aponta para ${current}; não alterado.`)
750
+ }
751
+
752
+ const destination = 'info/exclude'
753
+ const label = '.git/info/exclude'
754
+ const exclude = readMaterializedFile(git.commonRoot, destination, errors, label)
755
+ if (exclude === null || !exclude.exists) return
756
+ const forbidden = new Set([
757
+ '.claude/',
758
+ '.claude/skills/',
759
+ '.claude/hooks/opus-check-on-stop.mjs',
760
+ '.agents/',
761
+ '.agents/skills/',
762
+ ])
763
+ const lines = exclude.content.split(/\r?\n/)
764
+ const blocked = lines.filter((line) => forbidden.has(line.trim()))
765
+ if (blocked.length > 0 && mode === 'check') {
766
+ errors.push(`git info/exclude oculta artefatos de agentes: ${blocked.join(', ')}.`)
767
+ } else if (blocked.length > 0) {
768
+ const newline = lineEndingStyle(exclude.content) === 'crlf' ? '\r\n' : '\n'
769
+ const kept = lines.filter((line) => !forbidden.has(line.trim())).join(newline).replace(/(?:\r?\n)+$/u, '')
770
+ const output = kept === '' ? '' : `${kept}${newline}`
771
+ if (replaceMaterializedFile(git.commonRoot, destination, output, exclude, errors, {}, label)) {
772
+ changes.push('removidas exclusões locais de artefatos de agentes')
773
+ }
774
+ }
263
775
  }
264
776
 
265
- export function materializeOpus(root, mode = 'setup') {
777
+ export function materializeOpus(root, mode = 'setup', options = {}) {
266
778
  const target = resolve(root)
267
- const errors = sourceSkills().flatMap((slug) => validateSkill(join(REGISTRY, 'skills', slug)).map((error) => `skills/${slug}: ${error}`))
779
+ const skills = sourceSkills()
780
+ const errors = skills.flatMap((slug) => validateSkill(join(REGISTRY, 'skills', slug)).map((error) => `skills/${slug}: ${error}`))
781
+ const required = requiredSkills(new Set(skills), errors)
268
782
  const changes = []
269
- const exclude = config(target, mode, errors, changes)
783
+ if (errors.length > 0) return { ok: false, errors, changes, expected: [] }
784
+ const exclude = config(target, mode, errors, changes, required, options.invocationDirectory)
785
+ if (errors.length > 0) return { ok: false, errors, changes, expected: [] }
270
786
  const expected = expectedFiles(exclude)
271
787
  reconcileSharedPrePush(target, mode, errors, changes)
272
788
  for (const [destination, item] of expected) reconcileFile(target, destination, item, mode, errors, changes)
@@ -278,3 +794,13 @@ export function materializeOpus(root, mode = 'setup') {
278
794
  configureGit(target, mode, errors, changes)
279
795
  return { ok: errors.length === 0, errors, changes, expected: [...expected.keys()] }
280
796
  }
797
+
798
+ /** Valida sources e configuração reparável do setup sem tocar no projeto. */
799
+ export function validateOpusSetup(root, options = {}) {
800
+ const target = resolve(root)
801
+ const skills = sourceSkills()
802
+ const errors = skills.flatMap((slug) => validateSkill(join(REGISTRY, 'skills', slug)).map((error) => `skills/${slug}: ${error}`))
803
+ const required = requiredSkills(new Set(skills), errors)
804
+ if (errors.length === 0) config(target, 'setup', errors, [], required, options.invocationDirectory, false)
805
+ return errors
806
+ }