@ingeniomaps/cauce 0.4.1 → 0.5.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 CHANGED
@@ -8,6 +8,53 @@ esa operación sea confiable en vez de sólo cómoda: acá se lee qué cambió a
8
8
  un cambio en el protocolo, en las reglas del sistema o en un guard es visible para el usuario y sube
9
9
  minor aunque no toque una sola línea de código.
10
10
 
11
+ ## [0.5.0] - 2026-08-15
12
+
13
+ ### Cambiado
14
+
15
+ - **Los adaptadores de runner y los workflows tampoco se copian al proyecto.** Cierran el mismo
16
+ criterio que ya rige para cargos y equipos: los lee el motor, no la empresa. `automation install`,
17
+ `check` y `doctor` los resuelven desde la dependencia npm, o desde `.ops/` cuando el repo no usa
18
+ npm. Nadie los editaba —la lista de runners es cerrada, así que una empresa ni siquiera podía
19
+ agregar el suyo— y `automatization/workflows/` estaba además duplicado dentro de cada instancia,
20
+ idéntico a lo que `automation install` deja en `.claude/workflows/`.
21
+ - `upgrade` retira `automatization/runners/` y `automatization/workflows/` de las instancias que los
22
+ arrastran de una versión anterior. Un guard propio en `automatization/hooks/` no se toca.
23
+ - `automatization/hooks/` se queda en el proyecto, y esto no es una excepción arbitraria: la
24
+ configuración de cada runner nombra cada guard por ruta literal y no sabe resolver en cascada. Ahí
25
+ es también donde una empresa agrega el suyo.
26
+
27
+ - **En modo `sidecar`, `automation install` instala en la carpeta de la compañía, no dentro del repo
28
+ ops.** El repo ops coordina varios repos de producto y es hermano de ellos, así que instalar el
29
+ runner adentro lo dejaba sin ver una sola línea de código: el dev que abría su herramienta donde
30
+ está el código no tenía guards, ni cargos, ni workflows. Las rutas de guards y los punteros de cada
31
+ cargo se reescriben con el prefijo del repo ops al instalar.
32
+
33
+ ### Corregido
34
+
35
+ - **Una mejora del toolkit en el wiring ahora llega a un runner ya instalado.** `install` conservaba
36
+ cualquier archivo existente, así que un workflow o un `CLAUDE.md` mejorado río arriba no llegaba
37
+ nunca. Peor: si el archivo difería, `install` fallaba —`existe y difiere de la fuente canónica`— y
38
+ `doctor` lo reportaba como error, dejando a la empresa sin salida salvo borrar a mano. Ahora el
39
+ registro de entrega distingue las dos cosas que antes se veían iguales: lo que la empresa editó se
40
+ conserva o detiene la instalación, y lo que sólo cambió río arriba se actualiza.
41
+ - `automation check` compara los guards de la instancia contra los del paquete. Existir y ser
42
+ ejecutable no alcanzaba: un guard viejo no falla, **deja de proteger en silencio**, y `check`,
43
+ `doctor` y `upgrade` daban verde igual porque sólo miraban el número de versión. Ahora bloquea la
44
+ instalación del runner y dice qué correr; distingue además el guard que la empresa editó del que
45
+ simplemente quedó atrás.
46
+ - `upgrade` recuerda reinstalar los runners: el wiring vive fuera de la instancia y lo escribe otro
47
+ comando, así que sin el aviso una mejora se quedaba en el paquete.
48
+ - Los guards resuelven la raíz ops por su cuenta. `findOpsRoot` sólo sube por el árbol, y en sidecar
49
+ la raíz ops es un *hermano* de los repos de producto: desde ahí no la encontraba, devolvía vacío y
50
+ el guard permitía todo **en silencio**. `run-hook.sh` ya sabía dónde vive; ahora lo exporta.
51
+ - `automatization/README.md` y `automatization/AGENTS.md` se actualizan con el toolkit. Los escribe
52
+ Cauce y envejecían en cada instancia: después de retirar `runners/` y `workflows/`, el README de la
53
+ empresa seguía explicando cómo usar dos carpetas que ya no existían.
54
+ - El registro de entrega olvida lo que ya no está en disco. Sólo crecía: al retirar una ruta dejaba
55
+ su digest para siempre, y el día que un nombre se reutilizara la entrega nueva se habría leído como
56
+ una edición local y detenido la actualización.
57
+
11
58
  ## [0.4.1] - 2026-08-15
12
59
 
13
60
  ### Corregido
package/README.md CHANGED
@@ -157,10 +157,11 @@ Cada colección adaptable separa lo que actualiza el toolkit de lo que escribe e
157
157
  | `teams/` | composiciones que vienen con Cauce | los equipos propios |
158
158
  | `agents/<tipo>/` | *(en el paquete, no se copia)* | los cargos propios |
159
159
 
160
- `automatization/hooks/` y `automatization/runners/` no tienen `system/`: son runtime que se reemplaza
161
- entero. No hace falta, porque lo que un proyecto necesita ya funciona sin editarlos — un guard propio
162
- convive y sobrevive, y desactivar uno del toolkit es quitarlo de la configuración del runner, que es del
163
- proyecto. Editar uno existente detiene el `upgrade` antes de pisarlo.
160
+ `automatization/hooks/` no tiene `system/`: es runtime que se reemplaza entero. No hace falta, porque lo
161
+ que un proyecto necesita ya funciona sin editarlo — un guard propio convive y sobrevive, y desactivar uno
162
+ del toolkit es quitarlo de la configuración del runner, que es del proyecto. Editar uno existente detiene
163
+ el `upgrade` antes de pisarlo. Los hooks se quedan en el proyecto porque esa configuración los nombra por
164
+ ruta literal; los adaptadores de runner y los workflows, que sólo lee el motor, viajan en el paquete.
164
165
 
