@ingeniomaps/cauce 0.4.1 → 0.5.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 CHANGED
@@ -8,6 +8,73 @@ 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.1] - 2026-08-15
12
+
13
+ ### Agregado
14
+
15
+ - **Codex recibe su `AGENTS.md`.** Era el único runner sin archivo de instrucciones: se llevaba los
16
+ guards y nada más, así que podía ser detenido pero no sabía que existía un protocolo, un catálogo de
17
+ cargos ni equipos. No existe un `CODEX.md`: `AGENTS.md` es el nombre que Codex lee, compartido entre
18
+ herramientas, y por eso en modo embedded no se instala —el de la empresa ya está ahí y manda—.
19
+
20
+ ### Corregido
21
+
22
+ - **Las rutas de los adaptadores se resuelven contra la carpeta donde se abre la herramienta.** Al
23
+ mover la instalación a la raíz de la compañía quedaron apuntando al lugar equivocado: `@AGENTS.md`
24
+ y `@planning/PROTOCOL.md` no resolvían, y los workflows buscaban `planning/` donde no estaba. Ahora
25
+ cada fuente marca el lugar con `{{OPS_DIR}}` y `install` lo completa; `doctor` compara contra lo
26
+ mismo que `install` escribe.
27
+ - `tools/ops.js` exporta la raíz ops, que ya calculaba y no usaba. Invocado desde la carpeta de la
28
+ compañía —`node <empresa>-ops/tools/ops.js team list`—, `agents list` y `team list` resolvían contra
29
+ el cwd y devolvían **vacío en vez de fallar**. `team` además no aceptaba una raíz de ningún modo.
30
+
31
+ ## [0.5.0] - 2026-08-15
32
+
33
+ ### Cambiado
34
+
35
+ - **Los adaptadores de runner y los workflows tampoco se copian al proyecto.** Cierran el mismo
36
+ criterio que ya rige para cargos y equipos: los lee el motor, no la empresa. `automation install`,
37
+ `check` y `doctor` los resuelven desde la dependencia npm, o desde `.ops/` cuando el repo no usa
38
+ npm. Nadie los editaba —la lista de runners es cerrada, así que una empresa ni siquiera podía
39
+ agregar el suyo— y `automatization/workflows/` estaba además duplicado dentro de cada instancia,
40
+ idéntico a lo que `automation install` deja en `.claude/workflows/`.
41
+ - `upgrade` retira `automatization/runners/` y `automatization/workflows/` de las instancias que los
42
+ arrastran de una versión anterior. Un guard propio en `automatization/hooks/` no se toca.
43
+ - `automatization/hooks/` se queda en el proyecto, y esto no es una excepción arbitraria: la
44
+ configuración de cada runner nombra cada guard por ruta literal y no sabe resolver en cascada. Ahí
45
+ es también donde una empresa agrega el suyo.
46
+
47
+ - **En modo `sidecar`, `automation install` instala en la carpeta de la compañía, no dentro del repo
48
+ ops.** El repo ops coordina varios repos de producto y es hermano de ellos, así que instalar el
49
+ runner adentro lo dejaba sin ver una sola línea de código: el dev que abría su herramienta donde
50
+ está el código no tenía guards, ni cargos, ni workflows. Las rutas de guards y los punteros de cada
51
+ cargo se reescriben con el prefijo del repo ops al instalar.
52
+
53
+ ### Corregido
54
+
55
+ - **Una mejora del toolkit en el wiring ahora llega a un runner ya instalado.** `install` conservaba
56
+ cualquier archivo existente, así que un workflow o un `CLAUDE.md` mejorado río arriba no llegaba
57
+ nunca. Peor: si el archivo difería, `install` fallaba —`existe y difiere de la fuente canónica`— y
58
+ `doctor` lo reportaba como error, dejando a la empresa sin salida salvo borrar a mano. Ahora el
59
+ registro de entrega distingue las dos cosas que antes se veían iguales: lo que la empresa editó se
60
+ conserva o detiene la instalación, y lo que sólo cambió río arriba se actualiza.
61
+ - `automation check` compara los guards de la instancia contra los del paquete. Existir y ser
62
+ ejecutable no alcanzaba: un guard viejo no falla, **deja de proteger en silencio**, y `check`,
63
+ `doctor` y `upgrade` daban verde igual porque sólo miraban el número de versión. Ahora bloquea la
64
+ instalación del runner y dice qué correr; distingue además el guard que la empresa editó del que
65
+ simplemente quedó atrás.
66
+ - `upgrade` recuerda reinstalar los runners: el wiring vive fuera de la instancia y lo escribe otro
67
+ comando, así que sin el aviso una mejora se quedaba en el paquete.
68
+ - Los guards resuelven la raíz ops por su cuenta. `findOpsRoot` sólo sube por el árbol, y en sidecar
69
+ la raíz ops es un *hermano* de los repos de producto: desde ahí no la encontraba, devolvía vacío y
70
+ el guard permitía todo **en silencio**. `run-hook.sh` ya sabía dónde vive; ahora lo exporta.
71
+ - `automatization/README.md` y `automatization/AGENTS.md` se actualizan con el toolkit. Los escribe
72
+ Cauce y envejecían en cada instancia: después de retirar `runners/` y `workflows/`, el README de la
73
+ empresa seguía explicando cómo usar dos carpetas que ya no existían.
74
+ - El registro de entrega olvida lo que ya no está en disco. Sólo crecía: al retirar una ruta dejaba
75
+ su digest para siempre, y el día que un nombre se reutilizara la entrega nueva se habría leído como
76
+ una edición local y detenido la actualización.
77
+
11
78
  ## [0.4.1] - 2026-08-15
