@ingeniomaps/cauce 0.52.0 → 0.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/agents/roles/system/backend-engineer/learning/sources.yaml +2 -2
  3. package/agents/roles/system/community-manager/learning/sources.yaml +2 -2
  4. package/agents/roles/system/content-specialist/learning/sources.yaml +1 -1
  5. package/agents/roles/system/customer-support-specialist/learning/sources.yaml +1 -1
  6. package/agents/roles/system/data-analyst/learning/sources.yaml +2 -2
  7. package/agents/roles/system/data-governance-steward/learning/sources.yaml +1 -1
  8. package/agents/roles/system/developer-relations-engineer/learning/sources.yaml +3 -3
  9. package/agents/roles/system/devops-engineer/learning/sources.yaml +1 -1
  10. package/agents/roles/system/financial-controller/learning/sources.yaml +1 -1
  11. package/agents/roles/system/frontend-engineer/learning/sources.yaml +1 -1
  12. package/agents/roles/system/growth-marketer/learning/sources.yaml +2 -2
  13. package/agents/roles/system/integrations-engineer/learning/sources.yaml +6 -6
  14. package/agents/roles/system/mobile-engineer/learning/sources.yaml +1 -1
  15. package/agents/roles/system/people-operations-manager/learning/sources.yaml +1 -1
  16. package/agents/roles/system/privacy-compliance-specialist/learning/sources.yaml +1 -1
  17. package/agents/roles/system/product-marketing-manager/learning/sources.yaml +1 -1
  18. package/agents/roles/system/qa-engineer/learning/sources.yaml +2 -2
  19. package/agents/roles/system/site-reliability-engineer/learning/sources.yaml +1 -1
  20. package/agents/roles/system/software-architect/learning/sources.yaml +2 -2
  21. package/agents/roles/system/solutions-engineer/learning/sources.yaml +2 -2
  22. package/agents/roles/system/technical-writer/learning/sources.yaml +4 -4
  23. package/agents/roles/system/treasury-analyst/learning/sources.yaml +2 -2
  24. package/agents/roles/system/ui-designer/learning/sources.yaml +1 -1
  25. package/agents/roles/system/ux-designer/learning/sources.yaml +1 -1
  26. package/automatization/hooks/guard-dependencies.sh +1 -1
  27. package/automatization/hooks/guard-destructive.sh +1 -1
  28. package/automatization/hooks/guard-engine.sh +1 -1
  29. package/automatization/hooks/guard-generated.sh +1 -1
  30. package/automatization/hooks/guard-git-add.sh +1 -1
  31. package/automatization/hooks/guard-governance.sh +1 -1
  32. package/automatization/hooks/guard-integration-snapshot.sh +1 -1
  33. package/automatization/hooks/guard-migrations.sh +1 -1
  34. package/automatization/hooks/guard-planning-drift.sh +1 -1
  35. package/automatization/hooks/guard-secrets.sh +1 -1
  36. package/automatization/hooks/guard-test-evidence.sh +1 -1
  37. package/automatization/hooks/guard-verify.sh +1 -1
  38. package/automatization/hooks/guard-workspace-boundary.sh +1 -1
  39. package/automatization/hooks/run-hook.sh +3 -2
  40. package/automatization/runners/antigravity/hook.js +13 -14
  41. package/automatization/shared/eval-only.js +3 -3
  42. package/automatization/workflows/agent-eval.js +3 -3
  43. package/automatization/workflows/agent-promote.js +4 -4
  44. package/automatization/workflows/autobuild.js +12 -12
  45. package/automatization/workflows/flow-eval.js +10 -7
  46. package/automatization/workflows/flow.js +9 -10
  47. package/automatization/workflows/onboard.js +4 -5
  48. package/engine/agents/evaluations.js +18 -16
  49. package/engine/agents/learning-files.js +111 -0
  50. package/engine/agents/learning-sources.js +166 -0
  51. package/engine/agents/learning.js +93 -272
  52. package/engine/automation/config.js +175 -0
  53. package/engine/automation/hooks.js +96 -0
  54. package/engine/automation/index.js +17 -431
  55. package/engine/automation/roles.js +72 -0
  56. package/engine/automation/runners.js +162 -0
  57. package/engine/cli/catalog.js +7 -12
  58. package/engine/cli/instance.js +11 -10
  59. package/engine/cli/io.js +1 -1
  60. package/engine/cli/ops.js +12 -10
  61. package/engine/cli/wiring.js +2 -4
  62. package/engine/config/validate.js +2 -1
  63. package/engine/core/frontmatter.js +2 -1
  64. package/engine/core/ownership.js +5 -4
  65. package/engine/core/scan.js +3 -0
  66. package/engine/flows/registry.js +2 -2
  67. package/engine/hooks/files.js +160 -0
  68. package/engine/hooks/input.js +129 -0
  69. package/engine/hooks/run.js +24 -449
  70. package/engine/hooks/shell.js +197 -0
  71. package/engine/integrations/registry.js +2 -0
  72. package/engine/planning/contracts.js +12 -10
  73. package/engine/planning/parser.js +4 -3
  74. package/engine/planning/state.js +2 -1
  75. package/package.json +5 -5
