@ingeniomaps/cauce 0.53.0 → 0.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/automatization/hooks/guard-dependencies.sh +1 -1
  3. package/automatization/hooks/guard-destructive.sh +1 -1
  4. package/automatization/hooks/guard-engine.sh +1 -1
  5. package/automatization/hooks/guard-generated.sh +1 -1
  6. package/automatization/hooks/guard-git-add.sh +1 -1
  7. package/automatization/hooks/guard-governance.sh +1 -1
  8. package/automatization/hooks/guard-integration-snapshot.sh +1 -1
  9. package/automatization/hooks/guard-migrations.sh +1 -1
  10. package/automatization/hooks/guard-planning-drift.sh +1 -1
  11. package/automatization/hooks/guard-secrets.sh +1 -1
  12. package/automatization/hooks/guard-test-evidence.sh +1 -1
  13. package/automatization/hooks/guard-verify.sh +1 -1
  14. package/automatization/hooks/guard-workspace-boundary.sh +1 -1
  15. package/automatization/hooks/run-hook.sh +3 -2
  16. package/automatization/runners/antigravity/hook.js +13 -14
  17. package/automatization/shared/eval-only.js +3 -3
  18. package/automatization/workflows/agent-eval.js +3 -3
  19. package/automatization/workflows/agent-promote.js +4 -4
  20. package/automatization/workflows/autobuild.js +12 -12
  21. package/automatization/workflows/flow-eval.js +10 -7
  22. package/automatization/workflows/flow.js +9 -10
  23. package/automatization/workflows/onboard.js +4 -5
  24. package/engine/agents/evaluations.js +18 -16
  25. package/engine/agents/learning-files.js +111 -0
  26. package/engine/agents/learning-sources.js +166 -0
  27. package/engine/agents/learning.js +93 -300
  28. package/engine/automation/config.js +175 -0
  29. package/engine/automation/hooks.js +96 -0
  30. package/engine/automation/index.js +17 -431
  31. package/engine/automation/roles.js +72 -0
  32. package/engine/automation/runners.js +162 -0
  33. package/engine/cli/catalog.js +7 -12
  34. package/engine/cli/instance.js +11 -10
  35. package/engine/cli/io.js +1 -1
  36. package/engine/cli/ops.js +12 -10
  37. package/engine/cli/wiring.js +2 -4
  38. package/engine/config/validate.js +2 -1
  39. package/engine/core/frontmatter.js +2 -1
  40. package/engine/core/ownership.js +5 -4
  41. package/engine/core/scan.js +3 -0
  42. package/engine/flows/registry.js +2 -2
  43. package/engine/hooks/files.js +160 -0
  44. package/engine/hooks/input.js +129 -0
  45. package/engine/hooks/run.js +24 -449
  46. package/engine/hooks/shell.js +197 -0
  47. package/engine/integrations/registry.js +2 -0
  48. package/engine/planning/contracts.js +12 -10
  49. package/engine/planning/parser.js +4 -3
  50. package/engine/planning/state.js +2 -1
  51. package/package.json +5 -5