12
79
 
13
80
  ### 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=""
@@ -1,6 +1,6 @@
1
1
  # Cauce
2
2
 
3
- Lee y cumple `AGENTS.md` y `planning/PROTOCOL.md` antes de ejecutar trabajo. `planning/WIP.md` es el mutex de
4
- ejecución y `planning/AWAITING_REVIEW.md` bloquea una corrida nueva. No promociones ideas desde INBOX, no
3
+ Lee y cumple `AGENTS.md` y `{{OPS_DIR}}planning/PROTOCOL.md` antes de ejecutar trabajo. `{{OPS_DIR}}planning/WIP.md` es el mutex de
4
+ ejecución y `{{OPS_DIR}}planning/AWAITING_REVIEW.md` bloquea una corrida nueva. No promociones ideas desde INBOX, no
5
5
  inventes aprobaciones o credenciales y no hagas push ni deploy. Cierra cada tarea con verificación real y
6
6
  evidencia en DONE.
@@ -1,11 +1,11 @@
1
1
  # Cauce para Claude Code
2
2
 
3
- @AGENTS.md
4
- @planning/PROTOCOL.md
3
+ @{{OPS_DIR}}AGENTS.md
4
+ @{{OPS_DIR}}planning/PROTOCOL.md
5
5
 
6
6
  Los hooks de `.claude/settings.json` son obligatorios. Usa `/team` para evaluar si una intención es viable
7
7
  y proponer una épica, y `/autobuild` para ejecutar trabajo ya promovido; `/integration-sync` e
8
8
  `/integration-promote` gestionan staging local sin escritura remota. Ninguno promueve al BACKLOG.
9
9
 
10
- Antes de iniciar, respeta `planning/AWAITING_REVIEW.md` y el mutex de `planning/WIP.md`. Si el protocolo y
11
- un workflow difieren, manda el protocolo y la diferencia se registra en `planning/INBOX.md`.
10
+ Antes de iniciar, respeta `{{OPS_DIR}}planning/AWAITING_REVIEW.md` y el mutex de `{{OPS_DIR}}planning/WIP.md`. Si el protocolo y
11
+ un workflow difieren, manda el protocolo y la diferencia se registra en `{{OPS_DIR}}planning/INBOX.md`.
@@ -4,20 +4,20 @@
4
4
  {
5
5
  "matcher": "Bash",
6
6
  "hooks": [
7
- { "type": "command", "command": "$CLAUDE_PROJECT_DIR/automatization/hooks/guard-shell.sh" }
7
+ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/{{OPS_DIR}}automatization/hooks/guard-shell.sh" }
8
8
  ]
9
9
  },
10
10
  {
11
11
  "matcher": "Edit|Write",
12
12
  "hooks": [
13
- { "type": "command", "command": "$CLAUDE_PROJECT_DIR/automatization/hooks/guard-files.sh" }
13
+ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/{{OPS_DIR}}automatization/hooks/guard-files.sh" }
14
14
  ]
15
15
  }
16
16
  ],
17
17
  "Stop": [
18
18
  {
19
19
  "hooks": [
20
- { "type": "command", "command": "$CLAUDE_PROJECT_DIR/automatization/hooks/guard-planning-drift.sh" }
20
+ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/{{OPS_DIR}}automatization/hooks/guard-planning-drift.sh" }
21
21
  ]
22
22
  }
23
23
  ]