165
166
  Un archivo propio con el mismo nombre o ID que uno de `system/` lo reemplaza: el del proyecto manda y
166
167
  `check` lo reporta como override explícito. Así una mejora del proceso no obliga a forkear el archivo,
@@ -4,6 +4,10 @@ set -u
4
4
  hook_name=${1:-}
5
5
  hook_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
6
6
  ops_root=$(CDPATH= cd -- "$hook_dir/../.." && pwd)
7
+ # El guard sabe dónde vive; quien lo invoca, no necesariamente. En modo sidecar la herramienta se
8
+ # abre en la carpeta de la compañía y la raíz ops es un hermano, que ninguna búsqueda hacia arriba
9
+ # encuentra: sin esto el guard no halla `ops.config.json` y deja pasar todo en silencio.
10
+ export OPS_ROOT="$ops_root"
7
11
  # Mismo orden que tools/ops.js: primero la dependencia npm, después la copia local, y por último
8
12
  # el propio repositorio del toolkit. Un guard que no encuentra su motor bloquea, nunca permite.
9
13
  runner=""
@@ -8,14 +8,30 @@ const H = require('../hooks/run')
8
8
  const P = require('../planning/parser')
9
9
  const catalog = require('../agents/catalog')
10
10
  const O = require('../core/ownership')
11
+ const M = require('../core/manifest')
11
12
 
12
13
  const RUNNER_NAMES = ['claude', 'codex', 'gemini', 'antigravity']
13
14
 
15
+ // Adaptadores y workflows viven en el paquete, no en la instancia: son definiciones que el motor
16
+ // consume y que ninguna empresa edita —`RUNNER_NAMES` es cerrado, así que ni siquiera puede agregar
17
+ // uno propio—. Los hooks sí se quedan en el proyecto: la configuración del runner los nombra por
18
+ // ruta literal y no sabe resolver en cascada.
19
+ // Se busca por `runners/` y no por `automatization/`: toda instancia tiene el segundo —ahí viven sus
20
+ // hooks— y encontrarlo daría por buena una dependencia sin instalar.
21
+ function packagedAutomation(root) {
22
+ const runners = O.packagePath(root, path.join('automatization', 'runners'))
23
+ return runners ? path.dirname(runners) : ''
24
+ }
25
+
14
26
  function runnerManifest(root, name) {
15
27
  if (!RUNNER_NAMES.includes(name)) {
16
28
  throw new Error(`runner debe ser ${RUNNER_NAMES.join(', ')}`)
17
29
  }
18
- const file = path.join(root, 'automatization', 'runners', name, 'manifest.json')
30
+ const packaged = packagedAutomation(root)
31
+ if (!packaged) {
32
+ throw new Error('no encuentro automatization/: instalá la dependencia o restaurá .ops/')
33
+ }
34
+ const file = path.join(packaged, 'runners', name, 'manifest.json')
19
35
  try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch (error) {
20
36
  throw new Error(`${name}: manifest inválido (${error.message})`)
21
37
  }
@@ -57,20 +73,38 @@ function includesConfig(actual, expected) {
57
73
  return actual === expected
58
74
  }
59
75
 
76
+ // Dónde abre el dev su herramienta, que no siempre es la raíz ops. En modo sidecar el repo ops es
77
+ // un hermano de los repos de producto: `aparatejo-ops/` coordina, `aparatejo/` es lo que se abre.
78
+ // Instalar dentro del sidecar dejaría al runner sin ver una sola línea de código.
79
+ function installRoot(root) {
80
+ try {
81
+ const config = JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8'))
82
+ if (config.mode === 'sidecar') return path.resolve(root, '..')
83
+ } catch { /* sin configuración legible, instalar donde está */ }
84
+ return root
85
+ }
86
+
87
+ // Cómo se nombra la raíz ops desde ahí: `aparatejo-ops/` en sidecar, vacío cuando coinciden.
88
+ function opsPrefix(root) {
89
+ const relative = path.relative(installRoot(root), root)
90
+ return relative ? `${relative.split(path.sep).join('/')}/` : ''
91
+ }
92
+
60
93
  function runnerPaths(root, name, runner) {
61
- const automationRoot = path.join(root, 'automatization')
62
- const sourceDir = path.join(root, 'automatization', 'runners', name)
94
+ const automationRoot = packagedAutomation(root)
95
+ const sourceDir = path.join(automationRoot, 'runners', name)
96
+ const install = installRoot(root)
63
97
  const configSource = F.assertWithin(
64
98
  sourceDir,
65
99
  path.resolve(sourceDir, runner.config.source),
66
100
  `${name}: config.source`,
67
101
  )
68
102
  const configTarget = F.assertWithin(
69
- root,
70
- path.resolve(root, runner.config.target),
103
+ install,
104
+ path.resolve(install, runner.config.target),
71
105
  `${name}: config.target`,
72
106
  )
73
- return { automationRoot, sourceDir, configSource, configTarget }
107
+ return { automationRoot, sourceDir, configSource, configTarget, install }
74
108
  }
75
109
 
76
110
  function resolveItem(paths, root, name, item) {
@@ -80,16 +114,30 @@ function resolveItem(paths, root, name, item) {
80
114
  path.resolve(paths.sourceDir, item.source),
81
115
  `${name}: source`,
82
116
  ),
83
- target: F.assertWithin(root, path.resolve(root, item.target), `${name}: target`),
117
+ target: F.assertWithin(
118
+ paths.install,
119
+ path.resolve(paths.install, item.target),
120
+ `${name}: target`,
121
+ ),
84
122
  }
85
123
  }
