@softize/opus 11.1.1 → 12.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -1
- package/README.md +31 -1
- package/bin/cli.mjs +97 -27
- package/bin/lib/check.mjs +55 -43
- package/bin/lib/copy.mjs +2202 -0
- package/bin/lib/create.mjs +227 -39
- package/bin/lib/db-migrate-runner.mjs +9 -6
- package/bin/lib/db-project-path.mjs +20 -0
- package/bin/lib/db-scaffold-runner.mjs +23 -8
- package/bin/lib/db.mjs +6 -4
- package/bin/lib/gen.mjs +60 -29
- package/bin/lib/init.mjs +212 -56
- package/bin/lib/introspect.mjs +3 -2
- package/bin/lib/materialize.mjs +623 -97
- package/bin/lib/postinstall.mjs +6 -5
- package/bin/lib/validate-skill.mjs +502 -30
- package/docs/code-style.md +142 -7
- package/docs/consumer-upgrade-propagation.md +4 -3
- package/docs/releasing.md +28 -17
- package/package.json +6 -1
- package/registry/git/pre-push.d/00-opus-copy +14 -0
- package/registry/git/pre-push.d/opus +7 -21
- package/registry/git/run-opus-pre-push.mjs +141 -0
- package/registry/hooks/opus-check-on-stop.mjs +13 -31
- package/registry/instructions/opus.md +11 -5
- package/registry/skills/build-opus-ui/SKILL.md +5 -4
- package/registry/skills/create-opus-action/SKILL.md +4 -4
- package/registry/skills/implement-opus-change/SKILL.md +7 -5
- package/registry/skills/upgrade-opus/SKILL.md +8 -4
- package/registry/skills/upgrade-opus/references/upgrade-checklist.md +4 -1
- package/registry/templates/app/package.json +4 -0
- package/registry/templates/app/pnpm-workspace.yaml +3 -2
- package/registry/templates/app/src/domains/tasks/actions/list.ts +1 -1
- package/registry/templates/monorepo/pnpm-workspace.yaml +3 -1
- package/src/ui/docs/content/cli.md +8 -7
- package/src/ui/docs/content/communication.md +79 -126
- package/src/ui/docs/content/getting-started.md +29 -16
- package/registry/skills/write-product-communication/SKILL.md +0 -28
- package/registry/skills/write-product-communication/agents/openai.yaml +0 -4
- package/registry/templates/app/_npmrc +0 -1
- package/registry/templates/monorepo/_npmrc +0 -1
package/bin/lib/materialize.mjs
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { execFileSync } from 'node:child_process'
|
|
3
|
-
import {
|
|
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 {
|
|
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
|
|
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
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (
|
|
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
|
|
80
|
-
const
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
}
|
|
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
|
|
123
|
-
if (
|
|
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
|
-
|
|
133
|
-
|
|
370
|
+
|
|
371
|
+
const current = currentFile.content
|
|
372
|
+
if (contentIsCurrent && modeIsCurrent) return
|
|
134
373
|
const owner = metadata(current)
|
|
135
|
-
if (owner?.package !== PACKAGE_NAME) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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)
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
|
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 {
|
|
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
|
|
525
|
+
if (managedTextEquivalent(actual, expected.text)) return
|
|
202
526
|
if (mode === 'check') errors.push(`${file}: bloco Opus desatualizado.`)
|
|
203
|
-
else {
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
234
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|