@@ -0,0 +1,162 @@
1
+ 'use strict'
2
+
3
+ // El modelo de un adaptador de runner: qué archivos trae, contra qué carpeta se resuelven y cómo se
4
+ // rellenan sus marcadores. Vive aparte de los comandos porque cambia por otra razón —cuando se agrega
5
+ // un runner o se mueve un marcador— y porque `check`, `doctor`, `install` y `uninstall` lo consumen
6
+ // los cuatro igual.
7
+
8
+ const fs = require('node:fs')
9
+ const path = require('node:path')
10
+ const { spawnSync } = require('node:child_process')
11
+ const F = require('../core/files')
12
+ const O = require('../core/ownership')
13
+
14
+ const RUNNER_NAMES = ['claude', 'codex', 'gemini', 'antigravity']
15
+
16
+ // Adaptadores y workflows viven en el paquete, no en la instancia: son definiciones que el motor
17
+ // consume y que ninguna empresa edita —`RUNNER_NAMES` es cerrado, así que ni siquiera puede agregar
18
+ // uno propio—. Los hooks sí se quedan en el proyecto: la configuración del runner los nombra por
19
+ // ruta literal y no sabe resolver en cascada.
20
+ //
21
+ // Se busca por `runners/` y no por `automatization/`: toda instancia tiene el segundo —ahí viven sus
22
+ // hooks— y encontrarlo daría por buena una dependencia sin instalar.
23
+ function packagedAutomation(root) {
24
+ const runners = O.packagePath(root, path.join('automatization', 'runners'))
25
+ return runners ? path.dirname(runners) : ''
26
+ }
27
+
28
+ function runnerManifest(root, name) {
29
+ if (!RUNNER_NAMES.includes(name)) {
30
+ throw new Error(`runner debe ser ${RUNNER_NAMES.join(', ')}`)
31
+ }
32
+ const packaged = packagedAutomation(root)
33
+ if (!packaged) {
34
+ throw new Error('no encuentro automatization/: corré "npm install" en la raíz del repo ops')
35
+ }
36
+ const file = path.join(packaged, 'runners', name, 'manifest.json')
37
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch (error) {
38
+ throw new Error(`${name}: manifest inválido (${error.message})`)
39
+ }
40
+ }
41
+
42
+ // Dónde abre el dev su herramienta, que no siempre es la raíz ops. En modo sidecar el repo ops es
43
+ // un hermano de los repos de producto: `<empresa>-ops/` coordina, `<empresa>/` es lo que se abre.
44
+ // Instalar dentro del sidecar dejaría al runner sin ver una sola línea de código.
45
+ function installRoot(root) {
46
+ try {
47
+ const config = JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8'))
48
+ if (config.mode === 'sidecar') return path.resolve(root, '..')
49
+ } catch { /* sin configuración legible, instalar donde está */ }
50
+ return root
51
+ }
52
+
53
+ // Cómo se nombra la raíz ops desde ahí: `<empresa>-ops/` en sidecar, vacío cuando coinciden.
54
+ function opsPrefix(root) {
55
+ const relative = path.relative(installRoot(root), root)
56
+ return relative ? `${relative.split(path.sep).join('/')}/` : ''
57
+ }
58
+
59
+ function runnerPaths(root, name, runner) {
60
+ const automationRoot = packagedAutomation(root)
61
+ const sourceDir = path.join(automationRoot, 'runners', name)
62
+ const install = installRoot(root)
63
+ const configSource = F.assertWithin(
64
+ sourceDir,
65
+ path.resolve(sourceDir, runner.config.source),
66
+ `${name}: config.source`,
67
+ )
68
+ const configTarget = F.assertWithin(
69
+ install,
70
+ path.resolve(install, runner.config.target),
71
+ `${name}: config.target`,
72
+ )
73
+ return { automationRoot, sourceDir, configSource, configTarget, install }
74
+ }
75
+
76
+ function resolveItem(paths, root, name, item) {
77
+ return {
78
+ automationRoot: paths.automationRoot,
79
+ opsRoot: root,
80
+ source: F.assertWithin(
81
+ paths.automationRoot,
82
+ path.resolve(paths.sourceDir, item.source),
83
+ `${name}: source`,
84
+ ),
85
+ target: F.assertWithin(
86
+ paths.install,
87
+ path.resolve(paths.install, item.target),
88
+ `${name}: target`,
89
+ ),
90
+ }
91
+ }
92
+
93
+ // Todo lo que un adaptador copia —configuración, instrucciones, workflows— nombra rutas relativas a
94
+ // la carpeta donde se abre la herramienta. Cuando la raíz ops no es esa carpeta, cada una necesita el
95
+ // prefijo. El marcador es explícito en la fuente en vez de adivinarse con reemplazos de texto:
96
+ // `{{OPS_DIR}}` significa "acá va la raíz ops, o nada si coinciden".
97
+ //
98
+ // Un solo render, y `install` escribe exactamente lo que `doctor` compara.
99
+ const OPS_DIR = '{{OPS_DIR}}'
100
+
101
+ // Un fragmento que varios adaptadores comparten, resuelto contra la raíz de `automatization/`. El
102
+ // arranque es el mismo trabajo en tres formatos —la sección de un `AGENTS.md`, el cuerpo de un
103
+ // `SKILL.md`, el prompt de un `.toml`—, y escrito tres veces hizo lo que hace siempre una copia: dos
104
+ // de ellas anunciaban cinco puntos y enumeraban seis, con el sexto doblado dentro del quinto.
105
+ //
106
+ // El workflow de Claude queda afuera a propósito: es un programa con fases y esquemas, no una prosa
107
+ // enmarcada, así que su arranque no es una copia de éste sino otra cosa.
108
+ //
109
+ // Se resuelve antes que `{{OPS_DIR}}` para que el fragmento también reciba el prefijo, y no anida:
110
+ // lo incluido se copia tal cual.
111
+ const INCLUDE = /\{\{INCLUDE:([^}]+)\}\}/g
112
+
113
+ function inline(text, automationRoot) {
114
+ return text.replace(INCLUDE, (_, relative) => {
115
+ const shared = F.assertWithin(
116
+ automationRoot,
117
+ path.resolve(automationRoot, relative.trim()),
118
+ 'INCLUDE',
119
+ )
120
+ return fs.readFileSync(shared, 'utf8').trimEnd()
121
+ })
122
+ }
123
+
124
+ // `{{OPS_ROOT}}` es la raíz absoluta. La necesita quien no puede deducirla de dónde lo ejecutaron
125
+ // —el puente de Antigravity—, y por eso no reemplaza a `{{OPS_DIR}}`: una ruta absoluta escrita en un
126
+ // archivo se rompe si el proyecto se mueve, así que la lleva sólo el que se queda sin alternativa.
127
+ const OPS_ROOT = '{{OPS_ROOT}}'
128
+
129
+ function render(file, prefix, automationRoot, opsRoot = '') {
130
+ return inline(fs.readFileSync(file, 'utf8'), automationRoot)
131
+ .split(OPS_ROOT).join(opsRoot)
132
+ .split(OPS_DIR).join(prefix)
133
+ }
134
+
135
+ function runnerConfig(paths, root) {
136
+ return JSON.parse(render(paths.configSource, opsPrefix(root), paths.automationRoot, root))
137
+ }
138
+
139
+ // Runners que además de los archivos necesitan un registro propio para que el wiring cuente. Copiar
140
+ // y quedarse ahí deja un plugin inerte: los archivos están, `doctor` da verde y nada se ejecuta.
141
+ function activated(runner) {
142
+ if (!runner.activation) return true
143
+ const result = spawnSync(runner.command, runner.activation.verify, { encoding: 'utf8' })
144
+ if (result.status !== 0) return null
145
+ return `${result.stdout || ''}`.includes(runner.activation.expect)
146
+ }
147
+
148
+ module.exports = {
149
+ activated,
150
+ RUNNER_NAMES,
151
+ OPS_DIR,
152
+ OPS_ROOT,
153
+ packagedAutomation,
154
+ runnerManifest,
155
+ installRoot,
156
+ opsPrefix,
157
+ runnerPaths,
158
+ resolveItem,
159
+ inline,
160
+ render,
161
+ runnerConfig,
162
+ }
@@ -14,8 +14,6 @@ const O = require('../core/ownership')
14
14
  const IN = require('./instance')