86
124
 
125
+ // La configuración del runner nombra cada guard por ruta relativa a donde se abre la herramienta.
126
+ // Si eso ya no es la raíz ops, hay que reescribir el prefijo o el runner buscaría los guards en la
127
+ // carpeta equivocada. Se hace en un solo lugar: `install` lo escribe y `doctor` compara contra esto.
128
+ function runnerConfig(paths, root) {
129
+ const raw = fs.readFileSync(paths.configSource, 'utf8')
130
+ const prefix = opsPrefix(root)
131
+ return JSON.parse(prefix ? raw.split('automatization/hooks/').join(`${prefix}automatization/hooks/`) : raw)
132
+ }
133
+
87
134
  // Cargos del catálogo, con el frontmatter que el runner indexa para elegir a quién invocar.
88
135
  function roleCatalog(root) {
89
136
  return catalog.list(root)
90
137
  .map((role) => {
91
138
  const field = P.frontmatter(fs.readFileSync(path.join(role.dir, 'SKILL.md'), 'utf8'))
92
- return { ...role, reference: path.relative(root, role.dir), description: field('description') }
139
+ const reference = path.relative(installRoot(root), role.dir).split(path.sep).join('/')
140
+ return { ...role, reference, description: field('description') }
93
141
  })
94
142
  .filter((role) => role.description)
95
143
  }