@@ -0,0 +1,47 @@
1
+ # Cauce para Codex
2
+
3
+ `{{OPS_DIR}}AGENTS.md` tiene las reglas del sistema y `{{OPS_DIR}}planning/PROTOCOL.md` es la fuente de
4
+ verdad del proceso. Leelos antes de trabajar: acá sólo está lo específico de este runner.
5
+
6
+ > Codex lee el `AGENTS.md` de la raíz, que es un nombre compartido entre herramientas. Cuando el repo
7
+ > ops **es** la raíz, este archivo no se instala: el `AGENTS.md` de la empresa ya está ahí y manda.
8
+
9
+ Los hooks de `.codex/hooks/hooks.json` son obligatorios y bloquean por su cuenta. Codex no ejecuta los
10
+ workflows JS de Claude —son referencia, no un runtime compatible—, así que el recorrido se hace fase por
11
+ fase siguiendo el protocolo.
12
+
13
+ `{{OPS_DIR}}planning/WIP.md` es el mutex: una sola tarea activa. `{{OPS_DIR}}planning/AWAITING_REVIEW.md`
14
+ bloquea una corrida nueva hasta que un humano revise. Nada se promueve desde `INBOX.md` sin aprobación.
15
+
16
+ Antes de cerrar, corré `node {{OPS_DIR}}tools/ops.js check {{OPS_DIR}}planning`.
17
+
18
+ ## Los cargos
19
+
20
+ El catálogo está en `{{OPS_DIR}}node_modules/@ingeniomaps/cauce/agents/` —o en
21
+ `{{OPS_DIR}}.ops/agents/` si este repo no usa npm— y los propios de la empresa en
22
+ `{{OPS_DIR}}agents/`, que mandan sobre los del sistema con el mismo nombre.
23
+
24
+ Cada cargo tiene un `SKILL.md` con su contrato: cuándo actúa, qué decide, qué no le corresponde y cuál es
25
+ su entrega mínima. Para ver la lista con una línea por cargo:
26
+
27
+ ```bash
28
+ node {{OPS_DIR}}tools/ops.js agents list
29
+ ```
30
+
31
+ Leé el `SKILL.md` del cargo que corresponda antes de actuar en su terreno, y respetá sus límites. Lo que
32
+ ese cargo debe saber de esta empresa está en `{{OPS_DIR}}organization/roles/<slug>.md`.
33
+
34
+ ## Los equipos
35
+
36
+ Un equipo es una secuencia de cargos con etapas y exit gates, para evaluar una intención antes de que
37
+ exista una épica. Están en `{{OPS_DIR}}node_modules/@ingeniomaps/cauce/teams/system/` y los propios en
38
+ `{{OPS_DIR}}teams/`.
39
+
40
+ ```bash
41
+ node {{OPS_DIR}}tools/ops.js team list
42
+ node {{OPS_DIR}}tools/ops.js team show <slug>
43
+ ```
44
+
45
+ Ningún equipo promueve trabajo al BACKLOG: escribe la épica o el informe y para.
46
+
47
+ Nunca omitas aprobaciones, inventes credenciales, escribas en un sistema remoto, hagas push o deploy.
@@ -4,20 +4,20 @@
4
4
  {
5
5
  "matcher": "shell|unified_exec",
6
6
  "hooks": [
7
- { "type": "command", "command": "automatization/hooks/guard-shell.sh" }
7
+ { "type": "command", "command": "{{OPS_DIR}}automatization/hooks/guard-shell.sh" }
8
8
  ]
9
9
  },
10
10
  {
11
11
  "matcher": "apply_patch|files",
12
12
  "hooks": [
13
- { "type": "command", "command": "automatization/hooks/guard-files.sh" }
13
+ { "type": "command", "command": "{{OPS_DIR}}automatization/hooks/guard-files.sh" }
14
14
  ]
15
15
  }
16
16
  ],
17
17
  "SessionEnd": [
18
18
  {
19
19
  "hooks": [
20
- { "type": "command", "command": "automatization/hooks/guard-planning-drift.sh" }
20
+ { "type": "command", "command": "{{OPS_DIR}}automatization/hooks/guard-planning-drift.sh" }
21
21
  ]
22
22
  }
23
23
  ]
@@ -6,7 +6,12 @@
6
6
  "source": "hooks.json",
7
7
  "target": ".codex/hooks/hooks.json"
8
8
  },
9
- "instructions": [],
9
+ "instructions": [
10
+ {
11
+ "source": "AGENTS.md",
12
+ "target": "AGENTS.md"
13
+ }
14
+ ],
10
15
  "artifacts": [],
11
16
  "capabilities": {
12
17
  "nativeHooks": true,
@@ -1,12 +1,12 @@
1
1
  # Cauce para Gemini CLI
2
2
 
3
- @AGENTS.md
4
- @planning/PROTOCOL.md
3
+ @{{OPS_DIR}}AGENTS.md
4
+ @{{OPS_DIR}}planning/PROTOCOL.md
5
5
 
6
- `planning/PROTOCOL.md` es la fuente de verdad. Ejecuta `/ops:autobuild` fase por fase; los workflows JS de
7
- Claude son referencia, no un runtime compatible. `planning/WIP.md` es el mutex y
8
- `planning/AWAITING_REVIEW.md` bloquea una corrida nueva.
6
+ `{{OPS_DIR}}planning/PROTOCOL.md` es la fuente de verdad. Ejecuta `/ops:autobuild` fase por fase; los workflows JS de
7
+ Claude son referencia, no un runtime compatible. `{{OPS_DIR}}planning/WIP.md` es el mutex y
8
+ `{{OPS_DIR}}planning/AWAITING_REVIEW.md` bloquea una corrida nueva.
9
9
 
10
10
  Gemini no tiene guards nativos configurados por este toolkit. Antes de cada commit ejecuta los wrappers de
11
- `automatization/hooks/` como prechecks y al cerrar ejecuta `node tools/ops.js check planning`. Nunca omitas
11
+ `{{OPS_DIR}}automatization/hooks/` como prechecks y al cerrar ejecuta `node {{OPS_DIR}}tools/ops.js check planning`. Nunca omitas
12
12
  aprobaciones, inventes credenciales, escribas remoto, hagas push/deploy o promociones trabajo desde INBOX.
@@ -10,7 +10,10 @@ export const meta = {
10
10
  ].map((title) => ({ title, detail: `Fase ${title} del protocolo agnóstico` })),
11
11
  }
12
12
 
