@ingeniomaps/cauce 0.53.0 → 0.53.1
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 +26 -0
- package/automatization/hooks/guard-dependencies.sh +1 -1
- package/automatization/hooks/guard-destructive.sh +1 -1
- package/automatization/hooks/guard-engine.sh +1 -1
- package/automatization/hooks/guard-generated.sh +1 -1
- package/automatization/hooks/guard-git-add.sh +1 -1
- package/automatization/hooks/guard-governance.sh +1 -1
- package/automatization/hooks/guard-integration-snapshot.sh +1 -1
- package/automatization/hooks/guard-migrations.sh +1 -1
- package/automatization/hooks/guard-planning-drift.sh +1 -1
- package/automatization/hooks/guard-secrets.sh +1 -1
- package/automatization/hooks/guard-test-evidence.sh +1 -1
- package/automatization/hooks/guard-verify.sh +1 -1
- package/automatization/hooks/guard-workspace-boundary.sh +1 -1
- package/automatization/hooks/run-hook.sh +3 -2
- package/automatization/runners/antigravity/hook.js +13 -14
- package/automatization/shared/eval-only.js +3 -3
- package/automatization/workflows/agent-eval.js +3 -3
- package/automatization/workflows/agent-promote.js +4 -4
- package/automatization/workflows/autobuild.js +12 -12
- package/automatization/workflows/flow-eval.js +10 -7
- package/automatization/workflows/flow.js +9 -10
- package/automatization/workflows/onboard.js +4 -5
- package/engine/agents/evaluations.js +18 -16
- package/engine/agents/learning-files.js +111 -0
- package/engine/agents/learning-sources.js +166 -0
- package/engine/agents/learning.js +93 -300
- package/engine/automation/config.js +175 -0
- package/engine/automation/hooks.js +96 -0
- package/engine/automation/index.js +17 -431
- package/engine/automation/roles.js +72 -0
- package/engine/automation/runners.js +162 -0
- package/engine/cli/catalog.js +7 -12
- package/engine/cli/instance.js +11 -10
- package/engine/cli/io.js +1 -1
- package/engine/cli/ops.js +12 -10
- package/engine/cli/wiring.js +2 -4
- package/engine/config/validate.js +2 -1
- package/engine/core/frontmatter.js +2 -1
- package/engine/core/ownership.js +5 -4
- package/engine/core/scan.js +3 -0
- package/engine/flows/registry.js +2 -2
- package/engine/hooks/files.js +160 -0
- package/engine/hooks/input.js +129 -0
- package/engine/hooks/run.js +24 -449
- package/engine/hooks/shell.js +197 -0
- package/engine/integrations/registry.js +2 -0
- package/engine/planning/contracts.js +12 -10
- package/engine/planning/parser.js +4 -3
- package/engine/planning/state.js +2 -1
- package/package.json +5 -5
|
@@ -9,326 +9,19 @@ const P = require('../planning/parser')
|
|
|
9
9
|
const catalog = require('../agents/catalog')
|
|
10
10
|
const O = require('../core/ownership')
|
|
11
11
|
const M = require('../core/manifest')
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// hooks— y encontrarlo daría por buena una dependencia sin instalar.
|
|
26
|
-
function packagedAutomation(root) {
|
|
27
|
-
const runners = O.packagePath(root, path.join('automatization', 'runners'))
|
|
28
|
-
return runners ? path.dirname(runners) : ''
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function runnerManifest(root, name) {
|
|
32
|
-
if (!RUNNER_NAMES.includes(name)) {
|
|
33
|
-
throw new Error(`runner debe ser ${RUNNER_NAMES.join(', ')}`)
|
|
34
|
-
}
|
|
35
|
-
const packaged = packagedAutomation(root)
|
|
36
|
-
if (!packaged) {
|
|
37
|
-
throw new Error('no encuentro automatization/: corré "npm install" en la raíz del repo ops')
|
|
38
|
-
}
|
|
39
|
-
const file = path.join(packaged, 'runners', name, 'manifest.json')
|
|
40
|
-
try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch (error) {
|
|
41
|
-
throw new Error(`${name}: manifest inválido (${error.message})`)
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function mergeConfig(current, incoming) {
|
|
46
|
-
if (Array.isArray(incoming)) {
|
|
47
|
-
const values = [...(Array.isArray(current) ? current : []), ...incoming]
|
|
48
|
-
const seen = new Set()
|
|
49
|
-
return values.filter((value) => {
|
|
50
|
-
const key = JSON.stringify(value)
|
|
51
|
-
if (seen.has(key)) return false
|
|
52
|
-
seen.add(key)
|
|
53
|
-
return true
|
|
54
|
-
})
|
|
55
|
-
}
|
|
56
|
-
if (incoming && typeof incoming === 'object') {
|
|
57
|
-
const object = current && typeof current === 'object' && !Array.isArray(current)
|
|
58
|
-
const result = object ? { ...current } : {}
|
|
59
|
-
for (const [key, value] of Object.entries(incoming)) {
|
|
60
|
-
result[key] = mergeConfig(result[key], value)
|
|
61
|
-
}
|
|
62
|
-
return result
|
|
63
|
-
}
|
|
64
|
-
return incoming
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Una entrada de hook que puso Cauce se reconoce por el guard al que apunta: `automatization/hooks/`
|
|
68
|
-
// es nuestro y ninguna empresa escribe ahí. Se saca del archivo del usuario antes de fusionar para que
|
|
69
|
-
// el merge deje exactamente las de esta versión, ni una más.
|
|
70
|
-
const ENTREGADO = /automatization\/hooks\/guard-[a-z-]+\.sh/
|
|
71
|
-
|
|
72
|
-
function withoutDeliveredHooks(config, live) {
|
|
73
|
-
const dropped = []
|
|
74
|
-
const walk = (node) => {
|
|
75
|
-
if (Array.isArray(node)) {
|
|
76
|
-
return node
|
|
77
|
-
.filter((item) => {
|
|
78
|
-
const command = item && typeof item === 'object' ? String(item.command || '') : ''
|
|
79
|
-
if (!ENTREGADO.test(command)) return true
|
|
80
|
-
// Sólo se anuncia lo que ya no vuelve: una entrada que el merge repone quedó igual, y decir
|
|
81
|
-
// que se quitó y se puso la misma línea es ruido que esconde el caso que sí importa.
|
|
82
|
-
if (!live.has(command)) dropped.push(command)
|
|
83
|
-
return false
|
|
84
|
-
})
|
|
85
|
-
.map(walk)
|
|
86
|
-
.filter((item) => !(item && typeof item === 'object' && Array.isArray(item.hooks) && !item.hooks.length))
|
|
87
|
-
}
|
|
88
|
-
if (node && typeof node === 'object') {
|
|
89
|
-
return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, walk(value)]))
|
|
90
|
-
}
|
|
91
|
-
return node
|
|
92
|
-
}
|
|
93
|
-
return { config: walk(config), dropped }
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Por qué se fue cada entrada nuestra. Un guard suelto que ahora cubre un grupo no es lo mismo que una
|
|
97
|
-
// ruta que dejó de existir: el primero se ejecutaba dos veces por herramienta —con `verify`, la suite
|
|
98
|
-
// entera del proyecto dos veces por commit—, y el segundo no se ejecutaba nunca. Decirlo distinto es lo
|
|
99
|
-
// único que le permite a alguien darse cuenta de cuál de los dos tenía.
|
|
100
|
-
function reportRemoved(name, dropped, live, output) {
|
|
101
|
-
const wrappers = new Map()
|
|
102
|
-
const loose = []
|
|
103
|
-
for (const command of dropped) {
|
|
104
|
-
const hit = supersededGuards().find(
|
|
105
|
-
(entry) => command.endsWith(entry.file) && [...live].some((v) => v.endsWith(entry.wrapper)),
|
|
106
|
-
)
|
|
107
|
-
if (hit) wrappers.set(hit.wrapper, [...(wrappers.get(hit.wrapper) || []), hit.file])
|
|
108
|
-
else loose.push(command)
|
|
109
|
-
}
|
|
110
|
-
for (const [wrapper, files] of wrappers) {
|
|
111
|
-
output.log(`− ${name}: reemplazado ${[...new Set(files)].join(', ')} por ${wrapper}`)
|
|
112
|
-
}
|
|
113
|
-
for (const command of loose) output.log(`− ${name}: quitada una entrada obsoleta (${command})`)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function includesConfig(actual, expected) {
|
|
117
|
-
if (Array.isArray(expected)) {
|
|
118
|
-
return Array.isArray(actual) && expected.every((item) => {
|
|
119
|
-
return actual.some((value) => JSON.stringify(value) === JSON.stringify(item))
|
|
120
|
-
})
|
|
121
|
-
}
|
|
122
|
-
if (expected && typeof expected === 'object') {
|
|
123
|
-
return actual && typeof actual === 'object' && Object.entries(expected).every(([key, value]) => {
|
|
124
|
-
return includesConfig(actual[key], value)
|
|
125
|
-
})
|
|
126
|
-
}
|
|
127
|
-
return actual === expected
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Dónde abre el dev su herramienta, que no siempre es la raíz ops. En modo sidecar el repo ops es
|
|
131
|
-
// un hermano de los repos de producto: `<empresa>-ops/` coordina, `<empresa>/` es lo que se abre.
|
|
132
|
-
// Instalar dentro del sidecar dejaría al runner sin ver una sola línea de código.
|
|
133
|
-
function installRoot(root) {
|
|
134
|
-
try {
|
|
135
|
-
const config = JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8'))
|
|
136
|
-
if (config.mode === 'sidecar') return path.resolve(root, '..')
|
|
137
|
-
} catch { /* sin configuración legible, instalar donde está */ }
|
|
138
|
-
return root
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Cómo se nombra la raíz ops desde ahí: `<empresa>-ops/` en sidecar, vacío cuando coinciden.
|
|
142
|
-
function opsPrefix(root) {
|
|
143
|
-
const relative = path.relative(installRoot(root), root)
|
|
144
|
-
return relative ? `${relative.split(path.sep).join('/')}/` : ''
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function runnerPaths(root, name, runner) {
|
|
148
|
-
const automationRoot = packagedAutomation(root)
|
|
149
|
-
const sourceDir = path.join(automationRoot, 'runners', name)
|
|
150
|
-
const install = installRoot(root)
|
|
151
|
-
const configSource = F.assertWithin(
|
|
152
|
-
sourceDir,
|
|
153
|
-
path.resolve(sourceDir, runner.config.source),
|
|
154
|
-
`${name}: config.source`,
|
|
155
|
-
)
|
|
156
|
-
const configTarget = F.assertWithin(
|
|
157
|
-
install,
|
|
158
|
-
path.resolve(install, runner.config.target),
|
|
159
|
-
`${name}: config.target`,
|
|
160
|
-
)
|
|
161
|
-
return { automationRoot, sourceDir, configSource, configTarget, install }
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function resolveItem(paths, root, name, item) {
|
|
165
|
-
return {
|
|
166
|
-
automationRoot: paths.automationRoot,
|
|
167
|
-
opsRoot: root,
|
|
168
|
-
source: F.assertWithin(
|
|
169
|
-
paths.automationRoot,
|
|
170
|
-
path.resolve(paths.sourceDir, item.source),
|
|
171
|
-
`${name}: source`,
|
|
172
|
-
),
|
|
173
|
-
target: F.assertWithin(
|
|
174
|
-
paths.install,
|
|
175
|
-
path.resolve(paths.install, item.target),
|
|
176
|
-
`${name}: target`,
|
|
177
|
-
),
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Todo lo que un adaptador copia —configuración, instrucciones, workflows— nombra rutas relativas a
|
|
182
|
-
// la carpeta donde se abre la herramienta. Cuando la raíz ops no es esa carpeta, cada una necesita el
|
|
183
|
-
// prefijo. El marcador es explícito en la fuente en vez de adivinarse con reemplazos de texto:
|
|
184
|
-
// `{{OPS_DIR}}` significa "acá va la raíz ops, o nada si coinciden".
|
|
185
|
-
//
|
|
186
|
-
// Un solo render, y `install` escribe exactamente lo que `doctor` compara.
|
|
187
|
-
const OPS_DIR = '{{OPS_DIR}}'
|
|
188
|
-
|
|
189
|
-
// Un fragmento que varios adaptadores comparten, resuelto contra la raíz de `automatization/`. El
|
|
190
|
-
// arranque es el mismo trabajo en tres formatos —la sección de un `AGENTS.md`, el cuerpo de un
|
|
191
|
-
// `SKILL.md`, el prompt de un `.toml`—, y escrito tres veces hizo lo que hace siempre una copia: dos
|
|
192
|
-
// de ellas anunciaban cinco puntos y enumeraban seis, con el sexto doblado dentro del quinto.
|
|
193
|
-
//
|
|
194
|
-
// El workflow de Claude queda afuera a propósito: es un programa con fases y esquemas, no una prosa
|
|
195
|
-
// enmarcada, así que su arranque no es una copia de éste sino otra cosa.
|
|
196
|
-
//
|
|
197
|
-
// Se resuelve antes que `{{OPS_DIR}}` para que el fragmento también reciba el prefijo, y no anida:
|
|
198
|
-
// lo incluido se copia tal cual.
|
|
199
|
-
const INCLUDE = /\{\{INCLUDE:([^}]+)\}\}/g
|
|
200
|
-
|
|
201
|
-
function inline(text, automationRoot) {
|
|
202
|
-
return text.replace(INCLUDE, (_, relative) => {
|
|
203
|
-
const shared = F.assertWithin(
|
|
204
|
-
automationRoot,
|
|
205
|
-
path.resolve(automationRoot, relative.trim()),
|
|
206
|
-
'INCLUDE',
|
|
207
|
-
)
|
|
208
|
-
return fs.readFileSync(shared, 'utf8').trimEnd()
|
|
209
|
-
})
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// `{{OPS_ROOT}}` es la raíz absoluta. La necesita quien no puede deducirla de dónde lo ejecutaron
|
|
213
|
-
// —el puente de Antigravity—, y por eso no reemplaza a `{{OPS_DIR}}`: una ruta absoluta escrita en un
|
|
214
|
-
// archivo se rompe si el proyecto se mueve, así que la lleva sólo el que se queda sin alternativa.
|
|
215
|
-
const OPS_ROOT = '{{OPS_ROOT}}'
|
|
216
|
-
|
|
217
|
-
function render(file, prefix, automationRoot, opsRoot = '') {
|
|
218
|
-
return inline(fs.readFileSync(file, 'utf8'), automationRoot)
|
|
219
|
-
.split(OPS_ROOT).join(opsRoot)
|
|
220
|
-
.split(OPS_DIR).join(prefix)
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function runnerConfig(paths, root) {
|
|
224
|
-
return JSON.parse(render(paths.configSource, opsPrefix(root), paths.automationRoot, root))
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Cargos del catálogo, con el frontmatter que el runner indexa para elegir a quién invocar.
|
|
228
|
-
function roleCatalog(root) {
|
|
229
|
-
return catalog.list(root)
|
|
230
|
-
.map((role) => {
|
|
231
|
-
const field = P.frontmatter(fs.readFileSync(path.join(role.dir, 'SKILL.md'), 'utf8'))
|
|
232
|
-
const reference = path.relative(installRoot(root), role.dir).split(path.sep).join('/')
|
|
233
|
-
return { ...role, reference, description: field('description') }
|
|
234
|
-
})
|
|
235
|
-
.filter((role) => role.description)
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// Puntero fino: conserva nombre y descripción —lo único que el runner lee hasta invocar— y remite
|
|
239
|
-
// al contrato completo. Evita duplicar el catálogo entero dentro de la configuración del runner.
|
|
240
|
-
function roleSkill(role) {
|
|
241
|
-
return `---
|
|
242
|
-
name: ${role.slug}
|
|
243
|
-
description: ${role.description}
|
|
244
|
-
---
|
|
245
|
-
|
|
246
|
-
# ${role.slug}
|
|
247
|
-
|
|
248
|
-
Leé \`${role.reference}/SKILL.md\` para el contrato completo del cargo: cuándo actuar,
|
|
249
|
-
qué decide, qué no le corresponde y cuál es su entrega mínima. Sus métodos y formatos de output están
|
|
250
|
-
en \`${role.reference}/references/\`.
|
|
251
|
-
|
|
252
|
-
Esas rutas se resuelven desde este directorio raíz, no desde el repositorio de operaciones: en modo
|
|
253
|
-
sidecar el wiring vive acá y el repo ops es uno de sus hijos.
|
|
254
|
-
|
|
255
|
-
Respetá los límites de ese contrato y las reglas de \`AGENTS.md\`. Generado por
|
|
256
|
-
\`cauce automation install\`: no lo edites acá.
|
|
257
|
-
`
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function installRoleSkills(root, runner, output) {
|
|
261
|
-
if (!runner.capabilities.nativeSkills || !runner.roleSkills) return
|
|
262
|
-
const install = installRoot(root)
|
|
263
|
-
const base = F.assertWithin(install, path.resolve(install, runner.roleSkills), `${runner.name}: roleSkills`)
|
|
264
|
-
const roles = roleCatalog(root)
|
|
265
|
-
// Los cargos y los recorridos comparten el espacio de nombres de skills del runner, así que un cargo
|
|
266
|
-
// que se llame como un recorrido lo pisa. Hoy no pasa, y por eso mismo hay que detenerlo acá: el
|
|
267
|
-
// catálogo de una empresa es suyo, nadie le prohíbe un cargo `flow`, y el daño sería que `/cauce:flow`
|
|
268
|
-
// deje de existir sin que nada falle. Renombrar el cargo es la salida, y sólo la puede tomar alguien.
|
|
269
|
-
const commandNames = new Set((runner.commands && runner.commands.names) || [])
|
|
270
|
-
const collide = roles.filter((role) => commandNames.has(role.slug)).map((role) => role.slug)
|
|
271
|
-
if (collide.length) {
|
|
272
|
-
throw new Error(
|
|
273
|
-
`${runner.name}: ${collide.join(', ')} es a la vez un cargo y un recorrido, y comparten `
|
|
274
|
-
+ `${runner.roleSkills}. Renombrá el cargo en agents/roles/ antes de instalar.`,
|
|
275
|
-
)
|
|
276
|
-
}
|
|
277
|
-
for (const role of roles) {
|
|
278
|
-
const file = path.join(base, role.slug, 'SKILL.md')
|
|
279
|
-
F.assertNoSymlinkPath(install, file)
|
|
280
|
-
F.atomicWrite(file, roleSkill(role))
|
|
281
|
-
}
|
|
282
|
-
if (roles.length) output.log(`✓ ${runner.name}: ${roles.length} cargo(s) disponibles en ${runner.roleSkills}`)
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// Runners que además de los archivos necesitan un registro propio para que el wiring cuente. Copiar
|
|
286
|
-
// y quedarse ahí deja un plugin inerte: los archivos están, `doctor` da verde y nada se ejecuta.
|
|
287
|
-
function activated(runner) {
|
|
288
|
-
if (!runner.activation) return true
|
|
289
|
-
const result = spawnSync(runner.command, runner.activation.verify, { encoding: 'utf8' })
|
|
290
|
-
if (result.status !== 0) return null
|
|
291
|
-
return `${result.stdout || ''}`.includes(runner.activation.expect)
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function hasHooks(config) {
|
|
295
|
-
if (config.hooks && Object.keys(config.hooks).length) return true
|
|
296
|
-
const events = [
|
|
297
|
-
'PreToolUse',
|
|
298
|
-
'PostToolUse',
|
|
299
|
-
'PreInvocation',
|
|
300
|
-
'PostInvocation',
|
|
301
|
-
'Stop',
|
|
302
|
-
'SessionEnd',
|
|
303
|
-
]
|
|
304
|
-
return Object.values(config).some((entry) => {
|
|
305
|
-
return entry && typeof entry === 'object' && events.some((event) => event in entry)
|
|
306
|
-
})
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// Guards de la instancia que ya no coinciden con los del paquete. Existir y ser ejecutable no
|
|
310
|
-
// alcanza: un guard viejo no falla, deja de proteger sin decir nada. La instancia declaraba una
|
|
311
|
-
// versión y nadie comprobaba que su runtime fuera realmente esa.
|
|
312
|
-
function staleHooks(root) {
|
|
313
|
-
const packaged = packagedAutomation(root)
|
|
314
|
-
if (!packaged) return []
|
|
315
|
-
const shipped = path.join(packaged, 'hooks')
|
|
316
|
-
const mine = path.join(root, 'automatization', 'hooks')
|
|
317
|
-
const recorded = M.read(root)
|
|
318
|
-
const stale = []
|
|
319
|
-
let names = []
|
|
320
|
-
try { names = fs.readdirSync(shipped) } catch { return [] }
|
|
321
|
-
for (const file of names) {
|
|
322
|
-
const local = path.join(mine, file)
|
|
323
|
-
// Los que faltan ya los reporta el chequeo de arriba; acá sólo interesa el que quedó atrás.
|
|
324
|
-
if (!fs.existsSync(local)) continue
|
|
325
|
-
const current = M.digest(local)
|
|
326
|
-
if (current === M.digest(path.join(shipped, file))) continue
|
|
327
|
-
const delivered = recorded[`automatization/hooks/${file}`]
|
|
328
|
-
stale.push({ file, edited: Boolean(delivered) && delivered !== current })
|
|
329
|
-
}
|
|
330
|
-
return stale
|
|
331
|
-
}
|
|
12
|
+
const {
|
|
13
|
+
RUNNER_NAMES, OPS_DIR, OPS_ROOT, packagedAutomation, runnerManifest, installRoot, opsPrefix,
|
|
14
|
+
runnerPaths, resolveItem, inline, render, runnerConfig, activated,
|
|
15
|
+
} = require('./runners')
|
|
16
|
+
const { roleCatalog, roleSkill, installRoleSkills } = require('./roles')
|
|
17
|
+
const {
|
|
18
|
+
GUARD_NAMES, groupWrappers, expectedHooks, supersededGuards,
|
|
19
|
+
legacyGuardWiring, staleHooks, listHooks,
|
|
20
|
+
} = require('./hooks')
|
|
21
|
+
const {
|
|
22
|
+
blockStart, mergeConfig, withoutDeliveredHooks, reportRemoved, includesConfig, hasHooks,
|
|
23
|
+
unmergeConfig, isSharedFile, withoutBlock, mergeInstruction, blockUpToDate,
|
|
24
|
+
} = require('./config')
|
|
332
25
|
|
|
333
26
|
function check(root) {
|
|
334
27
|
const errors = []
|
|
@@ -343,7 +36,8 @@ function check(root) {
|
|
|
343
36
|
errors.push(`automatization/hooks/${name} no es ejecutable`)
|
|
344
37
|
}
|
|
345
38
|
}
|
|
346
|
-
// El motor puede venir de la dependencia npm
|
|
39
|
+
// El motor puede venir de la dependencia npm o del propio repositorio, y la cascada la resuelve
|
|
40
|
+
// `packagePath`. Eran tres: la copia vendorizada se retiró en 0.10.0 y esta línea la sobrevivió.
|
|
347
41
|
if (!O.engineAt(root, path.join('hooks', 'run.js'))) {
|
|
348
42
|
errors.push('falta engine/hooks/run.js: corré "npm install" en la raíz del repo ops')
|
|
349
43
|
}
|
|
@@ -392,48 +86,6 @@ function validateRunnerManifest(root, name, errors) {
|
|
|
392
86
|
}
|
|
393
87
|
}
|
|
394
88
|
|
|
395
|
-
// Un `.sh` por grupo de más de un guard: es el que registra el runner para correrlos en un proceso.
|
|
396
|
-
function groupWrappers() {
|
|
397
|
-
return Object.entries(H.hookGroups)
|
|
398
|
-
.filter(([, names]) => names.length > 1)
|
|
399
|
-
.map(([group]) => [group, `guard-${group.replace('pre-', '')}.sh`])
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// Los scripts que la instancia debe tener, derivados del registro que los ejecuta en vez de copiados
|
|
403
|
-
// a mano: la copia envejecía sin avisar, porque un guard nuevo del motor no entraba en la cuenta.
|
|
404
|
-
//
|
|
405
|
-
// Se comprueba en una sola dirección a propósito. Un `.sh` que no está acá no sobra: así es como una
|
|
406
|
-
// empresa agrega el suyo —`guard-acme.sh` al lado de los nuestros—, que es lo que `upgrade` le dice
|
|
407
|
-
// que haga y lo único que sobrevive a cada actualización.
|
|
408
|
-
function expectedHooks() {
|
|
409
|
-
return [
|
|
410
|
-
'run-hook.sh',
|
|
411
|
-
...groupWrappers().map(([, wrapper]) => wrapper),
|
|
412
|
-
...GUARD_NAMES.map((name) => `guard-${name}.sh`),
|
|
413
|
-
]
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Guards que hoy viven dentro de un grupo, con el wrapper que los reemplaza.
|
|
417
|
-
function supersededGuards() {
|
|
418
|
-
const entries = []
|
|
419
|
-
for (const [group, wrapper] of groupWrappers()) {
|
|
420
|
-
for (const name of H.hookGroups[group]) entries.push({ file: `guard-${name}.sh`, wrapper })
|
|
421
|
-
}
|
|
422
|
-
return entries
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// Wiring heredado: guards que ahora corren agrupados pero siguen registrados uno por uno.
|
|
426
|
-
// Conviven sin romper nada, a costa de ejecutar el guard dos veces por herramienta.
|
|
427
|
-
function legacyGuardWiring(config) {
|
|
428
|
-
const text = JSON.stringify(config)
|
|
429
|
-
const superseded = []
|
|
430
|
-
for (const [group, names] of Object.entries(H.hookGroups)) {
|
|
431
|
-
if (names.length < 2 || !text.includes(`guard-${group.replace('pre-', '')}.sh`)) continue
|
|
432
|
-
superseded.push(...names.filter((guard) => text.includes(`guard-${guard}.sh`)))
|
|
433
|
-
}
|
|
434
|
-
return superseded
|
|
435
|
-
}
|
|
436
|
-
|
|
437
89
|
// Ejecuta el puente del runner tal como él lo invoca, y desde otra carpeta. Instalado no es lo mismo que
|
|
438
90
|
// operativo: un bridge que el runner no puede lanzar —porque su ruta es relativa y el cwd es otro, o
|
|
439
91
|
// porque falta node— falla cerrado y niega cada llamada a herramienta. `doctor` veía los archivos en su
|
|
@@ -594,33 +246,6 @@ function deliveryState(recorded, name, resolved, prefix = '') {
|
|
|
594
246
|
return delivered && delivered === current ? 'desactualizado' : 'ajeno'
|
|
595
247
|
}
|
|
596
248
|
|
|
597
|
-
// Quita de una estructura de configuración exactamente lo que este adaptador habría puesto, y nada más.
|
|
598
|
-
// Es el inverso de `mergeConfig`: una entrada del usuario nunca coincide literalmente con la nuestra, así
|
|
599
|
-
// que sobrevive; una que editó tampoco coincide, y por eso se conserva y se avisa en vez de borrarse.
|
|
600
|
-
function unmergeConfig(current, incoming) {
|
|
601
|
-
if (Array.isArray(incoming)) {
|
|
602
|
-
if (!Array.isArray(current)) return current
|
|
603
|
-
const ours = new Set(incoming.map((value) => JSON.stringify(value)))
|
|
604
|
-
return current.filter((value) => !ours.has(JSON.stringify(value)))
|
|
605
|
-
}
|
|
606
|
-
if (incoming && typeof incoming === 'object') {
|
|
607
|
-
if (!current || typeof current !== 'object' || Array.isArray(current)) return current
|
|
608
|
-
const result = { ...current }
|
|
609
|
-
for (const [key, value] of Object.entries(incoming)) {
|
|
610
|
-
if (!(key in result)) continue
|
|
611
|
-
const clean = unmergeConfig(result[key], value)
|
|
612
|
-
// Una clave que queda vacía por habernos ido no es del usuario: la creamos nosotros al instalar.
|
|
613
|
-
const empty = clean === undefined
|
|
614
|
-
|| (Array.isArray(clean) && !clean.length)
|
|
615
|
-
|| (clean && typeof clean === 'object' && !Array.isArray(clean) && !Object.keys(clean).length)
|
|
616
|
-
if (empty) delete result[key]
|
|
617
|
-
else result[key] = clean
|
|
618
|
-
}
|
|
619
|
-
return result
|
|
620
|
-
}
|
|
621
|
-
return JSON.stringify(current) === JSON.stringify(incoming) ? undefined : current
|
|
622
|
-
}
|
|
623
|
-
|
|
624
249
|
// Borra el archivo y, de paso, los directorios que quedaron vacíos por haberlo sacado. Nunca sube más
|
|
625
250
|
// allá del límite: `.claude/` puede tener cosas del usuario aunque `.claude/workflows/` quede vacío.
|
|
626
251
|
function removeFile(file, boundary) {
|
|
@@ -675,7 +300,7 @@ function uninstall(root, name, output = console) {
|
|
|
675
300
|
removed += 1
|
|
676
301
|
}
|
|
677
302
|
|
|
678
|
-
// Los punteros a cargos no se registran uno por uno —son
|
|
303
|
+
// Los punteros a cargos no se registran uno por uno —son todo el catálogo y se regeneran enteros—,
|
|
679
304
|
// así que se reconocen por contenido: sólo se va el que sigue siendo el que generamos.
|
|
680
305
|
if (runner.capabilities.nativeSkills && runner.roleSkills) {
|
|
681
306
|
const base = path.resolve(paths.install, runner.roleSkills)
|
|
@@ -710,37 +335,6 @@ function uninstall(root, name, output = console) {
|
|
|
710
335
|
// instrucciones de Codex es el mismo que el de la empresa. Conservarlo entero —lo correcto para un
|
|
711
336
|
// archivo del proyecto— dejaba a ese runner sin una sola línea de Cauce, así que su contenido se
|
|
712
337
|
// fusiona adentro, entre marcas, y todo lo demás del archivo queda intacto.
|
|
713
|
-
const blockStart = (name) => `<!-- cauce:${name} inicio — lo reescribe "automation install", no editar -->`
|
|
714
|
-
const blockEnd = (name) => `<!-- cauce:${name} fin -->`
|
|
715
|
-
|
|
716
|
-
function isSharedFile(root, target) {
|
|
717
|
-
return path.resolve(target) === path.resolve(root, 'AGENTS.md')
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
function withoutBlock(text, name) {
|
|
721
|
-
const sourceRoot = text.indexOf(blockStart(name))
|
|
722
|
-
if (sourceRoot === -1) return text
|
|
723
|
-
const until = text.indexOf(blockEnd(name), sourceRoot)
|
|
724
|
-
if (until === -1) return text
|
|
725
|
-
return `${text.slice(0, sourceRoot)}${text.slice(until + blockEnd(name).length)}`.trimEnd()
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
function mergeInstruction(file, name, content) {
|
|
729
|
-
const actual = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
|
|
730
|
-
const instructionBody = withoutBlock(actual, name).trimEnd()
|
|
731
|
-
const block = `${blockStart(name)}\n\n${content.trim()}\n\n${blockEnd(name)}\n`
|
|
732
|
-
F.atomicWrite(file, instructionBody ? `${instructionBody}\n\n${block}` : block)
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
function blockUpToDate(file, name, content) {
|
|
736
|
-
if (!fs.existsSync(file)) return false
|
|
737
|
-
const body = fs.readFileSync(file, 'utf8')
|
|
738
|
-
const sourceRoot = body.indexOf(blockStart(name))
|
|
739
|
-
const until = body.indexOf(blockEnd(name))
|
|
740
|
-
if (sourceRoot === -1 || until === -1) return false
|
|
741
|
-
return body.slice(sourceRoot + blockStart(name).length, until).trim() === content.trim()
|
|
742
|
-
}
|
|
743
|
-
|
|
744
338
|
function install(root, name, output = console, options = {}) {
|
|
745
339
|
// `install` arma la superficie de consumo de una empresa: punteros a cada cargo, una copia de los
|
|
746
340
|
// workflows y los guards. Acá los cargos y los workflows son el producto —la copia divergiría— y
|
|
@@ -831,7 +425,7 @@ function install(root, name, output = console, options = {}) {
|
|
|
831
425
|
// Cómo se lo llama acá. El nombre del recorrido es el mismo en todos los runners —`onboard`, `flow`,
|
|
832
426
|
// `autobuild`—; el prefijo lo pone cada uno según su espacio de nombres, y esa diferencia es la que
|
|
833
427
|
// hace que alguien no encuentre en Gemini lo que usó en Claude. Decirlo al instalar cuesta una línea
|
|
834
|
-
// y ahorra buscarlo en una lista
|
|
428
|
+
// y ahorra buscarlo en una lista tan larga como el catálogo.
|
|
835
429
|
const invocation = runner.commands && runner.commands.invocation
|
|
836
430
|
if (invocation && (runner.commands.names || []).length) {
|
|
837
431
|
const listing = runner.commands.names.map((nombre) => invocation.replace('{name}', nombre))
|
|
@@ -845,14 +439,6 @@ function install(root, name, output = console, options = {}) {
|
|
|
845
439
|
return runner
|
|
846
440
|
}
|
|
847
441
|
|
|
848
|
-
function listHooks(output = console) {
|
|
849
|
-
const nameWidth = Math.max(...H.hookMetadata.map((hook) => hook.name.length))
|
|
850
|
-
const eventWidth = Math.max(...H.hookMetadata.map((hook) => hook.event.length))
|
|
851
|
-
for (const hook of H.hookMetadata) {
|
|
852
|
-
output.log(`${hook.name.padEnd(nameWidth)} ${hook.event.padEnd(eventWidth)} ${hook.purpose}`)
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
|
|
856
442
|
module.exports = {
|
|
857
443
|
GUARD_NAMES,
|
|
858
444
|
RUNNER_NAMES,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Los punteros a cargos que el runner indexa para elegir a quién invocar. Cambian cuando cambia el
|
|
4
|
+
// catálogo —una profesión nueva, una descripción reescrita—, que es otro reloj que el de los comandos
|
|
5
|
+
// y otro que el del adaptador: por eso viven acá y no en `index.js`.
|
|
6
|
+
|
|
7
|
+
const path = require('node:path')
|
|
8
|
+
const fs = require('node:fs')
|
|
9
|
+
const F = require('../core/files')
|
|
10
|
+
const P = require('../planning/parser')
|
|
11
|
+
const catalog = require('../agents/catalog')
|
|
12
|
+
const { installRoot } = require('./runners')
|
|
13
|
+
|
|
14
|
+
// Cargos del catálogo, con el frontmatter que el runner indexa para elegir a quién invocar.
|
|
15
|
+
function roleCatalog(root) {
|
|
16
|
+
return catalog.list(root)
|
|
17
|
+
.map((role) => {
|
|
18
|
+
const field = P.frontmatter(fs.readFileSync(path.join(role.dir, 'SKILL.md'), 'utf8'))
|
|
19
|
+
const reference = path.relative(installRoot(root), role.dir).split(path.sep).join('/')
|
|
20
|
+
return { ...role, reference, description: field('description') }
|
|
21
|
+
})
|
|
22
|
+
.filter((role) => role.description)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Puntero fino: conserva nombre y descripción —lo único que el runner lee hasta invocar— y remite
|
|
26
|
+
// al contrato completo. Evita duplicar el catálogo entero dentro de la configuración del runner.
|
|
27
|
+
function roleSkill(role) {
|
|
28
|
+
return `---
|
|
29
|
+
name: ${role.slug}
|
|
30
|
+
description: ${role.description}
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
# ${role.slug}
|
|
34
|
+
|
|
35
|
+
Leé \`${role.reference}/SKILL.md\` para el contrato completo del cargo: cuándo actuar,
|
|
36
|
+
qué decide, qué no le corresponde y cuál es su entrega mínima. Sus métodos y formatos de output están
|
|
37
|
+
en \`${role.reference}/references/\`.
|
|
38
|
+
|
|
39
|
+
Esas rutas se resuelven desde este directorio raíz, no desde el repositorio de operaciones: en modo
|
|
40
|
+
sidecar el wiring vive acá y el repo ops es uno de sus hijos.
|
|
41
|
+
|
|
42
|
+
Respetá los límites de ese contrato y las reglas de \`AGENTS.md\`. Generado por
|
|
43
|
+
\`cauce automation install\`: no lo edites acá.
|
|
44
|
+
`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function installRoleSkills(root, runner, output) {
|
|
48
|
+
if (!runner.capabilities.nativeSkills || !runner.roleSkills) return
|
|
49
|
+
const install = installRoot(root)
|
|
50
|
+
const base = F.assertWithin(install, path.resolve(install, runner.roleSkills), `${runner.name}: roleSkills`)
|
|
51
|
+
const roles = roleCatalog(root)
|
|
52
|
+
// Los cargos y los recorridos comparten el espacio de nombres de skills del runner, así que un cargo
|
|
53
|
+
// que se llame como un recorrido lo pisa. Hoy no pasa, y por eso mismo hay que detenerlo acá: el
|
|
54
|
+
// catálogo de una empresa es suyo, nadie le prohíbe un cargo `flow`, y el daño sería que `/cauce:flow`
|
|
55
|
+
// deje de existir sin que nada falle. Renombrar el cargo es la salida, y sólo la puede tomar alguien.
|
|
56
|
+
const commandNames = new Set((runner.commands && runner.commands.names) || [])
|
|
57
|
+
const collide = roles.filter((role) => commandNames.has(role.slug)).map((role) => role.slug)
|
|
58
|
+
if (collide.length) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`${runner.name}: ${collide.join(', ')} es a la vez un cargo y un recorrido, y comparten `
|
|
61
|
+
+ `${runner.roleSkills}. Renombrá el cargo en agents/roles/ antes de instalar.`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
for (const role of roles) {
|
|
65
|
+
const file = path.join(base, role.slug, 'SKILL.md')
|
|
66
|
+
F.assertNoSymlinkPath(install, file)
|
|
67
|
+
F.atomicWrite(file, roleSkill(role))
|
|
68
|
+
}
|
|
69
|
+
if (roles.length) output.log(`✓ ${runner.name}: ${roles.length} cargo(s) disponibles en ${runner.roleSkills}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { roleCatalog, roleSkill, installRoleSkills }
|