15
15
  const { fail, opsRoot } = require('./io')
16
16
 
17
- // La raíz ops de un comando que no la recibe. El shim `tools/ops.js` la exporta porque sabe dónde
18
-
19
17
  function agentsFork(slug, dir) {
20
18
  const root = opsRoot(dir)
21
19
  if (!slug) fail('Falta el cargo: ops agents fork <cargo> [ops-root]', 2)
@@ -37,7 +35,7 @@ function agents(action, dir, extra, cli) {
37
35
  if (action !== 'list') fail(`Acción de agents desconocida: ${action || '(vacía)'}`, 2)
38
36
  const root = opsRoot(dir)
39
37
  // Una empresa mantiene sus cargos, no los nuestros: `learn` sobre uno del catálogo se niega, así que
40
- // recorrer los 48 para encontrar el suyo es ruido. `--own` es lo que hace ejecutable ese recorrido.
38
+ // recorrer el catálogo entero para encontrar el suyo es ruido. `--own` hace ejecutable ese recorrido.
41
39
  const own = cli.has('--own')
42
40
  const system = cli.has('--system')
43
41
  const roles = AG.list(root).filter((role) => (own ? !role.system : true) && (system ? role.system : true))
@@ -53,7 +51,7 @@ function agents(action, dir, extra, cli) {
53
51
  path: path.relative(root, role.dir).split(path.sep).join('/'),
54
52
  }))))
55
53
  }
56
- // Una línea por cargo, alineadas, para elegir a quién asignarle una tarea sin abrir 47 carpetas.
54
+ // Una línea por cargo, alineadas, para elegir a quién asignarle una tarea sin abrir una carpeta.
57
55
  const width = roles.reduce((max, role) => Math.max(max, role.slug.length), 0)
