@ingeniomaps/cauce 0.52.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/agents/roles/system/backend-engineer/learning/sources.yaml +2 -2
  3. package/agents/roles/system/community-manager/learning/sources.yaml +2 -2
  4. package/agents/roles/system/content-specialist/learning/sources.yaml +1 -1
  5. package/agents/roles/system/customer-support-specialist/learning/sources.yaml +1 -1
  6. package/agents/roles/system/data-analyst/learning/sources.yaml +2 -2
  7. package/agents/roles/system/data-governance-steward/learning/sources.yaml +1 -1
  8. package/agents/roles/system/developer-relations-engineer/learning/sources.yaml +3 -3
  9. package/agents/roles/system/devops-engineer/learning/sources.yaml +1 -1
  10. package/agents/roles/system/financial-controller/learning/sources.yaml +1 -1
  11. package/agents/roles/system/frontend-engineer/learning/sources.yaml +1 -1
  12. package/agents/roles/system/growth-marketer/learning/sources.yaml +2 -2
  13. package/agents/roles/system/integrations-engineer/learning/sources.yaml +6 -6
  14. package/agents/roles/system/mobile-engineer/learning/sources.yaml +1 -1
  15. package/agents/roles/system/people-operations-manager/learning/sources.yaml +1 -1
  16. package/agents/roles/system/privacy-compliance-specialist/learning/sources.yaml +1 -1
  17. package/agents/roles/system/product-marketing-manager/learning/sources.yaml +1 -1
  18. package/agents/roles/system/qa-engineer/learning/sources.yaml +2 -2
  19. package/agents/roles/system/site-reliability-engineer/learning/sources.yaml +1 -1
  20. package/agents/roles/system/software-architect/learning/sources.yaml +2 -2
  21. package/agents/roles/system/solutions-engineer/learning/sources.yaml +2 -2
  22. package/agents/roles/system/technical-writer/learning/sources.yaml +4 -4
  23. package/agents/roles/system/treasury-analyst/learning/sources.yaml +2 -2
  24. package/agents/roles/system/ui-designer/learning/sources.yaml +1 -1
  25. package/agents/roles/system/ux-designer/learning/sources.yaml +1 -1
  26. package/automatization/hooks/guard-dependencies.sh +1 -1
  27. package/automatization/hooks/guard-destructive.sh +1 -1
  28. package/automatization/hooks/guard-engine.sh +1 -1
  29. package/automatization/hooks/guard-generated.sh +1 -1
  30. package/automatization/hooks/guard-git-add.sh +1 -1
  31. package/automatization/hooks/guard-governance.sh +1 -1
  32. package/automatization/hooks/guard-integration-snapshot.sh +1 -1
  33. package/automatization/hooks/guard-migrations.sh +1 -1
  34. package/automatization/hooks/guard-planning-drift.sh +1 -1
  35. package/automatization/hooks/guard-secrets.sh +1 -1
  36. package/automatization/hooks/guard-test-evidence.sh +1 -1
  37. package/automatization/hooks/guard-verify.sh +1 -1
  38. package/automatization/hooks/guard-workspace-boundary.sh +1 -1
  39. package/automatization/hooks/run-hook.sh +3 -2
  40. package/automatization/runners/antigravity/hook.js +13 -14
  41. package/automatization/shared/eval-only.js +3 -3
  42. package/automatization/workflows/agent-eval.js +3 -3
  43. package/automatization/workflows/agent-promote.js +4 -4
  44. package/automatization/workflows/autobuild.js +12 -12
  45. package/automatization/workflows/flow-eval.js +10 -7
  46. package/automatization/workflows/flow.js +9 -10
  47. package/automatization/workflows/onboard.js +4 -5
  48. package/engine/agents/evaluations.js +18 -16
  49. package/engine/agents/learning-files.js +111 -0
  50. package/engine/agents/learning-sources.js +166 -0
  51. package/engine/agents/learning.js +93 -272
  52. package/engine/automation/config.js +175 -0
  53. package/engine/automation/hooks.js +96 -0
  54. package/engine/automation/index.js +17 -431
  55. package/engine/automation/roles.js +72 -0
  56. package/engine/automation/runners.js +162 -0
  57. package/engine/cli/catalog.js +7 -12
  58. package/engine/cli/instance.js +11 -10
  59. package/engine/cli/io.js +1 -1
  60. package/engine/cli/ops.js +12 -10
  61. package/engine/cli/wiring.js +2 -4
  62. package/engine/config/validate.js +2 -1
  63. package/engine/core/frontmatter.js +2 -1
  64. package/engine/core/ownership.js +5 -4
  65. package/engine/core/scan.js +3 -0
  66. package/engine/flows/registry.js +2 -2
  67. package/engine/hooks/files.js +160 -0
  68. package/engine/hooks/input.js +129 -0
  69. package/engine/hooks/run.js +24 -449
  70. package/engine/hooks/shell.js +197 -0
  71. package/engine/integrations/registry.js +2 -0
  72. package/engine/planning/contracts.js +12 -10
  73. package/engine/planning/parser.js +4 -3
  74. package/engine/planning/state.js +2 -1
  75. package/package.json +5 -5
