@ingeniomaps/cauce 0.53.0 → 0.53.2
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 +35 -0
- package/automatization/hooks/guard-dependencies.sh +1 -1
- package/automatization/hooks/guard-destructive.sh +1 -1
- package/automatization/hooks/guard-engine.sh +1 -1
- package/automatization/hooks/guard-generated.sh +1 -1
- package/automatization/hooks/guard-git-add.sh +1 -1
- package/automatization/hooks/guard-governance.sh +1 -1
- package/automatization/hooks/guard-integration-snapshot.sh +1 -1
- package/automatization/hooks/guard-migrations.sh +1 -1
- package/automatization/hooks/guard-planning-drift.sh +1 -1
- package/automatization/hooks/guard-secrets.sh +1 -1
- package/automatization/hooks/guard-test-evidence.sh +1 -1
- package/automatization/hooks/guard-verify.sh +1 -1
- package/automatization/hooks/guard-workspace-boundary.sh +1 -1
- package/automatization/hooks/run-hook.sh +3 -2
- package/automatization/runners/antigravity/hook.js +13 -14
- package/automatization/shared/eval-only.js +3 -3
- package/automatization/workflows/agent-eval.js +3 -3
- package/automatization/workflows/agent-promote.js +4 -4
- package/automatization/workflows/autobuild.js +12 -12
- package/automatization/workflows/flow-eval.js +10 -7
- package/automatization/workflows/flow.js +9 -10
- package/automatization/workflows/onboard.js +4 -5
- package/engine/agents/evaluations.js +18 -16
- package/engine/agents/learning-files.js +111 -0
- package/engine/agents/learning-sources.js +166 -0
- package/engine/agents/learning.js +93 -300
- package/engine/automation/config.js +175 -0
- package/engine/automation/hooks.js +96 -0
- package/engine/automation/index.js +17 -431
- package/engine/automation/roles.js +72 -0
- package/engine/automation/runners.js +162 -0
- package/engine/cli/catalog.js +7 -12
- package/engine/cli/instance.js +11 -10
- package/engine/cli/io.js +1 -1
- package/engine/cli/ops.js +12 -10
- package/engine/cli/wiring.js +2 -4
- package/engine/config/validate.js +2 -1
- package/engine/core/frontmatter.js +2 -1
- package/engine/core/ownership.js +5 -4
- package/engine/core/scan.js +3 -0
- package/engine/flows/registry.js +2 -2
- package/engine/hooks/files.js +160 -0
- package/engine/hooks/input.js +129 -0
- package/engine/hooks/run.js +24 -449
- package/engine/hooks/shell.js +197 -0
- package/engine/integrations/registry.js +2 -0
- package/engine/planning/contracts.js +12 -10
- package/engine/planning/parser.js +4 -3
- package/engine/planning/state.js +2 -1
- package/package.json +5 -5
- package/template/tools/ops.js +2 -2
|
@@ -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
|
+
}
|
package/engine/hooks/run.js
CHANGED
|
@@ -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 {
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
473
|
-
|
|
474
|
-
|
|
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
|
|
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
|
}
|