@@ -115,11 +163,12 @@ Respetá los límites de ese contrato y las reglas de \`AGENTS.md\`. Generado po
115
163
 
116
164
  function installRoleSkills(root, runner, output) {
117
165
  if (!runner.capabilities.nativeSkills || !runner.roleSkills) return 0
118
- const base = F.assertWithin(root, path.resolve(root, runner.roleSkills), `${runner.name}: roleSkills`)
166
+ const install = installRoot(root)
167
+ const base = F.assertWithin(install, path.resolve(install, runner.roleSkills), `${runner.name}: roleSkills`)
119
168
  const roles = roleCatalog(root)
120
169
  for (const role of roles) {
121
170
  const file = path.join(base, role.slug, 'SKILL.md')
122
- F.assertNoSymlinkPath(root, file)
171
+ F.assertNoSymlinkPath(install, file)
123
172
  F.atomicWrite(file, roleSkill(role))
124
173
  }
125
174
  if (roles.length) output.log(`✓ ${runner.name}: ${roles.length} cargo(s) disponibles en ${runner.roleSkills}`)
@@ -141,6 +190,30 @@ function hasHooks(config) {
141
190
  })
142
191
  }
143
192
 
193
+ // Guards de la instancia que ya no coinciden con los del paquete. Existir y ser ejecutable no
194
+ // alcanza: un guard viejo no falla, deja de proteger sin decir nada. La instancia declaraba una
195
+ // versión y nadie comprobaba que su runtime fuera realmente esa.
196
+ function staleHooks(root) {
197
+ const packaged = packagedAutomation(root)
198
+ if (!packaged) return []
199
+ const shipped = path.join(packaged, 'hooks')
200
+ const mine = path.join(root, 'automatization', 'hooks')
201
+ const recorded = M.read(root)
202
+ const stale = []
203
+ let names = []
204
+ try { names = fs.readdirSync(shipped) } catch { return [] }
205
+ for (const file of names) {
206
+ const local = path.join(mine, file)
207
+ // Los que faltan ya los reporta el chequeo de arriba; acá sólo interesa el que quedó atrás.
208
+ if (!fs.existsSync(local)) continue
209
+ const current = M.digest(local)
210
+ if (current === M.digest(path.join(shipped, file))) continue
211
+ const delivered = recorded[`automatization/hooks/${file}`]
212
+ stale.push({ file, edited: Boolean(delivered) && delivered !== current })
213
+ }
214
+ return stale
215
+ }
216
+
144
217
  function check(root) {
145
218
  const errors = []
146
219
  const hookDir = path.join(root, 'automatization', 'hooks')
@@ -176,11 +249,19 @@ function check(root) {
176
249
  }
177
250
  const workflows = ['autobuild.js', 'team.js', path.join('integrations', 'sync.js')]
178
251
  workflows.push(path.join('integrations', 'promote.js'))
252
+ const packaged = packagedAutomation(root)
179
253
  for (const name of workflows) {
180
- if (!fs.existsSync(path.join(root, 'automatization', 'workflows', name))) {
181
- errors.push(`falta automatization/workflows/${name}`)
254
+ if (!packaged || !fs.existsSync(path.join(packaged, 'workflows', name))) {
255
+ errors.push(`falta automatization/workflows/${name}: instalá la dependencia o restaurá .ops/`)
182
256
  }
183
257
  }
258
+ for (const { file, edited } of staleHooks(root)) {
259
+ errors.push(edited
260
+ ? `automatization/hooks/${file}: lo editaste y es del toolkit; agregá un guard propio al lado `
261
+ + 'o descartá tu cambio con `cauce upgrade --force`'
262
+ : `automatization/hooks/${file}: quedó atrás del paquete y ya no protege lo que dice; `
263
+ + 'corré `cauce upgrade` antes de instalar el runner')
264
+ }
184
265
  for (const name of RUNNER_NAMES) validateRunner(root, name, errors)
185
266
  return errors
186
267
  }
@@ -194,7 +275,7 @@ function validateRunner(root, name, errors) {
194
275
  return
195
276
  }
196
277
  const paths = runnerPaths(root, name, runner)
197
- const config = JSON.parse(fs.readFileSync(paths.configSource, 'utf8'))
278
+ const config = runnerConfig(paths, root)
198
279
  if (runner.capabilities.nativeHooks && !hasHooks(config)) {
199
280
  errors.push(`${name}: declara hooks nativos pero no los configura`)
200
281
  }
@@ -272,7 +353,7 @@ function doctor(root, name, output = console) {
272
353
  const errors = []
273
354
  const warnings = []
274
355
  try {
275
- const expected = JSON.parse(fs.readFileSync(paths.configSource, 'utf8'))
356
+ const expected = runnerConfig(paths, root)
276
357
  const actual = JSON.parse(fs.readFileSync(paths.configTarget, 'utf8'))
277
358
  if (!includesConfig(actual, expected)) {
278
359
  errors.push(`${runner.config.target}: configuración instalada incompleta o divergente`)
@@ -286,18 +367,27 @@ function doctor(root, name, output = console) {
286
367
  errors.push(`${runner.config.target}: ${error.message}`)
287
368
  }
288
369
  for (const item of runner.instructions || []) {
289
- const resolved = resolveItem(paths, root, name, item)
370
+ const resolved = { item, ...resolveItem(paths, root, name, item) }
290
371
  if (!fs.existsSync(resolved.target)) errors.push(`falta ${item.target}`)
291
372
  else if (!fs.readFileSync(resolved.target, 'utf8').includes('AGENTS.md')) {
292
373
  warnings.push(`${item.target}: no referencia AGENTS.md; verifica las reglas globales`)
293
374
  }
375
+ if (deliveryState(M.readRunners(root), name, resolved) === 'desactualizado') {
376
+ warnings.push(`${item.target}: Cauce trae una versión más nueva y vos no lo tocaste; reinstalá`)
377
+ }
294
378
  }
379
+ // Divergir no es un error: puede ser una mejora río arriba esperando reinstalación. Reportarlo
380
+ // como error dejaba a la empresa sin salida, porque `install` tampoco lo actualizaba.
381
+ const recorded = M.readRunners(root)
295
382
  for (const item of runner.artifacts || []) {
296
- const resolved = resolveItem(paths, root, name, item)
297
- if (!fs.existsSync(resolved.target)) errors.push(`falta ${item.target}`)
298
- else if (fs.readFileSync(resolved.source, 'utf8')
299
- !== fs.readFileSync(resolved.target, 'utf8')) {
300
- errors.push(`${item.target}: difiere de la fuente canónica`)
383
+ const resolved = { item, ...resolveItem(paths, root, name, item) }
384
+ const situacion = deliveryState(recorded, name, resolved)
385
+ if (situacion === 'nuevo') errors.push(`falta ${item.target}`)
386
+ else if (situacion === 'desactualizado') {
387
+ warnings.push(`${item.target}: hay una versión más nueva en Cauce; reinstalá el adaptador`)
388
+ } else if (situacion === 'ajeno') {
389
+ warnings.push(`${item.target}: lo editaste y es del toolkit; `
390
+ + 'agregá lo tuyo al lado o reinstalá con --force para volver a la versión de Cauce')
301
391
  }
302
392
  }
303
393
  // Un cargo que quedó fuera, o cuya descripción cambió en el catálogo, deja al runner eligiendo
@@ -306,7 +396,7 @@ function doctor(root, name, output = console) {
306
396
  const missing = []
307
397
  const stale = []
308
398
  for (const role of roleCatalog(root)) {
309
- const file = path.join(root, runner.roleSkills, role.slug, 'SKILL.md')
399
+ const file = path.join(paths.install, runner.roleSkills, role.slug, 'SKILL.md')
310
400
  if (!fs.existsSync(file)) missing.push(role.slug)
311
401
  else if (fs.readFileSync(file, 'utf8') !== roleSkill(role)) stale.push(role.slug)
312
402
  }
@@ -327,12 +417,35 @@ function doctor(root, name, output = console) {
327
417
  return { runner, errors, warnings }
328
418
  }
329
419
 
330
- function install(root, name, output = console) {
420
+ // Clave de entrega de un archivo del adaptador. Va en su propia sección del manifiesto porque puede
421
+ // caer fuera de la instancia: en sidecar el wiring vive en la carpeta de la compañía.
422
+ function deliveryKey(name, target) {
423
+ return `${name}/${target.split(path.sep).join('/')}`
424
+ }
425
+
426
+ // En qué estado quedó un archivo que este adaptador entregó alguna vez.
427
+ //
428
+ // nuevo no existe todavía
429
+ // al día idéntico a lo que trae Cauce
430
+ // desactualizado la empresa no lo tocó, pero río arriba cambió
431
+ // ajeno difiere de lo entregado: alguien lo editó acá
432
+ //
433
+ // Sin el registro de entrega, `desactualizado` y `ajeno` se ven igual. `install` resolvía esa duda
434
+ // conservando siempre, así que ninguna mejora del toolkit llegaba nunca a un runner ya instalado.
435
+ function deliveryState(recorded, name, resolved) {
436
+ if (!fs.existsSync(resolved.target)) return 'nuevo'
437
+ const current = M.digest(resolved.target)
438
+ if (current === M.digest(resolved.source)) return 'al día'
439
+ const delivered = recorded[deliveryKey(name, resolved.item.target)]
440
+ return delivered && delivered === current ? 'desactualizado' : 'ajeno'
441
+ }
442
+
443
+ function install(root, name, output = console, options = {}) {
331
444
  const runner = runnerManifest(root, name)
332
445
  const errors = check(root)
333
446
  if (errors.length) throw new Error(`La automatización no es instalable:\n- ${errors.join('\n- ')}`)
334
447
  const paths = runnerPaths(root, name, runner)
335
- const incoming = JSON.parse(fs.readFileSync(paths.configSource, 'utf8'))
448
+ const incoming = runnerConfig(paths, root)
336
449
  let current = {}
337
450
  if (fs.existsSync(paths.configTarget)) {
338
451
  try { current = JSON.parse(fs.readFileSync(paths.configTarget, 'utf8')) } catch (error) {
@@ -341,31 +454,57 @@ function install(root, name, output = console) {
341
454
  }
342
455
  const items = [...(runner.instructions || []), ...(runner.artifacts || [])]
343
456
  const resolvedItems = items.map((item) => ({ item, ...resolveItem(paths, root, name, item) }))
344
- F.assertNoSymlinkPath(root, paths.configTarget)
345
- for (const resolved of resolvedItems) {
346
- F.assertNoSymlinkPath(root, resolved.target)
347
- if (fs.existsSync(resolved.target)
348
- && !runner.instructions.includes(resolved.item)
349
- && fs.readFileSync(resolved.target, 'utf8') !== fs.readFileSync(resolved.source, 'utf8')) {
350
- throw new Error(`${resolved.item.target} existe y difiere de la fuente canónica`)
351
- }
457
+ F.assertNoSymlinkPath(paths.install, paths.configTarget)
458
+ for (const resolved of resolvedItems) F.assertNoSymlinkPath(paths.install, resolved.target)
459
+
460
+ const recorded = M.readRunners(root)
461
+ const state = new Map(resolvedItems.map((resolved) => [resolved, deliveryState(recorded, name, resolved)]))
462
+ // Un archivo de instrucciones es del proyecto y se conserva; uno ejecutable es del toolkit y se
463
+ // reemplaza. Editarlo detiene la instalación antes de pisarlo, igual que hace `upgrade`.
464
+ const editados = resolvedItems.filter((resolved) => {
465
+ return state.get(resolved) === 'ajeno' && !runner.instructions.includes(resolved.item)
466
+ })
467
+ if (editados.length && !options.force) {
468
+ throw new Error(
469
+ `${editados.length} archivo(s) que mantiene Cauce fueron editados y se perderían:\n`
470
+ + `${editados.map((resolved) => `- ${resolved.item.target}`).join('\n')}\n\n`
471
+ + 'Son del toolkit: en vez de editarlos, agregá lo tuyo al lado y registralo en la\n'
472
+ + 'configuración de tu runner. Si el cambio ya no te sirve, repetí con --force.',
473
+ )
352
474
  }
353
475
  const merged = pruneSupersededHooks(mergeConfig(current, incoming))
354
476
  F.atomicWriteJson(paths.configTarget, merged.config)
355
477
  for (const { wrapper, files } of merged.replaced) {
356
478
  output.log(`− ${name}: reemplazado ${[...new Set(files)].join(', ')} por ${wrapper}`)
357
479
  }
480
+ // Dónde aterrizó, no sólo qué archivo: en sidecar el destino no es el repo desde el que se corrió
481
+ // el comando, y descubrirlo por sorpresa es la diferencia entre confiar y adivinar.
482
+ if (paths.install !== root) {
483
+ output.log(` ${name}: el runner se abre en ${paths.install} — ahí quedan .claude/ y CLAUDE.md`)
484
+ }
358
485
  output.log(`✓ ${name}: configuración instalada en ${runner.config.target}`)
486
+ const entregado = { ...recorded }
359
487
  for (const resolved of resolvedItems) {
360
- if (fs.existsSync(resolved.target)) {
361
- output.log(`= ${name}: conservado ${resolved.item.target}`)
488
+ const situacion = state.get(resolved)
489
+ const propio = runner.instructions.includes(resolved.item)
490
+ if (situacion === 'ajeno' && propio) {
491
+ output.log(`= ${name}: conservado ${resolved.item.target} (tiene cambios tuyos)`)
492
+ } else if (situacion === 'al día') {
493
+ output.log(`= ${name}: ${resolved.item.target} ya está al día`)
362
494
  } else {
363
495
  fs.mkdirSync(path.dirname(resolved.target), { recursive: true })
364
496
  fs.copyFileSync(resolved.source, resolved.target)
365
- output.log(`✓ ${name}: instalado ${resolved.item.target}`)
497
+ const verbo = situacion === 'nuevo' ? 'instalado' : 'actualizado'
498
+ output.log(`✓ ${name}: ${verbo} ${resolved.item.target}`)
499
+ }
500
+ // Sólo se anota lo que Cauce puso: un archivo conservado con cambios de la empresa no es una
501
+ // entrega, y registrarlo lo volvería indistinguible de uno intacto en la próxima instalación.
502
+ if (!(situacion === 'ajeno' && propio)) {
503
+ entregado[deliveryKey(name, resolved.item.target)] = M.digest(resolved.target)
366
504
  }
367
505
  }
368
506
  installRoleSkills(root, runner, output)
507
+ M.write(root, undefined, entregado)
369
508
  return runner
370
509
  }
371
510
 
package/engine/cli/ops.js CHANGED
@@ -140,20 +140,6 @@ function init(target) {
140
140
  preserve,
141
141
  root,
142
142
  )
143
- copyRuntime(
144
- path.join(PROJECT_ROOT, 'automatization', 'runners'),
145
- path.join(root, 'automatization', 'runners'),
146
- preserve,
147
- root,
148
- // El README de runners lo provee el template: le habla al proyecto, no a quien desarrolla Cauce.
149
- O.TEMPLATE_OWNED.filter((owned) => owned.startsWith('automatization/runners/')).map((owned) => path.basename(owned)),
150
- )
151
- copyRuntime(
152
- path.join(PROJECT_ROOT, 'automatization', 'workflows'),
153
- path.join(root, 'automatization', 'workflows'),
154
- preserve,
155
- root,
156
- )
157
143
  const version = require(path.join(PROJECT_ROOT, 'package.json')).version
158
144
  // Un repo con npm recibe el motor como dependencia versionada; uno de Go, Python o Rust recibe
159
145
  // la copia, porque exigirle un package.json para correr su planning sería imponerle un stack.
@@ -165,9 +151,18 @@ function init(target) {
165
151
  const engine = path.join(root, '.ops', 'engine')
166
152
  copyRuntime(path.join(PROJECT_ROOT, 'engine'), engine, preserve, root)
167
153
  fs.chmodSync(path.join(engine, 'cli', 'ops.js'), 0o755)
168
- // Sin npm no hay de dónde leer el catálogo en tiempo de ejecución: viaja junto al motor.
154
+ // Sin npm no hay de dónde leer el catálogo en tiempo de ejecución: viaja junto al motor, igual
155
+ // que los adaptadores de runner y los workflows que instala `automation install`.
169
156
  copyRuntime(path.join(PROJECT_ROOT, 'agents'), path.join(root, '.ops', 'agents'), preserve, root)
170
157
  copyRuntime(path.join(PROJECT_ROOT, 'teams'), path.join(root, '.ops', 'teams'), preserve, root)
158
+ for (const name of ['runners', 'workflows']) {
159
+ copyRuntime(
160
+ path.join(PROJECT_ROOT, 'automatization', name),
161
+ path.join(root, '.ops', 'automatization', name),
162
+ preserve,
163
+ root,
164
+ )
165
+ }
171
166
  }
172
167
  let entregado = {}
173
168
  for (const relative of O.trackedPaths()) {
@@ -546,10 +541,7 @@ function upgrade(dir) {
546
541
  // `.ops/` es el paquete vendorizado de una instancia sin npm. Si no existe, esta instancia lo
547
542
  // toma de la dependencia y crearlo sería duplicar lo que el lockfile ya versiona.
548
543
  if (relative.startsWith('.ops/') && !fs.existsSync(target)) continue
549
- const skip = O.TEMPLATE_OWNED
550
- .filter((owned) => owned.startsWith(`${relative}/`))
551
- .map((owned) => path.basename(owned))
552
- if (fs.statSync(origin).isDirectory()) overlayTree(origin, target, root, skip)
544
+ if (fs.statSync(origin).isDirectory()) overlayTree(origin, target, root)
553
545
  else {
554
546
  F.assertNoSymlinkPath(root, target)
555
547
  F.atomicWrite(target, fs.readFileSync(origin, 'utf8'))
@@ -573,7 +565,7 @@ function upgrade(dir) {
573
565
  const dir = path.join(root, relative)
574
566
  if (fs.existsSync(dir)) registro = { ...registro, ...M.record(root, relative, O.treeFiles(dir)) }
575
567
  }
576
- M.write(root, registro)
568
+ M.write(root, M.prune(root, registro))
577
569
 
578
570
  const config = JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8'))
579
571
  config.cauceVersion = to
@@ -590,6 +582,14 @@ function upgrade(dir) {
590
582
  console.log(`= conservado ${override.collection}/${override.project}: sobrescribe ${override.system}`)
591
583
  }
592
584
  console.log(' planning, organization y todo lo propio quedaron intactos')
585
+ // El wiring del runner no se actualiza solo: vive fuera de la instancia y lo escribe otro comando.
586
+ // Sin este recordatorio, una mejora en un workflow o en el catálogo se queda en el paquete.
587
+ const runners = Object.keys(M.readRunners(root))
588
+ .map((key) => key.split('/')[0])
589
+ .filter((name, index, all) => all.indexOf(name) === index)
590
+ for (const name of runners) {
591
+ console.log(` reinstalá tu runner para que el wiring quede al día: make install-${name}`)
592
+ }
593
593
  }
594
594
 
595
595
  // Lista los cargos visibles resolviendo la precedencia; evita que cada consumidor —CI incluido—
@@ -717,7 +717,8 @@ function automation(action, rootArg, runnerName) {
717
717
  }
718
718
  if (action === 'install') {
719
719
  let runner
720
- try { runner = A.install(root, runnerName) } catch (error) { fail(error.message, 2) }
720
+ const force = process.argv.includes('--force')
721
+ try { runner = A.install(root, runnerName, console, { force }) } catch (error) { fail(error.message, 2) }
721
722
  if (runnerName === 'codex') {
722
723
  console.log(' Codex pedirá revisar y confiar en hooks nuevos al iniciar sesión.')
723
724
  }
@@ -19,18 +19,38 @@ function digest(file) {
19
19
  } catch { return '' }
20
20
  }
21
21
 
22
- function read(root) {
22
+ // Dos secciones porque son dos entregas distintas: `files` es lo que se materializó dentro de la
23
+ // instancia y `runners` lo que un adaptador dejó fuera de ella —en modo sidecar el wiring vive en
24
+ // la carpeta de la compañía—. El registro se queda igual acá, que es el repo que la empresa versiona.
25
+ function readAll(root) {
23
26
  try {
24
- const data = JSON.parse(fs.readFileSync(path.join(root, FILE), 'utf8'))
25
- return data && typeof data.files === 'object' ? data.files : {}
26
- } catch { return {} }
27
+ const data = JSON.parse(fs.readFileSync(path.join(root, FILE), 'utf8')) || {}
28
+ return {
29
+ files: typeof data.files === 'object' && data.files ? data.files : {},
30
+ runners: typeof data.runners === 'object' && data.runners ? data.runners : {},
31
+ }
32
+ } catch { return { files: {}, runners: {} } }
27
33
  }
28
34
 
29
- function write(root, files) {
35
+ function read(root) { return readAll(root).files }
36
+
37
+ function readRunners(root) { return readAll(root).runners }
38
+
39
+ // Cada sección que no se pasa se conserva: `upgrade` no sabe del wiring y `install` no sabe de la
40
+ // instancia, y ninguno de los dos debería borrar lo que el otro anotó.
41
+ function write(root, files, runners) {
42
+ const current = readAll(root)
30
43
  const target = path.join(root, FILE)
31
44
  fs.mkdirSync(path.dirname(target), { recursive: true })
32
- const ordered = Object.fromEntries(Object.entries(files).sort(([left], [right]) => left.localeCompare(right)))
33
- fs.writeFileSync(target, `${JSON.stringify({ version: 1, files: ordered }, null, 2)}\n`)
45
+ const ordered = (map) => Object.fromEntries(
46
+ Object.entries(map).sort(([left], [right]) => left.localeCompare(right)),
47
+ )
48
+ const data = {
49
+ version: 1,
50
+ files: ordered(files || current.files),
51
+ runners: ordered(runners || current.runners),
52
+ }
53
+ fs.writeFileSync(target, `${JSON.stringify(data, null, 2)}\n`)
34
54
  }
35
55
 
36
56
  // Registra lo entregado en una ruta, relativo a la raíz de la instancia.
@@ -40,6 +60,15 @@ function record(root, relative, files) {
40
60
  return current
41
61
  }
42
62
 
63
+ // Olvida lo que ya no está en disco. Sin esto el registro sólo crece: una ruta retirada deja su
64
+ // digest para siempre, y el día que un nombre se reutilice la entrega nueva se leería como una
65
+ // edición local y detendría la actualización.
66
+ function prune(root, files) {
67
+ return Object.fromEntries(
68
+ Object.entries(files).filter(([file]) => fs.existsSync(path.join(root, file))),
69
+ )
70
+ }
71
+
43
72
  // Archivos que la empresa modificó después de recibirlos. Un archivo sin registro previo no
44
73
  // cuenta: llegó con una versión anterior a este mecanismo, o lo agregó el proyecto.
45
74
  function edited(root, relative, files) {
@@ -51,4 +80,4 @@ function edited(root, relative, files) {
51
80
  })
52
81
  }
53
82
 
54
- module.exports = { FILE, digest, edited, read, record, write }
83
+ module.exports = { FILE, digest, edited, prune, read, readRunners, record, write }
@@ -25,6 +25,8 @@ const SYSTEM_FILES = [
25
25
  'organization/roles/README.md',
26
26
  'teams/000-template.md',
27
27
  'teams/README.md',
28
+ 'automatization/README.md',
29
+ 'automatization/AGENTS.md',
28
30
  ]
29
31
 
30
32
  // Colecciones mixtas: el toolkit posee `<dir>/system/`, el proyecto todo lo demás del directorio.
@@ -41,23 +43,27 @@ const RUNTIME_PATHS = [
41
43
  '.ops/engine',
42
44
  '.ops/agents',
43
45
  '.ops/teams',
46
+ '.ops/automatization/runners',
47
+ '.ops/automatization/workflows',
44
48
  'automatization/hooks',
45
- 'automatization/runners',
46
- 'automatization/workflows',
47
49
  ]
48
50
 
49
- // Rutas que el paquete tiene por duplicado: una versión para el proyecto en `template/` y otra
50
- // interna del toolkit. Gana la del template, que es la que le habla a quien usa la instancia.
51
- const TEMPLATE_OWNED = ['automatization/runners/README.md']
52
-
53
51
  // Dentro del paquete, lo que una instancia recibe en su raíz vive bajo `template/`; el catálogo,
54
52
  // los equipos y la automatización están en la raíz del paquete, y el motor en `engine/`.
55
- const TEMPLATE_PREFIXES = ['planning/', 'organization/', 'integrations/', 'teams/']
53
+ const TEMPLATE_PREFIXES = [
54
+ 'planning/',
55
+ 'organization/',
56
+ 'integrations/',
57
+ 'teams/',
58
+ 'automatization/',
59
+ ]
56
60
 
57
61
  function sourceOf(relative) {
58
- if (relative === '.ops/engine') return 'engine'
59
- if (relative === '.ops/agents') return 'agents'
60
- if (relative === '.ops/teams') return 'teams'
62
+ // `.ops/` es el paquete vendorizado: cada ruta de ahí adentro se llama igual en el origen.
63
+ if (relative.startsWith('.ops/')) return relative.slice('.ops/'.length)
64
+ // El runtime sale de la raíz del paquete aunque su prefijo sea de plantilla: `automatization/`
65
+ // le entrega documentos a la instancia, pero los guards que ejecuta son los del toolkit.
66
+ if (RUNTIME_PATHS.includes(relative)) return relative
61
67
  if (TEMPLATE_PREFIXES.some((prefix) => relative.startsWith(prefix))) {
62
68
  return path.join('template', relative)
63
69
  }
@@ -81,6 +87,17 @@ function engineAt(root, relative = '') {
81
87
  .find((candidate) => fs.existsSync(candidate)) || ''
82
88
  }
83
89
 
90
+ // Una ruta cualquiera del paquete, en el mismo orden de preferencia que el motor. La usan los
91
+ // adaptadores de runner y los workflows: el motor los consume, el proyecto no los materializa.
92
+ function packagePath(root, relative) {
93
+ const candidates = [
94
+ path.join(root, 'node_modules', '@ingeniomaps', 'cauce', relative),
95
+ path.join(root, '.ops', relative),
96
+ path.join(root, relative),
97
+ ]
98
+ return candidates.find((candidate) => fs.existsSync(candidate)) || ''
99
+ }
100
+
84
101
  // Definiciones que consume el motor —cargos y equipos— y que por eso viajan con el paquete en vez
85
102
  // de copiarse. Se reconocen por contener `system/`, que es el espacio del toolkit y no algo que un
86
103
  // proyecto deba crear. Una sola implementación para las dos, o divergen.
@@ -135,6 +152,8 @@ const RETIRED = [
135
152
  'agents/roles/system',
136
153
  'teams/system',
137
154
  '.github/workflows/agent-learning.yml',
155
+ 'automatization/runners',
156
+ 'automatization/workflows',
138
157
  ]
139
158
 
140
159
  // Aprendizaje que quedó dentro de una ruta retirada. Es lo único ahí que no se puede reponer, así
@@ -205,7 +224,7 @@ module.exports = {
205
224
  retiredWithLearning,
206
225
  engineAt,
207
226
  engineCandidates,
208
- TEMPLATE_OWNED,
227
+ packagePath,
209
228
  SYSTEM_COLLECTIONS,
210
229
  SYSTEM_FILES,
211
230
  identity,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -58,8 +58,9 @@ Aplica a `planning/business-rules/`, `planning/adr/`, `planning/rules/`, `teams/