13
- const ROOT = process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || '.'
13
+ // El prefijo lo completa `automation install`: en modo sidecar la herramienta se abre en la carpeta
14
+ // de la compañía y la raíz ops es uno de sus hijos, no la carpeta misma.
15
+ const ROOT = (process.env.OPS_ROOT
16
+ || `${process.env.CLAUDE_PROJECT_DIR || '.'}/{{OPS_DIR}}`).replace(/\/+$/, '')
14
17
  const CONFIG = `${ROOT}/ops.config.json`
15
18
  const P = `${ROOT}/planning`
16
19
  const BACKLOG = `${P}/BACKLOG.md`
@@ -10,7 +10,10 @@ export const meta = {
10
10
  ],
11
11
  }
12
12
 
13
- const ROOT = process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || '.'
13
+ // El prefijo lo completa `automation install`: en modo sidecar la herramienta se abre en la carpeta
14
+ // de la compañía y la raíz ops es uno de sus hijos, no la carpeta misma.
15
+ const ROOT = (process.env.OPS_ROOT
16
+ || `${process.env.CLAUDE_PROJECT_DIR || '.'}/{{OPS_DIR}}`).replace(/\/+$/, '')
14
17
  const PROVIDER = process.env.OPS_INTEGRATION_PROVIDER || ''
15
18
  const KEY = process.env.OPS_INTEGRATION_KEY || ''
16
19
  const RESULT = {
@@ -10,7 +10,10 @@ export const meta = {
10
10
  ],
11
11
  }
12
12
 
13
- const ROOT = process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || '.'
13
+ // El prefijo lo completa `automation install`: en modo sidecar la herramienta se abre en la carpeta
14
+ // de la compañía y la raíz ops es uno de sus hijos, no la carpeta misma.
15
+ const ROOT = (process.env.OPS_ROOT
16
+ || `${process.env.CLAUDE_PROJECT_DIR || '.'}/{{OPS_DIR}}`).replace(/\/+$/, '')
14
17
  const REQUESTED = process.env.OPS_INTEGRATION_PROVIDER || ''
15
18
  const RESULT = {
16
19
  type: 'object', additionalProperties: false, required: ['passed', 'provider', 'details'],
@@ -15,7 +15,10 @@ export const meta = {
15
15
  ],
16
16
  }
17
17
 
18
- const ROOT = process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || '.'
18
+ // El prefijo lo completa `automation install`: en modo sidecar la herramienta se abre en la carpeta
19
+ // de la compañía y la raíz ops es uno de sus hijos, no la carpeta misma.
20
+ const ROOT = (process.env.OPS_ROOT
21
+ || `${process.env.CLAUDE_PROJECT_DIR || '.'}/{{OPS_DIR}}`).replace(/\/+$/, '')
19
22
  const P = `${ROOT}/planning`
20
23
  const ROADMAP = `${P}/roadmap`
21
24
  const HUMAN = `${P}/HUMAN_ACTIONS.md`
@@ -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,37 @@ 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
+ // Todo lo que un adaptador copia —configuración, instrucciones, workflows— nombra rutas relativas a
126
+ // la carpeta donde se abre la herramienta. Cuando la raíz ops no es esa carpeta, cada una necesita el
127
+ // prefijo. El marcador es explícito en la fuente en vez de adivinarse con reemplazos de texto:
128
+ // `{{OPS_DIR}}` significa "acá va la raíz ops, o nada si coinciden".
129
+ //
130
+ // Un solo render, y `install` escribe exactamente lo que `doctor` compara.
131
+ const OPS_DIR = '{{OPS_DIR}}'
132
+
133
+ function render(file, prefix) {
134
+ return fs.readFileSync(file, 'utf8').split(OPS_DIR).join(prefix)
135
+ }
136
+
137
+ function runnerConfig(paths, root) {
138
+ return JSON.parse(render(paths.configSource, opsPrefix(root)))
139
+ }
140
+
87
141
  // Cargos del catálogo, con el frontmatter que el runner indexa para elegir a quién invocar.
88
142
  function roleCatalog(root) {
89
143
  return catalog.list(root)
90
144
  .map((role) => {
91
145
  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') }
146
+ const reference = path.relative(installRoot(root), role.dir).split(path.sep).join('/')
147
+ return { ...role, reference, description: field('description') }
93
148
  })
94
149
  .filter((role) => role.description)
95
150
  }