58
56
  for (const role of roles) {
59
57
  const mark = role.system ? '' : ' (propio)'
@@ -161,8 +159,7 @@ function learn(agent, cli) {
161
159
 
162
160
  function evaluate(agent, caso, cli) {
163
161
  const root = opsRoot()
164
- // De quién son los casos. Se nombra en vez de deducirse del slug: un cargo y un recorrido pueden
165
- // llamarse igual sin colisionar, y deducirlo los volvería ambiguos el día que eso pase.
162
+ // De quién son los casos, resuelto por bandera y no por el slug. Por qué no se deduce, en `subject`.
166
163
  const kind = cli.has('--flow') ? 'flow' : 'agent'
167
164
  // El banco sólo tiene sentido acá: en una empresa el cargo que se evalúa es suyo —propio o
168
165
  // adoptado— y su `planning/` ya es el lugar legítimo donde trabajar.
@@ -206,15 +203,13 @@ function evaluate(agent, caso, cli) {
206
203
  for (const warning of [...result.warnings, ...runs.warnings]) console.warn(`⚠ ${warning}`)
207
204
  for (const error of errors) console.error(`✗ ${error}`)
208
205
  if (errors.length) fail(`\n${errors.length} error(es)`, 1)
209
- // El veredicto vigente de cada caso, compuesto sobre todas las corridas, no el de la última: desde
210
- // que `--cases` existe una corrida cubre menos casos a propósito, y leer sólo la última decía «1/1
211
- // pasan» de un sujeto con los cuatro medidos. Cuándo se midió es un rango cuando hubo más de una,
212
- // porque lo compuesto es tan viejo como su parte más rancia.
213
- const cuando = runs.state && runs.state.oldest !== runs.state.newest
206
+ // Cuándo se midió es un rango cuando el veredicto vigente lo aportó más de una corrida. Por qué se
207
+ // compone en vez de leerse la última, en `composed`.
208
+ const measuredAt = runs.state && runs.state.oldest !== runs.state.newest
214
209
  ? `${runs.state.oldest}…${runs.state.newest}`
215
210
  : (runs.state ? runs.state.newest : '')
216
211
  const lastRun = runs.state
217
- ? `${runs.state.passed}/${runs.state.total} pasan (${cuando})`
212
+ ? `${runs.state.passed}/${runs.state.total} pasan (${measuredAt})`
218
213
  : 'sin correr'
219
214
  if (kind === 'flow') {
220
215
  return console.log(`✓ ${agent}: ${runs.cases} caso(s) — ${lastRun}, ` +
@@ -21,6 +21,9 @@ const { fail } = require('./io')
21
21
 
22
22
  const PROJECT_ROOT = path.resolve(__dirname, '..', '..')
23
23
 
24
+ // Proveedores que el toolkit conoce, para saltearlos al copiar la plantilla: su andamiaje
25
+ // —configuración, staging/, proposed/— no se materializa hasta que alguien lo habilite. Antes cada
26
+ // instancia recibía el de un proveedor apagado que quizá no usaba nunca, y que nadie actualizaba.
24
27
  function providerNames() {
25
28
  try {
26
29
  const file = path.join(PROJECT_ROOT, 'template', 'integrations', 'config.json')
@@ -34,7 +37,8 @@ function copyTemplate(source, target, replacements, force, skip = [], quiet = fa
34
37
  for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
35
38
  if (skip.includes(entry.name)) continue
36
39
  const from = path.join(source, entry.name)
37
- // npm no incluye un `.gitignore` dentro de un tarball, así que viaja sin punto y se restituye
40
+ // npm no incluye un `.gitignore` dentro de un tarball —comprobado con `npm pack --dry-run` en npm
41
+ // 11.16.0: lo deja afuera en la raíz y en subdirectorios—, así que viaja sin punto y se restituye
38
42
  // acá. Sin esto el archivo existe en el repo del toolkit y desaparece para todo consumidor real.
39
43
  const to = path.join(target, entry.name === 'gitignore' ? '.gitignore' : entry.name)
40
44
  if (entry.isDirectory()) copyTemplate(from, to, replacements, force, skip, quiet)
@@ -99,9 +103,9 @@ function undeclareEngine(manifest) {
99
103
  if (!('@ingeniomaps/cauce' in dev)) return []
100
104
  delete dev['@ingeniomaps/cauce']
101
105
  pkg.devDependencies = dev
102
- const generado = !Object.keys(dev).length && !Object.keys(pkg.dependencies || {}).length
106
+ const ours = !Object.keys(dev).length && !Object.keys(pkg.dependencies || {}).length
103
107
  && !Object.keys(pkg.scripts || {}).length && pkg.private === true && pkg.version === '0.0.0'
104
- if (generado) {
108
+ if (ours) {
105
109
  fs.rmSync(manifest, { force: true })
106
110
  return ['package.json (lo había creado init: sin dependencias ni scripts propios)']
107
111
  }
@@ -110,9 +114,6 @@ function undeclareEngine(manifest) {
110
114
  return ['package.json: se quitó la dependencia del motor y el resto queda como estaba']
111
115
  }
112
116
 
113
- // Proveedores que el toolkit conoce, para saltearlos al copiar la plantilla: su andamiaje
114
- // —configuración, staging/, proposed/— no se materializa hasta que alguien lo habilite. Antes cada
115
-
116
117
  // El andamiaje de una instancia, sin leer argv. `init` es la cáscara que traduce banderas a esto, y
117
118
  // el banco de evaluación lo llama directo: crear una instancia programáticamente no puede depender de
118
119
  // cómo venga escrita la línea de comandos.
@@ -123,8 +124,8 @@ function scaffold(root, { name, mode, force = false, quiet = false }) {
123
124
  '{{WORKSPACE_PATH}}': mode === 'embedded' ? '.' : '..',
124
125
  }, force, providerNames(), quiet)
125
126
  // No se copia `.github/`: `ci.yml` valida el toolkit con `npm run ci` —que una instancia no tiene— y
126
- // el ciclo de aprendizaje dejó de distribuirse en 0.4.0. Copiar salteando los dos únicos archivos
127
- // que existen dejaba `.github/workflows/` vacío en cada instancia.
127
+ // el ciclo de aprendizaje dejó de distribuirse en 0.4.0. Copiar salteando lo que no aplica dejaba
128
+ // `.github/workflows/` vacío en cada instancia.
128
129
  copyRuntime(
129
130
  path.join(PROJECT_ROOT, 'automatization', 'hooks'),
130
131
  path.join(root, 'automatization', 'hooks'),
@@ -173,7 +174,7 @@ function instanceVersion(root) {
173
174
  // Lo cuenta el mismo parser que usan `check` y `tree`, no una expresión regular propia: los moldes traen
174
175
  // ejemplos comentados, y contarlos a mano anunciaba una tarea en cola y otra terminada en una instancia
175
176
  // recién creada. Un aviso que exagera lo que se pierde se deja de leer igual que uno que lo minimiza.
176
- function loQueSePierde(root) {
177
+ function whatIsLost(root) {
177
178
  const planning = path.join(root, 'planning')
178
179
  const queued = P.readBacklog(planning).reduce((total, hito) => total + (hito.tasks || []).length, 0)
179
180
  const humanActions = ST.pendingHumanActions(planning).length
@@ -200,7 +201,7 @@ function destroy(dir, cli) {
200
201
  }
201
202
  if (O.mode(root) === 'toolkit') fail(`${root} es el toolkit: acá se fabrica Cauce, no se lo borra.`, 2)
202
203
 
203
- const loss = loQueSePierde(root)
204
+ const loss = whatIsLost(root)
204
205
  const lines = [
205
206
  loss.epicas && `${loss.epicas} épica(s) en el roadmap`,
206
207
  loss.enCola && `${loss.enCola} tarea(s) en la cola`,
package/engine/cli/io.js CHANGED
@@ -4,12 +4,12 @@ const path = require('node:path')
4
4
 
5
5
  // Terminar la corrida con un mensaje y un código. Vive aparte porque lo usa cada familia de comandos, y
6
6
  // dejarlo en el despacho obligaría a que cada módulo dependa del que lo invoca.
7
-
8
7
  function fail(message, code = 1) {
9
8
  console.error(message)
10
9
  process.exit(code)
11
10
  }
12
11
 
12
+ // La raíz ops de un comando que no la recibe. El shim `tools/ops.js` la exporta porque sabe dónde
13
13
  // vive: sin eso, invocarlo desde otra carpeta —lo normal en sidecar— la resolvía contra el cwd.
14
14
  function opsRoot(dir) {
15
15
  return path.resolve(dir || process.env.OPS_ROOT || '.')
package/engine/cli/ops.js CHANGED
@@ -32,15 +32,10 @@ const BOOT = require('./bootstrap')
32
32
  // Dónde aterriza una instancia cuando nadie eligió: una carpeta propia junto al código.
33
33
  const DEFAULT_TARGET = 'ops'
34
34
 
35
- // Cuántos servicios se listan en pantalla antes de recortar. El resto sigue en `--json`, que es lo que
36
- // consume el recorrido de arranque: recortar la lista es para leerla, no para acotar lo que se sabe.
37
-
38
35
  // Dónde va la instancia cuando nadie eligió destino. Parada frecuente: el dev ya creó `acme-ops/` y
39
36
  // corre `init` adentro. Sin esto la instancia caía en `acme-ops/ops/` —una carpeta del toolkit dentro
40
37
  // de otra— y el proyecto quedaba llamándose «acme-ops». La carpeta que ya nombra al toolkit es la
41
38
  // instancia; no hay una segunda adentro.
42
- // instancia recibía el de un proveedor apagado que quizá no usaba nunca, y que nadie actualizaba.
43
-
44
39
  function implicitTarget(cwd) {
45
40
  const base = path.basename(cwd)
46
41
  return base === DEFAULT_TARGET || base.endsWith('-ops') ? '.' : DEFAULT_TARGET
@@ -55,10 +50,19 @@ function defaultName(root) {
55
50
 
56
51
  // El motor no se instala solo: `npm install` baja el paquete que `init` acaba de declarar, y sin él el
57
52
  // shim, los cargos, los equipos y los adaptadores no se resuelven. Correrlo desde acá es lo que hace que
58
- // una instalación sea un comando y no una lista. En Windows el ejecutable es `npm.cmd`.
53
+ // una instalación sea un comando y no una lista.
54
+ //
55
+ // En Windows no alcanza con nombrarlo `npm.cmd`. Node documenta que un `.cmd` «no es ejecutable por sí
56
+ // solo» y nombra como salida lanzar cmd.exe con el comando de argumento —nodejs.org/api/child_process,
57
+ // consultado 2026-08-29—, que es lo que va acá. La otra salida obvia queda descartada: pasar `args`
58
+ // junto a la opción `shell` está deprecado en runtime (DEP0190, nodejs.org/api/deprecations).
59
+ //
60
+ // Documentado y no comprobado: acá no hay Windows, y ninguna prueba llega a esta línea porque el
61
+ // arranque recibe `npm` inyectado.
59
62
  function npmInstall(root) {
60
- const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
61
- const result = spawnSync(npm, ['install'], { cwd: root, stdio: 'inherit' })
63
+ const result = process.platform === 'win32'
64
+ ? spawnSync('cmd.exe', ['/c', 'npm', 'install'], { cwd: root, stdio: 'inherit' })
65
+ : spawnSync('npm', ['install'], { cwd: root, stdio: 'inherit' })
62
66
  if (result.error) {
63
67
  console.error(` no pude ejecutar npm (${result.error.code || result.error.message}).`)
64
68
  return 1
@@ -179,8 +183,6 @@ function usage() {
179
183
  // molde y no del wiring del runner, así que pasarle el suyo instalaría a la fuerza algo que nadie pidió.
180
184
  const NO_FLAGS = { has: () => false, value: (_flag, fallback = '') => fallback }
181
185
 
182
- // Si el proveedor terminó su propia configuración. Son dos interruptores y `sync` exige los dos: el
183
-
184
186
  async function run(cli) {
185
187
  const [command] = cli.positional
186
188
  if (!command || ['help', '--help', '-h'].includes(command)) return usage()
@@ -14,9 +14,10 @@ const OB = require('../core/onboarding')
14
14
  const IN = require('./instance')
15
15
  const { fail, opsRoot } = require('./io')
16
16
 
17
+ // Cuántos servicios se listan en pantalla antes de recortar. El resto sigue en `--json`, que es lo que
18
+ // consume el recorrido de arranque: recortar la lista es para leerla, no para acotar lo que se sabe.
17
19
  const MAX_LISTED = 20
18
20
 
19
-
20
21
  const INTEGRATION = {
21
22
  list: {
22
23
  run: (root) => {
@@ -111,9 +112,6 @@ const INTEGRATION = {
111
112
  },
112
113
  }
113
114
 
114
- // Dónde puede mirar una instancia: exactamente las raíces que declara, y nada por encima de ellas. Sale
115
- // de `ops.config.json` en vez de suponerse —el sidecar declara `..`, el embebido `.`— para que acotar las
116
- // raíces acote también el escaneo, y para que nadie termine recorriendo la carpeta de al lado.
117
115
  // Las tres reconciliaciones son el mismo comando con otra operación: se declaran en el mismo lugar
118
116
  // para que agregar una cuarta no pida tocar el despachador.
119
117
  for (const operation of ['reset', 'rebase', 'reconcile']) {
@@ -6,7 +6,8 @@ const MODES = ['embedded', 'sidecar', 'toolkit']
6
6
  // quien actualiza merece saber qué hacer con la línea, no sólo que sobra.
7
7
  //
8
8
  // `planningDir` no configuraba, prometía: nadie lo honraba y la ubicación no es opinable —`findOpsRoot`
9
- // reconoce una raíz ops justamente por tener `planning/` en la raíz—.
9
+ // reconoce una raíz ops por tener `ops.config.json` y `planning/` en ella, así que moverlo la
10
+ // desconocería—.
10
11
  const RETIRED = {
11
12
  planningDir: 'el motor siempre busca planning/ en la raíz del repositorio. Borrá la línea',
12
13
  }
@@ -6,7 +6,8 @@
6
6
  // draft se leía distinto según quién lo abriera.
7
7
  //
8
8
  // Se resuelve por el lado estricto: frontmatter es lo que encabeza el documento, y un `---` en el medio
9
- // es una línea horizontal de markdown. La clave admite lo que admite YAML.
9
+ // es una línea horizontal de markdown. La clave admite letras, dígitos, `_` y `-` —de ahí sale la
10
+ // discrepancia del dígito inicial, resuelta a favor de admitirlo—, y no lo que admitiría YAML entero.
10
11
 
11
12
  function frontmatter(text) {
12
13
  const block = (String(text).match(/^---\s*\n([\s\S]*?)\n---/) || [])[1] || ''
@@ -117,8 +117,8 @@ function mode(root) {
117
117
  }
118
118
  }
119
119
 
120
- // Dónde quedó el motor. El bridge de Antigravity repite esta cascada a mano porque corre antes de
121
- // poder cargar este módulo: si cambia acá, cambia allá.
120
+ // Dónde quedó el motor. El bridge de Antigravity y `run-hook.sh` repiten esta cascada a mano porque
121
+ // corren antes de poder cargar este módulo: si cambia acá, cambia en los dos.
122
122
  function engineAt(root, relative = '') {
123
123
  return packagePath(root, relative ? path.join('engine', relative) : 'engine')
124
124
  }
@@ -200,8 +200,9 @@ function retiredWithLearning(root) {
200
200
  }
201
201
 
202
202
  // Rutas que `upgrade` reemplaza. Todo lo que no aparezca acá pertenece al proyecto.
203
- // Se listan aunque todavía no existan en la instancia: así un archivo nuevo del sistema llega en
204
- // vez de esperar a que alguien lo cree a mano.
203
+ // Los archivos sueltos se listan aunque todavía no existan en la instancia, así que uno nuevo del
204
+ // sistema llega en vez de esperar a que alguien lo cree a mano. Una colección no: entra sólo si su
205
+ // `system/` ya está, porque el que se lista es el directorio y no cada archivo de adentro.
205
206
  function systemPaths(root) {
206
207
  const paths = [...SYSTEM_FILES]
207
208
  for (const collection of SYSTEM_COLLECTIONS) {
@@ -171,6 +171,9 @@ function scan(root, skip = '') {
171
171
  }
172
172
  }
173
173
 
174
+ // Dónde puede mirar una instancia: exactamente las raíces que declara, y nada por encima de ellas. Sale
175
+ // de `ops.config.json` en vez de suponerse —el sidecar declara `..`, el embebido `.`— para que acotar las
176
+ // raíces acote también el escaneo, y para que nadie termine recorriendo la carpeta de al lado.
174
177
  function workspaceRoots(root) {
175
178
  try {
176
179
  const config = JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8'))
@@ -101,8 +101,8 @@ function slugsIn(dir) {
101
101
  } catch { return [] }
102
102
  }
103
103
 
104
- // Un slug aparece una sola vez aunque exista en los dos niveles: el del proyecto ya ganó.
105
- // Los propios de la empresa y los que trae Cauce, sin duplicar un slug que ya ganó el proyecto.
104
+ // Los propios de la empresa y los que trae Cauce, con cada slug una sola vez aunque exista en los
105
+ // dos niveles: el del proyecto ya ganó.
106
106
  function list(root) {
107
107
  const system = systemTeams(root)
108
108
  const slugs = new Set([
@@ -0,0 +1,160 @@
1
+ 'use strict'
2
+
3
+ // Los guards que juzgan lo que está por escribirse: un secreto, un archivo generado, una migración,
4
+ // una prueba que se apaga, el motor de la dependencia. Todos leen el contenido entrante y no el disco
5
+ // —lo que ya estaba no lo escribió este cambio— y son el grupo `pre-files` del registro.
6
+
7
+ const fs = require('node:fs')
8
+ const path = require('node:path')
9
+ const { patchOf, filesOf, contentOf, cwdOf, block, configOf, findOpsRoot } = require('./input')
10
+
11
+ function secrets(input) {
12
+ for (const file of filesOf(input)) {
13
+ const base = path.basename(file)
14
+ if (/^(?:\.env|\.env\..+)$/.test(base) && !/\.(?:example|sample|template|schema|dist|tpl)$/.test(base)) {
15
+ block(`${file} parece contener secretos. Edita una plantilla o registra una acción humana.`)
16
+ }
17
+ if (/^(?:accesos\.md|credenciales.*|credentials.*\.json|.*service-account.*\.json|.*\.(?:pem|key))$/i.test(base)) {
18
+ block(`${file} parece un archivo de credenciales en texto plano.`)
19
+ }
20
+ // Nombres de credencial que la herramienta escribe sola y que la lista anterior no cubría:
21
+ // `.npmrc` guarda el token de publicación, `.netrc` el de cualquier host, `id_rsa` y sus tres
22
+ // hermanas una clave privada de SSH, y `credentials` las de AWS. Los cuatro son estándar, no
23
+ // exóticos — y las claves SSH van por nombre de algoritmo, no por prefijo.
24
+ //
25
+ // Esto tapa un caso conocido; no vuelve completo al guard. La forma de decidir sigue siendo el
26
+ // nombre del archivo, así que otro formato pasa igual — ver «Qué son y qué no son» en el README.
27
+ if (/^(?:\.npmrc|\.netrc|_netrc|\.pypirc|\.dockercfg|id_(?:rsa|dsa|ecdsa|ed25519)|credentials)$/i.test(base)) {
28
+ block(`${file} es un archivo de credenciales que su herramienta mantiene. No lo edites a mano.`)
29
+ }
30
+ }
31
+ }
32
+
33
+ function integrationSnapshot(input) {
34
+ for (const raw of filesOf(input)) {
35
+ const file = raw.replace(/\\/g, '/')
36
+ if (/(?:^|\/)integrations\/[^/]+\/staging\/(?:.+\/remote\.json|sync-state\.json)$/.test(file)) {
37
+ block(`${file} pertenece al sincronizador. Cura draft.md; no edites snapshots a mano.`)
38
+ }
39
+ }
40
+ }
41
+
42
+ function generated(input) {
43
+ for (const raw of filesOf(input)) {
44
+ const file = raw.replace(/\\/g, '/')
45
+ const base = path.basename(file)
46
+ if (/(?:^|[._-])generated\.[^.]+$/i.test(base) || /(?:^|[._-])gen\.(?:go|ts|js|py)$/i.test(base)) {
47
+ block(`${file} parece código generado. Modifica su fuente y ejecuta el generador; no lo edites a mano.`)
48
+ }
49
+ }
50
+ }
51
+
52
+ // Las dos formas de que una prueba deje de juzgar sin que nadie lo note: apagarla o borrarla. Ninguna
53
+ // sale roja —el runner informa una suite verde más corta—, así que el verde pasa de decir «el
54
+ // comportamiento está» a decir «nadie lo miró», y `verify` tampoco lo ve porque también lee exit codes.
55
+ // Lo que se inspecciona es el contenido entrante, no el archivo: una marca que ya estaba no la apagó
56
+ // este cambio.
57
+ const TEST_OFF = [
58
+ [/\b(?:describe|context|it|test|suite)\s*\.\s*(?:skip|only|todo)\b/, 'skip/only'],
59
+ [/\b[xf](?:it|test|describe|context)\s*[("'`]/, 'xit/fit'],
60
+ [/\bt\.Skip(?:Now)?\s*\(/, 't.Skip'],
61
+ [/@pytest\.mark\.(?:skip|skipif|xfail)\b/, 'pytest.mark.skip'],
62
+ [/@unittest\.skip/, 'unittest.skip'],
63
+ [/@(?:Ignore|Disabled)\b/, 'Ignore/Disabled'],
64
+ [/#\[ignore\]/, 'ignore'],
65
+ ]
66
+
67
+ function isTestFile(raw) {
68
+ const file = raw.replace(/\\/g, '/')
69
+ const base = path.basename(file)
70
+ return /(?:^|\/)(?:tests?|specs?|__tests__)\//i.test(file)
71
+ || /\.(?:test|spec)\.[jt]sx?$/i.test(base)
72
+ || /_(?:test|spec)\.(?:go|py|rb|ts|js|jsx|tsx|rs|exs?)$/i.test(base)
73
+ || /^test_.+\.py$/i.test(base)
74
+ }
75
+
76
+ function testEvidence(input) {
77
+ if (process.env.OPS_TEST_EVIDENCE_OVERRIDE === '1') return
78
+ const why = 'Una prueba apagada no falla y una suite sin ella sale verde igual: el verde deja de ' +
79
+ 'decir que el comportamiento está y pasa a decir que nadie lo miró.\n' +
80
+ 'Si la aserción está mal, corregila; si el comportamiento cambió, cambialo junto con la prueba que ' +
81
+ 'lo fija. Si tiene que quedar afuera igual —flake conocido, entorno que acá no existe—, es una ' +
82
+ 'decisión con dueño: OPS_TEST_EVIDENCE_OVERRIDE=1 y que conste en el commit.'
83
+ for (const match of patchOf(input).matchAll(/^\*\*\* Delete File:\s*(.+)$/gm)) {
84
+ const removed = match[1].trim()
85
+ if (isTestFile(removed)) block(`${removed} borra una prueba.\n${why}`)
86
+ }
87
+ const content = contentOf(input)
88
+ if (!content) return
89
+ for (const raw of filesOf(input)) {
90
+ if (!isTestFile(raw)) continue
91
+ for (const [marca, nombre] of TEST_OFF) {
92
+ if (marca.test(content)) block(`${raw} apaga una prueba con ${nombre}.\n${why}`)
93
+ }
94
+ }
95
+ }
96
+
97
+ function workspaceBoundary(input) {
98
+ const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
99
+ if (!root) return
100
+ const config = configOf(root)
101
+ const allowed = [root, ...(config.workspaceRoots || []).map((entry) => path.resolve(root, entry.path))]
102
+ for (const raw of filesOf(input)) {
103
+ const file = path.resolve(cwdOf(input), raw)
104
+ if (!allowed.some((base) => file === base || file.startsWith(`${base}${path.sep}`))) {
105
+ block(`${file} está fuera de las raíces declaradas en ops.config.json.`)
106
+ }
107
+ }
108
+ }
109
+
110
+ function migrations(input) {
111
+ if (process.env.OPS_MIGRATIONS_OVERRIDE === '1') return
112
+ // Cada rama cierra su propio límite. Cuando el `\b` estaba al final del grupo se aplicaba a las tres, y
113
+ // la de `delete` termina a propósito en `;`: después de un punto y coma no hay límite de palabra, así que
114
+ // `DELETE FROM pedidos;` —la forma que tiene en cualquier migración— pasaba y sólo frenaba la variante sin
115
+ // punto y coma. `drop column` y `drop constraint` faltaban: pierden datos y garantías igual que `drop table`.
116
+ const destructiveSql = new RegExp(
117
+ String.raw`\bdrop\s+(?:table|database|schema|column|constraint)\b` +
118
+ String.raw`|\btruncate\b` +
119
+ String.raw`|\bdelete\s+from\s+\S+\s*(?:;|$)`,
120
+ 'i',
121
+ )
122
+ if (destructiveSql.test(contentOf(input))) {
123
+ block('La migración contiene SQL destructivo. Requiere revisión y OPS_MIGRATIONS_OVERRIDE=1.')
124
+ }
125
+ for (const raw of filesOf(input)) {
126
+ const normalized = raw.replace(/\\/g, '/')
127
+ if (!/(?:^|\/)(?:migrations?|migrate)\/.*\.sql$/i.test(normalized)) continue
128
+ const file = path.resolve(cwdOf(input), raw)
129
+ if (fs.existsSync(file)) {
130
+ block(`${raw} es una migración existente. Crea una nueva en vez de reescribir historial.`)
131
+ }
132
+ }
133
+ }
134
+
135
+ // Hace falta un guard aparte porque `workspace-boundary` no lo cubre: `node_modules/` cae dentro de
136
+ // la raíz declarada, así que editar el motor le parece legítimo.
137
+ //
138
+ // Editarlo rompe dos veces: el próximo `npm install` borra el cambio sin avisar, y hasta entonces la
139
+ // empresa corre un motor que no coincide con la versión que declara —la clase de diferencia que
140
+ // aparece como un bug irreproducible—. En modo `toolkit` no aplica: ahí el motor es el producto.
141
+ function engineWrites(input) {
142
+ const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
143
+ if (!root) return
144
+ const config = configOf(root)
145
+ if (config.mode === 'toolkit') return
146
+ const pkg = path.join(root, 'node_modules', '@ingeniomaps', 'cauce')
147
+ for (const raw of filesOf(input)) {
148
+ const file = path.resolve(cwdOf(input), raw)
149
+ if (file !== pkg && !file.startsWith(`${pkg}${path.sep}`)) continue
150
+ block(`${raw} pertenece al motor de Cauce, que llega por npm.\n` +
151
+ 'Un cambio acá lo borra el próximo install y mientras tanto corrés un motor que no coincide ' +
152
+ 'con la versión que declarás. Para traer una versión nueva son dos pasos —el motor y después ' +
153
+ 'las rutas del sistema de tu instancia—:\n' +
154
+ ' npm install --save-dev --save-exact @ingeniomaps/cauce@latest\n' +
155
+ ' node tools/ops.js upgrade\n' +
156
+ 'Y reportá el problema arriba. Lo que sí es tuyo son tus cargos, equipos e integraciones.')
157
+ }
158
+ }
159
+
160
+ module.exports = { secrets, integrationSnapshot, generated, testEvidence, workspaceBoundary, migrations, engineWrites }