58
58
  `planning/` —roadmap, backlog, WIP, done, inbox y acciones humanas— es del proyecto y no se toca al
59
59
  actualizar.
60
60
 
61
- `automatization/hooks/` y `automatization/runners/` son runtime del toolkit y se reemplazan enteros. No
62
- tienen `system/` porque no hace falta: lo que un proyecto necesita ya funciona sin editarlos.
61
+ `automatization/hooks/` es runtime del toolkit y se reemplaza entero. No tiene `system/` porque no hace
62
+ falta: lo que un proyecto necesita ya funciona sin editarlo. Los adaptadores de runner y los workflows ni
63
+ siquiera están acá — los lee el motor desde el paquete.
63
64
 
64
65
  - **Agregar un guard propio**: creá `automatization/hooks/guard-<nombre>.sh` y registralo en la
65
66
  configuración de tu runner. Sobrevive a cada actualización, porque el toolkit no lo conoce.
@@ -1,19 +1,34 @@
1
- # Automatización de {{PROJECT_NAME}}
1
+ # Automatización
2
2
 
3
3
  Wiring local entre `planning/PROTOCOL.md` y el runner elegido. El proyecto empieza en modo manual y seguro;
4
- activar un runner requiere completar su adaptador bajo `runners/`.
4
+ ningún runner se activa solo.
5
5
 