@@ -115,11 +170,12 @@ Respetá los límites de ese contrato y las reglas de \`AGENTS.md\`. Generado po
115
170
 
116
171
  function installRoleSkills(root, runner, output) {
117
172
  if (!runner.capabilities.nativeSkills || !runner.roleSkills) return 0
118
- const base = F.assertWithin(root, path.resolve(root, runner.roleSkills), `${runner.name}: roleSkills`)
173
+ const install = installRoot(root)
174
+ const base = F.assertWithin(install, path.resolve(install, runner.roleSkills), `${runner.name}: roleSkills`)
119
175
  const roles = roleCatalog(root)
120
176
  for (const role of roles) {
121
177
  const file = path.join(base, role.slug, 'SKILL.md')
122
- F.assertNoSymlinkPath(root, file)
178
+ F.assertNoSymlinkPath(install, file)
123
179
  F.atomicWrite(file, roleSkill(role))
124
180
  }
125
181
  if (roles.length) output.log(`✓ ${runner.name}: ${roles.length} cargo(s) disponibles en ${runner.roleSkills}`)
@@ -141,6 +197,30 @@ function hasHooks(config) {
141
197
  })
142
198
  }
143
199
 
200
+ // Guards de la instancia que ya no coinciden con los del paquete. Existir y ser ejecutable no
201
+ // alcanza: un guard viejo no falla, deja de proteger sin decir nada. La instancia declaraba una
202
+ // versión y nadie comprobaba que su runtime fuera realmente esa.
203
+ function staleHooks(root) {
204
+ const packaged = packagedAutomation(root)
205
+ if (!packaged) return []
206
+ const shipped = path.join(packaged, 'hooks')
207
+ const mine = path.join(root, 'automatization', 'hooks')
208
+ const recorded = M.read(root)
209
+ const stale = []
210
+ let names = []
211
+ try { names = fs.readdirSync(shipped) } catch { return [] }
212
+ for (const file of names) {
213
+ const local = path.join(mine, file)
214
+ // Los que faltan ya los reporta el chequeo de arriba; acá sólo interesa el que quedó atrás.
215
+ if (!fs.existsSync(local)) continue
216
+ const current = M.digest(local)
217
+ if (current === M.digest(path.join(shipped, file))) continue
218
+ const delivered = recorded[`automatization/hooks/${file}`]
219
+ stale.push({ file, edited: Boolean(delivered) && delivered !== current })
220
+ }
221
+ return stale
222
+ }
223
+
144
224
  function check(root) {
145
225
  const errors = []
146
226
  const hookDir = path.join(root, 'automatization', 'hooks')
@@ -176,11 +256,19 @@ function check(root) {
176
256
  }
177
257
  const workflows = ['autobuild.js', 'team.js', path.join('integrations', 'sync.js')]
178
258
  workflows.push(path.join('integrations', 'promote.js'))
259
+ const packaged = packagedAutomation(root)
179
260
  for (const name of workflows) {
180
- if (!fs.existsSync(path.join(root, 'automatization', 'workflows', name))) {
181
- errors.push(`falta automatization/workflows/${name}`)
261
+ if (!packaged || !fs.existsSync(path.join(packaged, 'workflows', name))) {
262
+ errors.push(`falta automatization/workflows/${name}: instalá la dependencia o restaurá .ops/`)
182
263
  }
183
264
  }
265
+ for (const { file, edited } of staleHooks(root)) {
266
+ errors.push(edited
267
+ ? `automatization/hooks/${file}: lo editaste y es del toolkit; agregá un guard propio al lado `
268
+ + 'o descartá tu cambio con `cauce upgrade --force`'
269
+ : `automatization/hooks/${file}: quedó atrás del paquete y ya no protege lo que dice; `
270
+ + 'corré `cauce upgrade` antes de instalar el runner')
271
+ }
184
272
  for (const name of RUNNER_NAMES) validateRunner(root, name, errors)
185
273
  return errors
186
274
  }
@@ -194,7 +282,7 @@ function validateRunner(root, name, errors) {
194
282
  return
195
283
  }
196
284
  const paths = runnerPaths(root, name, runner)
197
- const config = JSON.parse(fs.readFileSync(paths.configSource, 'utf8'))
285
+ const config = runnerConfig(paths, root)
198
286
  if (runner.capabilities.nativeHooks && !hasHooks(config)) {
199
287
  errors.push(`${name}: declara hooks nativos pero no los configura`)
200
288
  }
@@ -272,7 +360,7 @@ function doctor(root, name, output = console) {
272
360
  const errors = []
273
361
  const warnings = []
274
362
  try {
275
- const expected = JSON.parse(fs.readFileSync(paths.configSource, 'utf8'))
363
+ const expected = runnerConfig(paths, root)
276
364
  const actual = JSON.parse(fs.readFileSync(paths.configTarget, 'utf8'))
277
365
  if (!includesConfig(actual, expected)) {
278
366
  errors.push(`${runner.config.target}: configuración instalada incompleta o divergente`)
@@ -286,18 +374,31 @@ function doctor(root, name, output = console) {
286
374
  errors.push(`${runner.config.target}: ${error.message}`)
287
375
  }
288
376
  for (const item of runner.instructions || []) {
289
- const resolved = resolveItem(paths, root, name, item)
377
+ const resolved = { item, ...resolveItem(paths, root, name, item) }
378
+ // `AGENTS.md` es un nombre compartido entre herramientas y la instancia ya tiene el suyo: en
379
+ // modo embedded el archivo del runner y el de la empresa son el mismo, y pedirle que se
380
+ // referencie a sí mismo no significa nada.
381
+ const propio = path.basename(resolved.target) === 'AGENTS.md'
290
382
  if (!fs.existsSync(resolved.target)) errors.push(`falta ${item.target}`)
291
- else if (!fs.readFileSync(resolved.target, 'utf8').includes('AGENTS.md')) {
383
+ else if (!propio && !fs.readFileSync(resolved.target, 'utf8').includes('AGENTS.md')) {
292
384
  warnings.push(`${item.target}: no referencia AGENTS.md; verifica las reglas globales`)
293
385
  }
386
+ if (deliveryState(M.readRunners(root), name, resolved, opsPrefix(root)) === 'desactualizado') {
387
+ warnings.push(`${item.target}: Cauce trae una versión más nueva y vos no lo tocaste; reinstalá`)
388
+ }
294
389
  }
390
+ // Divergir no es un error: puede ser una mejora río arriba esperando reinstalación. Reportarlo
391
+ // como error dejaba a la empresa sin salida, porque `install` tampoco lo actualizaba.
392
+ const recorded = M.readRunners(root)
295
393
  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`)