@@ -0,0 +1,96 @@
1
+ 'use strict'
2
+
3
+ // El wiring de guards: cuáles existen, cuáles corren agrupados y cuáles quedaron registrados de a uno
4
+ // en una instancia vieja. Su reloj es el registro que los ejecuta —un guard nuevo entra por ahí—, no
5
+ // el de los comandos que los instalan.
6
+
7
+ const fs = require('node:fs')
8
+ const path = require('node:path')
9
+ const H = require('../hooks/run')
10
+ const O = require('../core/ownership')
11
+ const M = require('../core/manifest')
12
+ const { packagedAutomation } = require('./runners')
13
+
14
+ // Los guards que el motor implementa, tomados del registro que los ejecuta. Sale de ahí y no de un
15
+ // número escrito a mano: `automation check` anunciaba «11 guards» cuando hacía rato que eran doce, y
16
+ // un conteo que envejece solo es peor que ninguno —dice que revisó menos de lo que revisó—.
17
+ const GUARD_NAMES = Object.keys(H.guards)
18
+
19
+ // Un `.sh` por grupo de más de un guard: es el que registra el runner para correrlos en un proceso.
20
+ function groupWrappers() {
21
+ return Object.entries(H.hookGroups)
22
+ .filter(([, names]) => names.length > 1)
23
+ .map(([group]) => [group, `guard-${group.replace('pre-', '')}.sh`])
24
+ }
25
+
26
+ // Los scripts que la instancia debe tener, derivados del registro que los ejecuta en vez de copiados
27
+ // a mano: la copia envejecía sin avisar, porque un guard nuevo del motor no entraba en la cuenta.
28
+ //
29
+ // Se comprueba en una sola dirección a propósito. Un `.sh` que no está acá no sobra: así es como una
30
+ // empresa agrega el suyo —`guard-acme.sh` al lado de los nuestros—, que es lo que `upgrade` le dice
31
+ // que haga y lo único que sobrevive a cada actualización.
32
+ function expectedHooks() {
33
+ return [
34
+ 'run-hook.sh',
35
+ ...groupWrappers().map(([, wrapper]) => wrapper),
36
+ ...GUARD_NAMES.map((name) => `guard-${name}.sh`),
37
+ ]
38
+ }
39
+
40
+ // Guards que hoy viven dentro de un grupo, con el wrapper que los reemplaza.
41
+ function supersededGuards() {
42
+ const entries = []
43
+ for (const [group, wrapper] of groupWrappers()) {
44
+ for (const name of H.hookGroups[group]) entries.push({ file: `guard-${name}.sh`, wrapper })
45
+ }
46
+ return entries
47
+ }
48
+
49
+ // Wiring heredado: guards que ahora corren agrupados pero siguen registrados uno por uno.
50
+ // Conviven sin romper nada, a costa de ejecutar el guard dos veces por herramienta.
51
+ function legacyGuardWiring(config) {
52
+ const text = JSON.stringify(config)
53
+ const superseded = []
54
+ for (const [group, names] of Object.entries(H.hookGroups)) {
55
+ if (names.length < 2 || !text.includes(`guard-${group.replace('pre-', '')}.sh`)) continue
56
+ superseded.push(...names.filter((guard) => text.includes(`guard-${guard}.sh`)))
57
+ }
58
+ return superseded
59
+ }
60
+
61
+ // Guards de la instancia que ya no coinciden con los del paquete. Existir y ser ejecutable no
62
+ // alcanza: un guard viejo no falla, deja de proteger sin decir nada. La instancia declaraba una
63
+ // versión y nadie comprobaba que su runtime fuera realmente esa.
64
+ function staleHooks(root) {
65
+ const packaged = packagedAutomation(root)
66
+ if (!packaged) return []
67
+ const shipped = path.join(packaged, 'hooks')
68
+ const mine = path.join(root, 'automatization', 'hooks')
69
+ const recorded = M.read(root)
70
+ const stale = []
71
+ let names = []
72
+ try { names = fs.readdirSync(shipped) } catch { return [] }
73
+ for (const file of names) {
74
+ const local = path.join(mine, file)
75
+ // Los que faltan ya los reporta el chequeo de arriba; acá sólo interesa el que quedó atrás.
76
+ if (!fs.existsSync(local)) continue
77
+ const current = M.digest(local)
78
+ if (current === M.digest(path.join(shipped, file))) continue
79
+ const delivered = recorded[`automatization/hooks/${file}`]
80
+ stale.push({ file, edited: Boolean(delivered) && delivered !== current })
81
+ }
82
+ return stale
83
+ }
84
+
85
+ function listHooks(output = console) {
86
+ const nameWidth = Math.max(...H.hookMetadata.map((hook) => hook.name.length))
87
+ const eventWidth = Math.max(...H.hookMetadata.map((hook) => hook.event.length))
88
+ for (const hook of H.hookMetadata) {
89
+ output.log(`${hook.name.padEnd(nameWidth)} ${hook.event.padEnd(eventWidth)} ${hook.purpose}`)
90
+ }
91
+ }
92
+
93
+ module.exports = {
94
+ GUARD_NAMES, groupWrappers, expectedHooks, supersededGuards,
95
+ legacyGuardWiring, staleHooks, listHooks,
96
+ }
@@ -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
- const RUNNER_NAMES = ['claude', 'codex', 'gemini', 'antigravity']
14
-
15
- // Los guards que el motor implementa, tomados del registro que los ejecuta. Sale de ahí y no de un
16
- // número escrito a mano: `automation check` anunciaba «11 guards» cuando hacía rato que eran doce, y
17
- // un conteo que envejece solo es peor que ninguno —dice que revisó menos de lo que revisó—.
18
- const GUARD_NAMES = Object.keys(H.guards)
19
-
20
- // Adaptadores y workflows viven en el paquete, no en la instancia: son definiciones que el motor
21
- // consume y que ninguna empresa edita —`RUNNER_NAMES` es cerrado, así que ni siquiera puede agregar
22
- // uno propio—. Los hooks sí se quedan en el proyecto: la configuración del runner los nombra por
23
- // ruta literal y no sabe resolver en cascada.
24
- // Se busca por `runners/` y no por `automatization/`: toda instancia tiene el segundo —ahí viven sus
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, de la copia local o del propio repositorio.
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 cuarenta y siete y se regeneran enteros—,
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 de cincuenta skills.
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 }