6
6
  - `config.json`: runner activo y gates requeridos.
7
- - `hooks/`: implementaciones específicas del runner.
8
- - `workflows/`: recorridos específicos del runner.
9
- - `runners/`: instalación y configuración por herramienta.
7
+ - `hooks/`: los guards que se ejecutan. Acá va también el tuyo: agregalo con otro nombre y registralo
8
+ en la configuración de tu runner, que es del proyecto y sobrevive a cada actualización.
9
+
10
+ Los adaptadores de runner y los workflows no se copian acá: son definiciones que el motor consume y
11
+ viajan con Cauce, igual que el catálogo de cargos y los equipos. `automation install` los lee desde ahí.
10
12
 
11
13
  No copies reglas del protocolo aquí: enlázalas y mecaniza únicamente lo comprobable.
12
14
 
13
15
  ```bash
14
16
  node tools/ops.js automation check .
15
- node tools/ops.js automation install . codex
17
+ node tools/ops.js automation install . claude
18
+ ```
19
+
20
+ **Dónde aterriza**: en modo `embedded`, acá mismo. En modo `sidecar` el runner se instala en la carpeta
21
+ de la compañía —la que contiene este repo y los de producto—, porque es donde el dev abre la
22
+ herramienta y la única desde la que ve el código. El comando dice la ruta exacta al terminar.
23
+
24
+ Hay adaptadores para Claude, Codex, Antigravity y Gemini. Antigravity (`agy`) es la opción Google
25
+ recomendada para cuentas individuales y proyectos nuevos; Gemini se conserva para Enterprise, Google
26
+ Cloud y API keys. La instalación conserva la configuración existente, y `doctor` comprueba archivos y
27
+ disponibilidad del CLI sin autenticarse.
28
+
29
+ ```bash
30
+ make install-antigravity
31
+ make doctor-antigravity
16
32
  ```
17
33
 
18
- También puede usarse `make install-claude`, `make install-codex` o `make install-gemini`.
19
- Valida después con `make doctor-claude`, `make doctor-codex` o `make doctor-gemini`.
34
+ También existen `make install-claude`, `make install-codex` y `make install-gemini`, con sus `doctor-*`.
@@ -1,13 +0,0 @@
1
- # Runners
2
-
3
- Esta instancia incluye adaptadores instalables para Claude, Codex, Antigravity y Gemini, pero ninguno se
4
- activa automáticamente. Antigravity (`agy`) es la opción Google recomendada para cuentas individuales y
5
- proyectos nuevos. Gemini se conserva para Enterprise, Google Cloud y API keys.
6
-
7
- ```bash
8
- make install-antigravity
9
- make doctor-antigravity
10
- ```
11
-
12
- Usa el alias equivalente para otro runner. La instalación conserva configuración existente y `doctor`
13
- comprueba archivos y disponibilidad del CLI sin autenticarse.