@@ -0,0 +1,129 @@
1
+ 'use strict'
2
+
3
+ // Cómo un guard lee lo que el runner le mandó, y cómo se niega. Es una sola pregunta —qué hay en la
4
+ // entrada y cómo se interpreta— y la comparten las tres familias de guards, así que vive acá y no en
5
+ // ninguna de ellas: copiada, una copia dejaría de reconocer un formato y su guard permitiría todo en
6
+ // silencio, que es la falla que ninguna prueba verde delata.
7
+
8
+ const fs = require('node:fs')
9
+ const path = require('node:path')
10
+ const { spawnSync } = require('node:child_process')
11
+
12
+ // Sin stdin no hay nada que leer y los guards caen a las variables de entorno; con stdin ilegible sí
13
+ // hay algo y no se entiende, que es otra cosa. Devolver `{}` ahí dejaba a cada guard sin comando ni
14
+ // archivos, o sea permitiendo todo, y en silencio.
15
+ function readInput() {
16
+ let raw = ''
17
+ try { raw = fs.readFileSync(0, 'utf8') } catch { /* sin stdin */ }
18
+ if (!raw.trim()) return {}
19
+ try { return JSON.parse(raw) } catch (error) {
20
+ block(`la entrada del hook no es JSON válido (${error.message}).`)
21
+ }
22
+ }
23
+
24
+ function commandOf(input) {
25
+ const value = input.tool_input && (input.tool_input.command || input.tool_input.cmd)
26
+ || input.command || input.input && input.input.command || process.env.OPS_HOOK_COMMAND || ''
27
+ return Array.isArray(value) ? value.join(' ') : String(value)
28
+ }
29
+
30
+ function fileOf(input) {
31
+ return String(input.tool_input && (input.tool_input.file_path || input.tool_input.path)
32
+ || input.file_path || input.path || process.env.OPS_HOOK_FILE || '')
33
+ }
34
+
35
+ // El sobre de `apply_patch`, venga por donde venga. Codex lo manda entero como `command` en vez de
36
+ // `patch`, y sin reconocerlo ahí el guard de archivos no ve ni un archivo: mira una escritura que
37
+ // reemplaza una migración o filtra una credencial y la deja pasar sin decir nada. Se exige el
38
+ // encabezado en vez de aceptar cualquier `command`, para no leer un comando de shell como si fuera
39
+ // contenido de archivo.
40
+ function patchOf(input) {
41
+ const fields = input.tool_input || {}
42
+ const command = String(fields.command || '')
43
+ const envelope = command.startsWith('*** Begin Patch') ? command : ''
44
+ return String(fields.patch || fields.input || input.patch || envelope || '')
45
+ }
46
+
47
+ function filesOf(input) {
48
+ const files = new Set()
49
+ const direct = fileOf(input)
50
+ if (direct) files.add(direct)
51
+ const patch = patchOf(input)
52
+ for (const match of patch.matchAll(/^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/gm)) files.add(match[1].trim())
53
+ return [...files]
54
+ }
55
+
56
+ function contentOf(input) {
57
+ return String(input.tool_input && (
58
+ input.tool_input.content
59
+ || input.tool_input.new_string
60
+ || input.tool_input.patch
61
+ || input.tool_input.input
62
+ )
63
+ || input.content || input.patch || patchOf(input) || '')
64
+ }
65
+
66
+ function cwdOf(input) {
67
+ const cwd = input.cwd || input.tool_input && input.tool_input.cwd
68
+ || process.env.OPS_ROOT || process.cwd()
69
+ return path.resolve(String(cwd))
70
+ }
71
+
72
+ function block(message) {
73
+ const error = new Error(message)
74
+ error.blocked = true
75
+ throw error
76
+ }
77
+
78
+ // La configuración de la raíz ops. Un guard que no puede leerla bloquea: `findOpsRoot` sólo devuelve
79
+ // una raíz donde estén `ops.config.json` y `planning/`, así que llegar acá significa roto o ilegible,
80
+ // no ausente.
81
+ // Dejarlo pasar convertía una coma de más en «sin límite de escritura».
82
+ function configOf(root) {
83
+ try { return JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8')) } catch (error) {
84
+ block(`ops.config.json no se puede leer (${error.message}). Un guard no decide sin él.`)
85
+ }
86
+ }
87
+
88
+ function gitDirectory(command, cwd) {
89
+ const flag = command.match(/(?:^|\s)git\s+-C\s+(['"]?)([^\s'";&|]+)\1/)
90
+ const cd = command.match(/(?:^|[;&|]\s*)cd\s+(['"]?)([^\s'";&|]+)\1/)
91
+ return path.resolve(cwd, flag ? flag[2] : cd ? cd[2] : '.')
92
+ }
93
+
94
+ function isCommit(command) {
95
+ return /(?:^|[;&|]\s*)git(?:\s+-C\s+\S+)?\s+commit(?:\s|$)/.test(command)
96
+ }
97
+
98
+ function stagedFiles(dir) {
99
+ const result = spawnSync('git', ['-C', dir, 'diff', '--cached', '--name-only'], { encoding: 'utf8' })
100
+ return result.status === 0 ? result.stdout.trim().split('\n').filter(Boolean) : []
101
+ }
102
+
103
+ // R10 pide «la autorización configurada para el proyecto» y `runner.allowPush` es esa configuración:
104
+ // sin esto era un interruptor que nadie leía, y un cargo que lo leyó dio por imposible un push que el
105
+ // guard bloqueaba igual. Sin raíz legible no hay permiso que verificar, así que no se autoriza.
106
+ function pushAllowed(input) {
107
+ const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
108
+ if (!root) return false
109
+ const runner = configOf(root).runner
110
+ return Boolean(runner && runner.allowPush === true)
111
+ }
112
+
113
+ function findOpsRoot(start) {
114
+ let current = path.resolve(start)
115
+ while (true) {
116
+ if (fs.existsSync(path.join(current, 'ops.config.json'))
117
+ && fs.existsSync(path.join(current, 'planning'))) {
118
+ return current
119
+ }
120
+ const parent = path.dirname(current)
121
+ if (parent === current) return ''
122
+ current = parent
123
+ }
124
+ }
125
+
126
+ module.exports = {
127
+ readInput, commandOf, fileOf, patchOf, filesOf, contentOf, cwdOf, block, configOf,
128
+ gitDirectory, isCommit, stagedFiles, pushAllowed, findOpsRoot,
129
+ }
@@ -1,428 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict'
3
3
 
4
+ // El registro de guards y su despacho: cuáles existen, en qué grupo corre cada uno y qué documenta.
5
+ // Es el punto por el que un runner los invoca —`run-hook.sh` ejecuta este archivo— y lo único que
6
+ // crece de a un guard. Lo que cada uno hace vive en `shell.js` y `files.js`, según qué lee de la
7
+ // entrada; cómo se lee esa entrada, en `input.js`.
8
+
4
9
  const fs = require('node:fs')
5
10
  const os = require('node:os')
6
11
  const path = require('node:path')
7
- const { spawnSync } = require('node:child_process')
8
-
9
- // Sin stdin no hay nada que leer y los guards caen a las variables de entorno; con stdin ilegible sí
10
- // hay algo y no se entiende, que es otra cosa. Devolver `{}` ahí dejaba a cada guard sin comando ni
11
- // archivos, o sea permitiendo todo, y en silencio.
12
- function readInput() {
13
- let raw = ''
14
- try { raw = fs.readFileSync(0, 'utf8') } catch { /* sin stdin */ }
15
- if (!raw.trim()) return {}
16
- try { return JSON.parse(raw) } catch (error) {
17
- block(`la entrada del hook no es JSON válido (${error.message}).`)
18
- }
19
- }
20
-
21
- function commandOf(input) {
22
- const value = input.tool_input && (input.tool_input.command || input.tool_input.cmd)
23
- || input.command || input.input && input.input.command || process.env.OPS_HOOK_COMMAND || ''
24
- return Array.isArray(value) ? value.join(' ') : String(value)
25
- }
26
-
27
- function fileOf(input) {
28
- return String(input.tool_input && (input.tool_input.file_path || input.tool_input.path)
29
- || input.file_path || input.path || process.env.OPS_HOOK_FILE || '')
30
- }
31
-
32
- // El sobre de `apply_patch`, venga por donde venga. Codex lo manda entero como `command` en vez de
33
- // `patch`, y sin reconocerlo ahí el guard de archivos no ve ni un archivo: mira una escritura que
34
- // reemplaza una migración o filtra una credencial y la deja pasar sin decir nada. Se exige el
35
- // encabezado en vez de aceptar cualquier `command`, para no leer un comando de shell como si fuera
36
- // contenido de archivo.
37
- function patchOf(input) {
38
- const fields = input.tool_input || {}
39
- const command = String(fields.command || '')
40
- const envelope = command.startsWith('*** Begin Patch') ? command : ''
41
- return String(fields.patch || fields.input || input.patch || envelope || '')
42
- }
43
-
44
- function filesOf(input) {
45
- const files = new Set()
46
- const direct = fileOf(input)
47
- if (direct) files.add(direct)
48
- const patch = patchOf(input)
49
- for (const match of patch.matchAll(/^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/gm)) files.add(match[1].trim())
50
- return [...files]
51
- }
52
-
53
- function contentOf(input) {
54
- return String(input.tool_input && (
55
- input.tool_input.content
56
- || input.tool_input.new_string
57
- || input.tool_input.patch
58
- || input.tool_input.input
59
- )
60
- || input.content || input.patch || patchOf(input) || '')
61
- }
62
-
63
- function cwdOf(input) {
64
- const cwd = input.cwd || input.tool_input && input.tool_input.cwd
65
- || process.env.OPS_ROOT || process.cwd()
66
- return path.resolve(String(cwd))
67
- }
68
-
69
- function block(message) {
70
- const error = new Error(message)
71
- error.blocked = true
72
- throw error
73
- }
74
-
75
- // La configuración de la raíz ops. Un guard que no puede leerla bloquea: `findOpsRoot` sólo devuelve
76
- // una raíz si `ops.config.json` existe, así que llegar acá significa roto o ilegible, no ausente.
77
- // Dejarlo pasar convertía una coma de más en «sin límite de escritura».
78
- function configOf(root) {
79
- try { return JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8')) } catch (error) {
80
- block(`ops.config.json no se puede leer (${error.message}). Un guard no decide sin él.`)
81
- }
82
- }
83
-
84
- function gitDirectory(command, cwd) {
85
- const flag = command.match(/(?:^|\s)git\s+-C\s+(['"]?)([^\s'";&|]+)\1/)
86
- const cd = command.match(/(?:^|[;&|]\s*)cd\s+(['"]?)([^\s'";&|]+)\1/)
87
- return path.resolve(cwd, flag ? flag[2] : cd ? cd[2] : '.')
88
- }
89
-
90
- function isCommit(command) {
91
- return /(?:^|[;&|]\s*)git(?:\s+-C\s+\S+)?\s+commit(?:\s|$)/.test(command)
92
- }
93
-
94
- function stagedFiles(dir) {
95
- const result = spawnSync('git', ['-C', dir, 'diff', '--cached', '--name-only'], { encoding: 'utf8' })
96
- return result.status === 0 ? result.stdout.trim().split('\n').filter(Boolean) : []
97
- }
98
-
99
- // R10 pide «la autorización configurada para el proyecto» y `runner.allowPush` es esa configuración:
100
- // sin esto era un interruptor que nadie leía, y un cargo que lo leyó dio por imposible un push que el
101
- // guard bloqueaba igual. Sin raíz legible no hay permiso que verificar, así que no se autoriza.
102
- function pushAllowed(input) {
103
- const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
104
- if (!root) return false
105
- const runner = configOf(root).runner
106
- return Boolean(runner && runner.allowPush === true)
107
- }
108
-
109
- function destructive(input) {
110
- const command = commandOf(input)
111
- if (/\bgit\s+push\b/.test(command) && !pushAllowed(input)) {
112
- block("'git push' publica cambios y requiere una acción humana. Se habilita con runner.allowPush.")
113
- }
114
- const rules = [
115
- [/\bgit\s+reset\s+--hard\b/, "'git reset --hard' destruye cambios locales."],
116
- [/\bgit\s+clean\s+-[^\s]*f/, "'git clean -f' borra archivos sin seguimiento."],
117
- // `git checkout -- .` destruye lo mismo que `reset --hard` y sin recuperación, pero se escribe como
118
- // una limpieza. Se bloquea sólo la forma ancha —`.`, `*`, `:/`, o sin ruta—: revertir un archivo
119
- // nombrado es trabajo corriente y no se toca.
120
- //
121
- // Pasó dos veces en una sesión, las dos limpiando restos de una prueba: el comando revirtió también
122
- // el trabajo de al lado, que no estaba commiteado. Lo que engaña es que el alcance no se ve en el
123
- // comando — `.` es el cwd, y el cwd suele tener más de lo que uno está mirando.
124
- [
125
- // `git restore .` no lleva `--` y destruye igual: es la forma moderna del mismo comando.
126
- /\bgit\s+(?:checkout|restore)\s+(?:[^;&|]*?\s)?(?:--\s*(?:$|[;&|])|(?:--\s+)?(?:\.|\*|:\/)\s*(?:$|[;&|]))/,
127
- "'git checkout -- .' revierte todo lo no commiteado del directorio, no sólo lo que estás mirando. "
128
- + 'Nombrá el archivo, o commiteá lo que quieras conservar antes.',
129
- ],
130
- [
131
- /\bdocker(?:\s+\w+)*\s+(?:volume\s+(?:rm|prune)|system\s+prune|network\s+prune)\b/,
132
- 'La limpieza global de Docker puede borrar datos compartidos.',
133
- ],
134
- [
135
- /\bdocker(?:\s+compose|-compose)\s+(?:\S+\s+)*(?:down|stop|kill|rm)\b/,
136
- 'Detener un stack Compose puede interrumpir servicios compartidos.',
137
- ],
138
- [
139
- /(?:^|\s)(?:mkfs\S*|shred)\s|\bdd\s+[^;&|]*\bof=\/dev\/|>\s*\/dev\/(?:sd|nvme|disk)/,
140
- 'Operación destructiva sobre disco o dispositivo.',
141
- ],
142
- [
143
- /\brm\s+(?:-[^\s]*r[^\s]*\s+)+(?:\/\*?|~\/?|\$HOME|\.\.)(?:\s|$)/,
144
- "'rm -r' sobre /, home o el directorio padre es catastrófico.",
145
- ],
146
- ]
147
- for (const [pattern, message] of rules) if (pattern.test(command)) block(message)
148
- }
149
-
150
- function gitAdd(input) {
151
- const command = commandOf(input)
152
- if (/\bgit\s+add\s+(?:[^;&|]*\s)?(?:-A\b|--all\b|\.)(?:\s|$|[;&|])/.test(command)) {
153
- block("'git add -A/--all/.' está prohibido. Stagea rutas explícitas.")
154
- }
155
- }
156
-
157
- function secrets(input) {
158
- for (const file of filesOf(input)) {
159
- const base = path.basename(file)
160
- if (/^(?:\.env|\.env\..+)$/.test(base) && !/\.(?:example|sample|template|schema|dist|tpl)$/.test(base)) {
161
- block(`${file} parece contener secretos. Edita una plantilla o registra una acción humana.`)
162
- }
163
- if (/^(?:accesos\.md|credenciales.*|credentials.*\.json|.*service-account.*\.json|.*\.(?:pem|key))$/i.test(base)) {
164
- block(`${file} parece un archivo de credenciales en texto plano.`)
165
- }
166
- // Nombres de credencial que la herramienta escribe sola y que la lista anterior no cubría:
167
- // `.npmrc` guarda el token de publicación, `.netrc` el de cualquier host, `id_*` una clave
168
- // privada de SSH y `~/.aws/credentials` las de AWS. Los cuatro son estándar, no exóticos.
169
- //
170
- // Esto tapa un caso conocido; no vuelve completo al guard. La forma de decidir sigue siendo el
171
- // nombre del archivo, así que otro formato pasa igual — ver «Qué son y qué no son» en el README.
172
- if (/^(?:\.npmrc|\.netrc|_netrc|\.pypirc|\.dockercfg|id_(?:rsa|dsa|ecdsa|ed25519)|credentials)$/i.test(base)) {
173
- block(`${file} es un archivo de credenciales que su herramienta mantiene. No lo edites a mano.`)
174
- }
175
- }
176
- }
177
-
178
- function integrationSnapshot(input) {
179
- for (const raw of filesOf(input)) {
180
- const file = raw.replace(/\\/g, '/')
181
- if (/(?:^|\/)integrations\/[^/]+\/staging\/(?:.+\/remote\.json|sync-state\.json)$/.test(file)) {
182
- block(`${file} pertenece al sincronizador. Cura draft.md; no edites snapshots a mano.`)
183
- }
184
- }
185
- }
186
-
187
- function generated(input) {
188
- for (const raw of filesOf(input)) {
189
- const file = raw.replace(/\\/g, '/')
190
- const base = path.basename(file)
191
- if (/(?:^|[._-])generated\.[^.]+$/i.test(base) || /(?:^|[._-])gen\.(?:go|ts|js|py)$/i.test(base)) {
192
- block(`${file} parece código generado. Modifica su fuente y ejecuta el generador; no lo edites a mano.`)
193
- }
194
- }
195
- }
196
-
197
- // Las dos formas de que una prueba deje de juzgar sin que nadie lo note: apagarla o borrarla. Ninguna
198
- // sale roja —el runner informa una suite verde más corta—, así que el verde pasa de decir «el
199
- // comportamiento está» a decir «nadie lo miró», y `verify` tampoco lo ve porque también lee exit codes.
200
- // Lo que se inspecciona es el contenido entrante, no el archivo: una marca que ya estaba no la apagó
201
- // este cambio.
202
- const TEST_OFF = [
203
- [/\b(?:describe|context|it|test|suite)\s*\.\s*(?:skip|only|todo)\b/, 'skip/only'],
204
- [/\b[xf](?:it|test|describe|context)\s*[("'`]/, 'xit/fit'],
205
- [/\bt\.Skip(?:Now)?\s*\(/, 't.Skip'],
206
- [/@pytest\.mark\.(?:skip|skipif|xfail)\b/, 'pytest.mark.skip'],
207
- [/@unittest\.skip/, 'unittest.skip'],
208
- [/@(?:Ignore|Disabled)\b/, 'Ignore/Disabled'],
209
- [/#\[ignore\]/, 'ignore'],
210
- ]
211
-
212
- function isTestFile(raw) {
213
- const file = raw.replace(/\\/g, '/')
214
- const base = path.basename(file)
215
- return /(?:^|\/)(?:tests?|specs?|__tests__)\//i.test(file)
216
- || /\.(?:test|spec)\.[jt]sx?$/i.test(base)
217
- || /_(?:test|spec)\.(?:go|py|rb|ts|js|jsx|tsx|rs|exs?)$/i.test(base)
218
- || /^test_.+\.py$/i.test(base)
219
- }
220
-
221
- function testEvidence(input) {
222
- if (process.env.OPS_TEST_EVIDENCE_OVERRIDE === '1') return
223
- const why = 'Una prueba apagada no falla y una suite sin ella sale verde igual: el verde deja de ' +
224
- 'decir que el comportamiento está y pasa a decir que nadie lo miró.\n' +
225
- 'Si la aserción está mal, corregila; si el comportamiento cambió, cambialo junto con la prueba que ' +
226
- 'lo fija. Si tiene que quedar afuera igual —flake conocido, entorno que acá no existe—, es una ' +
227
- 'decisión con dueño: OPS_TEST_EVIDENCE_OVERRIDE=1 y que conste en el commit.'
228
- for (const match of patchOf(input).matchAll(/^\*\*\* Delete File:\s*(.+)$/gm)) {
229
- const removed = match[1].trim()
230
- if (isTestFile(removed)) block(`${removed} borra una prueba.\n${why}`)
231
- }
232
- const content = contentOf(input)
233
- if (!content) return
234
- for (const raw of filesOf(input)) {
235
- if (!isTestFile(raw)) continue
236
- for (const [marca, nombre] of TEST_OFF) {
237
- if (marca.test(content)) block(`${raw} apaga una prueba con ${nombre}.\n${why}`)
238
- }
239
- }
240
- }
241
-
242
- function workspaceBoundary(input) {
243
- const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
244
- if (!root) return
245
- const config = configOf(root)
246
- const allowed = [root, ...(config.workspaceRoots || []).map((entry) => path.resolve(root, entry.path))]
247
- for (const raw of filesOf(input)) {
248
- const file = path.resolve(cwdOf(input), raw)
249
- if (!allowed.some((base) => file === base || file.startsWith(`${base}${path.sep}`))) {
250
- block(`${file} está fuera de las raíces declaradas en ops.config.json.`)
251
- }
252
- }
253
- }
254
-
255
- function migrations(input) {
256
- if (process.env.OPS_MIGRATIONS_OVERRIDE === '1') return
257
- // Cada rama cierra su propio límite. Cuando el `\b` estaba al final del grupo se aplicaba a las tres, y
258
- // la de `delete` termina a propósito en `;`: después de un punto y coma no hay límite de palabra, así que
259
- // `DELETE FROM pedidos;` —la forma que tiene en cualquier migración— pasaba y sólo frenaba la variante sin
260
- // punto y coma. `drop column` y `drop constraint` faltaban: pierden datos y garantías igual que `drop table`.
261
- const destructiveSql = new RegExp(
262
- String.raw`\bdrop\s+(?:table|database|schema|column|constraint)\b` +
263
- String.raw`|\btruncate\b` +
264
- String.raw`|\bdelete\s+from\s+\S+\s*(?:;|$)`,
265
- 'i',
266
- )
267
- if (destructiveSql.test(contentOf(input))) {
268
- block('La migración contiene SQL destructivo. Requiere revisión y OPS_MIGRATIONS_OVERRIDE=1.')
269
- }
270
- for (const raw of filesOf(input)) {
271
- const normalized = raw.replace(/\\/g, '/')
272
- if (!/(?:^|\/)(?:migrations?|migrate)\/.*\.sql$/i.test(normalized)) continue
273
- const file = path.resolve(cwdOf(input), raw)
274
- if (fs.existsSync(file)) {
275
- block(`${raw} es una migración existente. Crea una nueva en vez de reescribir historial.`)
276
- }
277
- }
278
- }
279
-
280
- function dependencies(input) {
281
- if (process.env.OPS_DEPENDENCIES_OVERRIDE === '1') return
282
- const command = commandOf(input)
283
- const unsafePackageCommand = /\b(?:npm|pnpm|yarn|bun)\s+publish\b/.test(command)
284
- || /\b(?:npm|pnpm|yarn)\s+(?:install|add)\b[^;&|]*(?:\s-g\b|\s--global\b)/.test(command)
285
- if (unsafePackageCommand) {
286
- block('Publicar paquetes o instalar dependencias globales requiere una acción humana explícita.')
287
- }
288
- if (!isCommit(command)) return
289
- const dir = gitDirectory(command, cwdOf(input))
290
- const staged = stagedFiles(dir)
291
- const manifests = new Set(['package.json', 'pyproject.toml', 'requirements.txt', 'go.mod', 'Cargo.toml'])
292
- const locks = new Set([
293
- 'package-lock.json',
294
- 'pnpm-lock.yaml',
295
- 'yarn.lock',
296
- 'bun.lock',
297
- 'bun.lockb',
298
- 'poetry.lock',
299
- 'uv.lock',
300
- 'go.sum',
301
- 'Cargo.lock',
302
- ])
303
- const byDir = new Map()
304
- for (const file of staged) {
305
- const base = path.basename(file)
306
- if (!manifests.has(base) && !locks.has(base)) continue
307
- const parent = path.dirname(file)
308
- const state = byDir.get(parent) || { manifests: [], locks: [] }
309
- state[manifests.has(base) ? 'manifests' : 'locks'].push(base)
310
- byDir.set(parent, state)
311
- }
312
- for (const [parent, state] of byDir) {
313
- const existingLocks = [...locks].filter((name) => fs.existsSync(path.join(dir, parent, name)))
314
- if (existingLocks.length > 1) {
315
- block(`${parent}: hay varios lockfiles (${existingLocks.join(', ')}). Conserva uno solo.`)
316
- }
317
- if (state.manifests.length && existingLocks.length && !state.locks.length) {
318
- block(`${parent}: cambió ${state.manifests.join(', ')} sin actualizar su lockfile.`)
319
- }
320
- if (state.locks.length && !state.manifests.length) {
321
- block(`${parent}: cambió ${state.locks.join(', ')} sin un cambio explícito en el manifest.`)
322
- }
323
- }
324
- }
325
-
326
- function governance(input) {
327
- if (process.env.OPS_GOVERNANCE_OVERRIDE === '1') return
328
- const command = commandOf(input)
329
- if (!isCommit(command)) return
330
- const dir = gitDirectory(command, cwdOf(input))
331
- // El contrato de un cargo y lo que lo mide son gobernanza, igual que un ADR o una regla. La firma de
332
- // «Aprobación humana» sólo estaba protegida por una frase en un prompt; `SKILL.md` y `references/`
333
- // son lo que la propuesta cambia, y editarlos directo saltea el ciclo entero; y `evaluations/` es el
334
- // denominador con que se juzga, así que moverlo ablanda toda medición pasada sin tocar una regla.
335
- //
336
- // Quedan afuera las dos clases de evidencia, que registran lo que pasó un día en vez de decidir algo:
337
- // `learning/reports/` y `evaluations/results/` —esta última se escribe en cada corrida, así que
338
- // gobernarla pediría un override por evaluación—. Por eso `evaluations/` se nombra por partes.
339
- const governedPattern = new RegExp(
340
- String.raw`^(?:(?:template\/)?planning\/(?:rules\/|adr\/|PROTOCOL\.md|` +
341
- String.raw`METHODOLOGY\.md|FLOW\.md)|automatization\/|engine\/` +
342
- String.raw`|agents\/[a-z0-9-]+\/(?:system\/)?[a-z0-9-]+\/(?:SKILL\.md|references\/` +
343
- String.raw`|evaluations\/(?:cases\/|expected-behaviors\.yaml)|learning\/proposals\/))`,
344
- )
345
- const governed = stagedFiles(dir).filter((file) => governedPattern.test(file))
346
- if (governed.length) {
347
- const files = governed.map((file) => ` - ${file}`).join('\n')
348
- block(`El commit toca gobernanza protegida:\n${files}\n` +
349
- 'Usa OPS_GOVERNANCE_OVERRIDE=1 solo con aprobación.')
350
- }
351
- }
352
-
353
- function run(program, args, cwd) {
354
- const env = { ...process.env }
355
- delete env.NODE_TEST_CONTEXT
356
- const result = spawnSync(program, args, { cwd, encoding: 'utf8', stdio: 'pipe', env })
357
- return {
358
- ok: result.status === 0,
359
- status: result.status,
360
- output: `${result.stdout || ''}${result.stderr || ''}`.trim(),
361
- }
362
- }
363
-
364
- function verify(input) {
365
- if (process.env.OPS_SKIP_VERIFY === '1') return
366
- const command = commandOf(input)
367
- if (!isCommit(command)) return
368
- const dir = gitDirectory(command, cwdOf(input))
369
- const staged = stagedFiles(dir)
370
- const changedOpenApi = staged.some((file) => /^(?:openapi|api|spec)(?:\/.*)?\/[^/]+\.ya?ml$/i.test(file))
371
- || staged.some((file) => /^(?:openapi|swagger)\.ya?ml$/i.test(file))
372
- const changedSqlSource = staged.some((file) => /^(?:db\/queries|queries)\/.*\.sql$/i.test(file))
373
- const hasApiGenerated = staged.some((file) => /(?:^|\/)[^/]*(?:generated|\.gen)\.(?:go|ts|js|py)$/i.test(file))
374
- const hasSqlGenerated = staged.some((file) => /(?:^|\/)(?:sqlc|generated)(?:\/|.*\.(?:go|ts|js|py)$)/i.test(file))
375
- if (changedOpenApi && !hasApiGenerated) {
376
- block('Cambió una fuente OpenAPI/Swagger sin incluir código regenerado. Ejecuta el generador y stagea su salida.')
377
- }
378
- if (changedSqlSource && !hasSqlGenerated) {
379
- block('Cambió una consulta SQL fuente sin artefactos regenerados. Ejecuta el generador.')
380
- }
381
- if (!staged.some((file) => /\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|html|css|scss|prisma)$/.test(file))) return
382
- const failures = []
383
- if (fs.existsSync(path.join(dir, 'package.json'))) {
384
- const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
385
- const usesPnpm = fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))
386
- && !fs.existsSync(path.join(dir, 'package-lock.json'))
387
- const pm = usesPnpm ? 'pnpm' : 'npm'
388
- for (const script of ['test', 'lint', 'typecheck', 'build']) {
389
- if (!pkg.scripts || !pkg.scripts[script]) continue
390
- const result = run(pm, ['run', script], dir)
391
- if (!result.ok) failures.push(`${script} (exit ${result.status})`)
392
- }
393
- } else if (fs.existsSync(path.join(dir, 'go.mod'))) {
394
- const makefile = path.join(dir, 'Makefile')
395
- if (fs.existsSync(makefile) && /^ci:/m.test(fs.readFileSync(makefile, 'utf8'))) {
396
- const result = run('make', ['ci'], dir)
397
- if (!result.ok) failures.push(`make ci (exit ${result.status})`)
398
- } else {
399
- for (const args of [['test', './...'], ['build', './...']]) {
400
- const result = run('go', args, dir)
401
- if (!result.ok) failures.push(`go ${args[0]} (exit ${result.status})`)
402
- }
403
- }
404
- } else if (fs.existsSync(path.join(dir, 'pyproject.toml')) || fs.existsSync(path.join(dir, 'requirements.txt'))) {
405
- const makefile = path.join(dir, 'Makefile')
406
- if (fs.existsSync(makefile) && /^test:/m.test(fs.readFileSync(makefile, 'utf8'))) {
407
- const result = run('make', ['test'], dir)
408
- if (!result.ok) failures.push(`make test (exit ${result.status})`)
409
- }
410
- }
411
- if (failures.length) block(`Verify falló en ${path.basename(dir)}: ${failures.join(', ')}. No se commitea en rojo.`)
412
- }
413
-
414
- function findOpsRoot(start) {
415
- let current = path.resolve(start)
416
- while (true) {
417
- if (fs.existsSync(path.join(current, 'ops.config.json'))
418
- && fs.existsSync(path.join(current, 'planning'))) {
419
- return current
420
- }
421
- const parent = path.dirname(current)
422
- if (parent === current) return ''
423
- current = parent
424
- }
425
- }
12
+ const { readInput, cwdOf, block, findOpsRoot } = require('./input')
13
+ const shell = require('./shell')
14
+ const files = require('./files')
426
15
 
427
16
  function planningDrift(input) {
428
17
  const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
@@ -431,7 +20,7 @@ function planningDrift(input) {
431
20
  const source = path.join(root, 'engine', 'cli', 'ops.js')
432
21
  const cli = fs.existsSync(local) ? local : source
433
22
  if (!fs.existsSync(cli)) return
434
- const result = run(process.execPath, [cli, 'check', path.join(root, 'planning')], root)
23
+ const result = shell.run(process.execPath, [cli, 'check', path.join(root, 'planning')], root)
435
24
  const session = String(input.session_id || input.sessionId || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '_')
436
25
  const marker = path.join(os.tmpdir(), `cauce-drift-${session}`)
437
26
  if (result.ok) {
@@ -443,35 +32,20 @@ function planningDrift(input) {
443
32
  block(`Planning o integraciones quedaron desalineados:\n${result.output}`)
444
33
  }
445
34
 
446
- // Hace falta un guard aparte porque `workspace-boundary` no lo cubre: `node_modules/` cae dentro de
447
- // la raíz declarada, así que editar el motor le parece legítimo.
448
- //
449
- // Editarlo rompe dos veces: el próximo `npm install` borra el cambio sin avisar, y hasta entonces la
450
- // empresa corre un motor que no coincide con la versión que declara —la clase de diferencia que
451
- // aparece como un bug irreproducible—. En modo `toolkit` no aplica: ahí el motor es el producto.
452
- function engineWrites(input) {
453
- const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
454
- if (!root) return
455
- const config = configOf(root)
456
- if (config.mode === 'toolkit') return
457
- const pkg = path.join(root, 'node_modules', '@ingeniomaps', 'cauce')
458
- for (const raw of filesOf(input)) {
459
- const file = path.resolve(cwdOf(input), raw)
460
- if (file !== pkg && !file.startsWith(`${pkg}${path.sep}`)) continue
461
- block(`${raw} pertenece al motor de Cauce, que llega por npm.\n` +
462
- 'Un cambio acá lo borra el próximo install y mientras tanto corrés un motor que no coincide ' +
463
- 'con la versión que declarás. Para traer una versión nueva son dos pasos —el motor y después ' +
464
- 'las rutas del sistema de tu instancia—:\n' +
465
- ' npm install --save-dev --save-exact @ingeniomaps/cauce@latest\n' +
466
- ' node tools/ops.js upgrade\n' +
467
- 'Y reportá el problema arriba. Lo que sí es tuyo son tus cargos, equipos e integraciones.')
468
- }
469
- }
470
-
471
35
  const guards = {
472
- destructive, 'git-add': gitAdd, secrets, generated, 'workspace-boundary': workspaceBoundary,
473
- migrations, dependencies, governance, verify, 'planning-drift': planningDrift,
474
- 'integration-snapshot': integrationSnapshot, engine: engineWrites, 'test-evidence': testEvidence,
36
+ destructive: shell.destructive,
37
+ 'git-add': shell.gitAdd,
38
+ dependencies: shell.dependencies,
39
+ governance: shell.governance,
40
+ verify: shell.verify,
41
+ secrets: files.secrets,
42
+ generated: files.generated,
43
+ 'workspace-boundary': files.workspaceBoundary,
44
+ engine: files.engineWrites,
45
+ migrations: files.migrations,
46
+ 'integration-snapshot': files.integrationSnapshot,
47
+ 'test-evidence': files.testEvidence,
48
+ 'planning-drift': planningDrift,
475
49
  }
476
50
 
477
51
  // Grupos por evento: un runner corre el grupo entero en un solo proceso en lugar de un guard por hook.
@@ -552,13 +126,14 @@ function execute(name, input) {
552
126
  guard(input)
553
127
  }
554
128
 
555
- // Expande grupos a guards y conserva el orden declarado; el primero que bloquea corta la ejecución.
129
+ // Expande grupos a guards y conserva el orden declarado.
556
130
  function resolve(names) {
557
131
  const resolved = names.flatMap((name) => hookGroups[name] || [name])
558
132
  if (!resolved.length) throw new Error('Se requiere el nombre de un guard o de un grupo.')
559
133
  return resolved
560
134
  }
561
135
 
136
+ // Corre en ese orden y el primero que bloquea corta: `execute` lanza y acá nadie lo atrapa.
562
137
  function executeAll(names, input) {
563
138
  for (const name of resolve(names)) execute(name, input)
564
139
  }