394
+ const resolved = { item, ...resolveItem(paths, root, name, item) }
395
+ const situacion = deliveryState(recorded, name, resolved, opsPrefix(root))
396
+ if (situacion === 'nuevo') errors.push(`falta ${item.target}`)
397
+ else if (situacion === 'desactualizado') {
398
+ warnings.push(`${item.target}: hay una versión más nueva en Cauce; reinstalá el adaptador`)
399
+ } else if (situacion === 'ajeno') {
400
+ warnings.push(`${item.target}: lo editaste y es del toolkit; `
401
+ + 'agregá lo tuyo al lado o reinstalá con --force para volver a la versión de Cauce')
301
402
  }
302
403
  }
303
404
  // Un cargo que quedó fuera, o cuya descripción cambió en el catálogo, deja al runner eligiendo
@@ -306,7 +407,7 @@ function doctor(root, name, output = console) {
306
407
  const missing = []
307
408
  const stale = []
308
409
  for (const role of roleCatalog(root)) {
309
- const file = path.join(root, runner.roleSkills, role.slug, 'SKILL.md')
410
+ const file = path.join(paths.install, runner.roleSkills, role.slug, 'SKILL.md')
310
411
  if (!fs.existsSync(file)) missing.push(role.slug)
311
412
  else if (fs.readFileSync(file, 'utf8') !== roleSkill(role)) stale.push(role.slug)
312
413
  }
@@ -327,12 +428,35 @@ function doctor(root, name, output = console) {
327
428
  return { runner, errors, warnings }
328
429
  }
329
430
 
330
- function install(root, name, output = console) {
431
+ // Clave de entrega de un archivo del adaptador. Va en su propia sección del manifiesto porque puede
432
+ // caer fuera de la instancia: en sidecar el wiring vive en la carpeta de la compañía.
433
+ function deliveryKey(name, target) {
434
+ return `${name}/${target.split(path.sep).join('/')}`
435
+ }
436
+
437
+ // En qué estado quedó un archivo que este adaptador entregó alguna vez.
438
+ //
439
+ // nuevo no existe todavía
440
+ // al día idéntico a lo que trae Cauce
441
+ // desactualizado la empresa no lo tocó, pero río arriba cambió
442
+ // ajeno difiere de lo entregado: alguien lo editó acá
443
+ //
444
+ // Sin el registro de entrega, `desactualizado` y `ajeno` se ven igual. `install` resolvía esa duda
445
+ // conservando siempre, así que ninguna mejora del toolkit llegaba nunca a un runner ya instalado.
446
+ function deliveryState(recorded, name, resolved, prefix = '') {
447
+ if (!fs.existsSync(resolved.target)) return 'nuevo'
448
+ const current = M.digest(resolved.target)
449
+ if (current === M.digestText(render(resolved.source, prefix))) return 'al día'
450
+ const delivered = recorded[deliveryKey(name, resolved.item.target)]
451
+ return delivered && delivered === current ? 'desactualizado' : 'ajeno'
452
+ }
453
+
454
+ function install(root, name, output = console, options = {}) {
331
455
  const runner = runnerManifest(root, name)
332
456
  const errors = check(root)
333
457
  if (errors.length) throw new Error(`La automatización no es instalable:\n- ${errors.join('\n- ')}`)
334
458
  const paths = runnerPaths(root, name, runner)
335
- const incoming = JSON.parse(fs.readFileSync(paths.configSource, 'utf8'))
459
+ const incoming = runnerConfig(paths, root)
336
460
  let current = {}
337
461
  if (fs.existsSync(paths.configTarget)) {
338
462
  try { current = JSON.parse(fs.readFileSync(paths.configTarget, 'utf8')) } catch (error) {
@@ -341,31 +465,58 @@ function install(root, name, output = console) {
341
465
  }
342
466
  const items = [...(runner.instructions || []), ...(runner.artifacts || [])]
343
467
  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
- }
468
+ F.assertNoSymlinkPath(paths.install, paths.configTarget)
469
+ for (const resolved of resolvedItems) F.assertNoSymlinkPath(paths.install, resolved.target)
470
+
471
+ const recorded = M.readRunners(root)
472
+ const prefix = opsPrefix(root)
473
+ const state = new Map(resolvedItems.map((r) => [r, deliveryState(recorded, name, r, prefix)]))
474
+ // Un archivo de instrucciones es del proyecto y se conserva; uno ejecutable es del toolkit y se
475
+ // reemplaza. Editarlo detiene la instalación antes de pisarlo, igual que hace `upgrade`.
476
+ const editados = resolvedItems.filter((resolved) => {
477
+ return state.get(resolved) === 'ajeno' && !runner.instructions.includes(resolved.item)
478
+ })
479
+ if (editados.length && !options.force) {
480
+ throw new Error(
481
+ `${editados.length} archivo(s) que mantiene Cauce fueron editados y se perderían:\n`
482
+ + `${editados.map((resolved) => `- ${resolved.item.target}`).join('\n')}\n\n`
483
+ + 'Son del toolkit: en vez de editarlos, agregá lo tuyo al lado y registralo en la\n'
484
+ + 'configuración de tu runner. Si el cambio ya no te sirve, repetí con --force.',
485
+ )
352
486
  }
353
487
  const merged = pruneSupersededHooks(mergeConfig(current, incoming))
354
488
  F.atomicWriteJson(paths.configTarget, merged.config)
355
489
  for (const { wrapper, files } of merged.replaced) {
356
490
  output.log(`− ${name}: reemplazado ${[...new Set(files)].join(', ')} por ${wrapper}`)
357
491
  }
492
+ // Dónde aterrizó, no sólo qué archivo: en sidecar el destino no es el repo desde el que se corrió
493
+ // el comando, y descubrirlo por sorpresa es la diferencia entre confiar y adivinar.
494
+ if (paths.install !== root) {
495
+ output.log(` ${name}: el runner se abre en ${paths.install} — ahí queda su configuración`)
496
+ }
358
497
  output.log(`✓ ${name}: configuración instalada en ${runner.config.target}`)
498
+ const entregado = { ...recorded }
359
499
  for (const resolved of resolvedItems) {
360
- if (fs.existsSync(resolved.target)) {
361
- output.log(`= ${name}: conservado ${resolved.item.target}`)
500
+ const situacion = state.get(resolved)
501
+ const propio = runner.instructions.includes(resolved.item)
502
+ if (situacion === 'ajeno' && propio) {
503
+ output.log(`= ${name}: conservado ${resolved.item.target} (tiene cambios tuyos)`)
504
+ } else if (situacion === 'al día') {
505
+ output.log(`= ${name}: ${resolved.item.target} ya está al día`)
362
506
  } else {
363
507
  fs.mkdirSync(path.dirname(resolved.target), { recursive: true })
364
- fs.copyFileSync(resolved.source, resolved.target)
365
- output.log(`✓ ${name}: instalado ${resolved.item.target}`)
508
+ F.atomicWrite(resolved.target, render(resolved.source, opsPrefix(root)))
509
+ const verbo = situacion === 'nuevo' ? 'instalado' : 'actualizado'
510
+ output.log(`✓ ${name}: ${verbo} ${resolved.item.target}`)
511
+ }
512
+ // Sólo se anota lo que Cauce puso: un archivo conservado con cambios de la empresa no es una
513
+ // entrega, y registrarlo lo volvería indistinguible de uno intacto en la próxima instalación.
514
+ if (!(situacion === 'ajeno' && propio)) {
515
+ entregado[deliveryKey(name, resolved.item.target)] = M.digest(resolved.target)
366
516
  }
367
517
  }
368
518
  installRoleSkills(root, runner, output)
519
+ M.write(root, undefined, entregado)
369
520
  return runner
370
521
  }
371
522
 
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,13 +582,27 @@ 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—
596
596
  // reimplemente el recorrido del catálogo.
597
+ // La raíz ops de un comando que no la recibe. El shim `tools/ops.js` la exporta porque sabe dónde
598
+ // vive: sin eso, invocarlo desde otra carpeta —lo normal en sidecar— la resolvía contra el cwd.
599
+ function opsRoot(dir) {
600
+ return path.resolve(dir || process.env.OPS_ROOT || '.')
601
+ }
602
+
597
603
  function agents(action, dir) {
598
604
  if (action !== 'list') fail(`Acción de agents desconocida: ${action || '(vacía)'}`, 2)
599
- const root = path.resolve(dir || '.')
605
+ const root = opsRoot(dir)
600
606
  const roles = AG.list(root)
601
607
  if (process.argv.includes('--json')) {
602
608
  // `path` viene resuelto: quien consuma esto no debería reconstruir dónde ganó la precedencia.
@@ -685,7 +691,7 @@ async function integration(action, rootArg, provider, key) {
685
691
  }
686
692
 
687
693
  function automation(action, rootArg, runnerName) {
688
- const root = path.resolve(rootArg || '.')
694
+ const root = opsRoot(rootArg)
689
695
  if (action === 'list-hooks') return A.listHooks()
690
696
  if (action === 'list') {
691
697
  for (const name of A.RUNNER_NAMES) {
@@ -717,7 +723,8 @@ function automation(action, rootArg, runnerName) {
717
723
  }
718
724
  if (action === 'install') {
719
725
  let runner
720
- try { runner = A.install(root, runnerName) } catch (error) { fail(error.message, 2) }
726
+ const force = process.argv.includes('--force')
727
+ try { runner = A.install(root, runnerName, console, { force }) } catch (error) { fail(error.message, 2) }
721
728
  if (runnerName === 'codex') {
722
729
  console.log(' Codex pedirá revisar y confiar en hooks nuevos al iniciar sesión.')
723
730
  }
@@ -755,12 +762,12 @@ function evaluate(agent) {
755
762
 
756
763
  function team(action, slug) {
757
764
  if (action === 'list') {
758
- for (const name of T.list(process.cwd())) console.log(name)
765
+ for (const name of T.list(opsRoot())) console.log(name)
759
766
  return
760
767
  }
761
768
  if (!['check', 'show'].includes(action)) fail(`Acción de team desconocida: ${action || '(vacía)'}`, 2)
762
769
  try {
763
- const result = T.validate(process.cwd(), slug)
770
+ const result = T.validate(opsRoot(), slug)
764
771
  for (const error of result.errors) console.error(`✗ ${error}`)
765
772
  if (result.errors.length) fail(`${slug}: ${result.errors.length} error(es)`, 1)
766
773
  if (action === 'show') {
@@ -13,24 +13,46 @@ const path = require('node:path')
13
13
 
14
14
  const FILE = path.join('.cauce', 'manifest.json')
15
15
 
16
+ function digestText(content) {
17
+ return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16)
18
+ }
19
+
16
20
  function digest(file) {
17
- try {
18
- return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex').slice(0, 16)
19
- } catch { return '' }
21
+ try { return digestText(fs.readFileSync(file)) } catch { return '' }
20
22
  }
21
23
 
22
- function read(root) {
24
+ // Dos secciones porque son dos entregas distintas: `files` es lo que se materializó dentro de la
25
+ // instancia y `runners` lo que un adaptador dejó fuera de ella —en modo sidecar el wiring vive en
26
+ // la carpeta de la compañía—. El registro se queda igual acá, que es el repo que la empresa versiona.
27
+ function readAll(root) {
23
28
  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 {} }
29
+ const data = JSON.parse(fs.readFileSync(path.join(root, FILE), 'utf8')) || {}
30
+ return {
31
+ files: typeof data.files === 'object' && data.files ? data.files : {},
32
+ runners: typeof data.runners === 'object' && data.runners ? data.runners : {},
33
+ }
34
+ } catch { return { files: {}, runners: {} } }
27
35
  }
28
36
 
29
- function write(root, files) {
37
+ function read(root) { return readAll(root).files }
38
+
39
+ function readRunners(root) { return readAll(root).runners }
40
+
41
+ // Cada sección que no se pasa se conserva: `upgrade` no sabe del wiring y `install` no sabe de la
42
+ // instancia, y ninguno de los dos debería borrar lo que el otro anotó.
43
+ function write(root, files, runners) {
44
+ const current = readAll(root)
30
45
  const target = path.join(root, FILE)
31
46
  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`)
47
+ const ordered = (map) => Object.fromEntries(
48
+ Object.entries(map).sort(([left], [right]) => left.localeCompare(right)),
49
+ )
50
+ const data = {
51
+ version: 1,
52
+ files: ordered(files || current.files),
53
+ runners: ordered(runners || current.runners),
54
+ }
55
+ fs.writeFileSync(target, `${JSON.stringify(data, null, 2)}\n`)
34
56
  }
35
57
 
36
58
  // Registra lo entregado en una ruta, relativo a la raíz de la instancia.
@@ -40,6 +62,15 @@ function record(root, relative, files) {
40
62
  return current
41
63
  }
42
64
 
65
+ // Olvida lo que ya no está en disco. Sin esto el registro sólo crece: una ruta retirada deja su
66
+ // digest para siempre, y el día que un nombre se reutilice la entrega nueva se leería como una
67
+ // edición local y detendría la actualización.
68
+ function prune(root, files) {
69
+ return Object.fromEntries(
70
+ Object.entries(files).filter(([file]) => fs.existsSync(path.join(root, file))),
71
+ )
72
+ }
73
+
43
74
  // Archivos que la empresa modificó después de recibirlos. Un archivo sin registro previo no
44
75
  // cuenta: llegó con una versión anterior a este mecanismo, o lo agregó el proyecto.
45
76
  function edited(root, relative, files) {
@@ -51,4 +82,4 @@ function edited(root, relative, files) {
51
82
  })
52
83
  }
53
84
 
54
- module.exports = { FILE, digest, edited, read, record, write }
85
+ module.exports = { FILE, digest, digestText, 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.1",
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-*`.
@@ -8,6 +8,12 @@
8
8
  const path = require('path')
9
9
 
10
10
  const root = path.join(__dirname, '..')
11
+
12
+ // El shim sabe dónde vive; quien lo invoca, no. En modo sidecar se lo llama desde la carpeta de la
13
+ // compañía —`node <empresa>-ops/tools/ops.js …`— y sin esto cada comando resolvería su raíz contra
14
+ // el cwd: `agents list` y `team list` devolvían vacío en vez de fallar.
15
+ process.env.OPS_ROOT = process.env.OPS_ROOT || root
16
+
11
17
  const candidates = [
12
18
  () => require.resolve('@ingeniomaps/cauce/engine/cli/ops.js', { paths: [root] }),
13
19
  () => require.resolve(path.join(root, '.ops', 'engine', 'cli', 'ops.js')),
@@ -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.