@ingeniomaps/cauce 0.62.0 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,65 @@ desde este repositorio no va, porque el que lee no puede actuar sobre eso. Cuand
14
14
  unas pocas líneas casi siempre es porque cuenta cómo se descubrió el problema o por qué se eligió el
15
15
  diseño — eso vive en el commit y en el código.
16
16
 
17
+ ## [0.64.0] - 2026-09-06
18
+
19
+ ### Agregado
20
+
21
+ - **Las salidas de los guards están documentadas, y con su alcance real.** Cinco guards se pueden abrir
22
+ y ninguna de las cinco llaves estaba escrita en un archivo que alguien fuera a abrir: el bloqueo te
23
+ nombraba una variable y no había dónde leer dónde va. `AGENTS.md` ahora las lista en «Qué se puede
24
+ editar y qué no», con lo incómodo dicho: el guard lee la variable de **su propio proceso**, así que
25
+ escribirla delante del comando no llega, y la forma que sí funciona —exportarla en el entorno desde
26
+ el que arranca tu runner— deja el guard apagado hasta que cierres la sesión. La única con alcance de
27
+ operación es la aprobación de gobernanza.
28
+
29
+ - **Un commit de gobernanza se aprueba por operación, no por sesión.** El guard ofrecía como salida
30
+ `OPS_GOVERNANCE_OVERRIDE=1`, que se lee del entorno del proceso: prendida antes de lanzar tu runner
31
+ deja el guard apagado hasta que la sesión cierre. Eso convierte «aprobado este commit» en «apagado
32
+ hasta que me vaya», y el `Makefile` de este proyecto ya decía cuál es el alcance correcto — «la
33
+ autorización de R10 es por operación y humana». **Qué cambia para vos**: escribís las rutas
34
+ autorizadas en `planning/.governance-approval`, una por línea, y el commit pasa. Vale para ese
35
+ conjunto y para ningún otro: si después sumás un archivo, ese archivo no está aprobado. No se consume
36
+ ni se borra sola —así un commit frenado por otra razón no te obliga a rehacerla—, así que `check` te
37
+ avisa mientras exista para que la borres. La variable sigue funcionando y el mensaje del guard ahora
38
+ dice por qué no es la vía recomendada.
39
+
40
+ ## [0.63.0] - 2026-09-06
41
+
42
+ ### Corregido
43
+
44
+ - **El salto de línea termina la lista de argumentos de un comando.** `shell-boundary` leía los
45
+ argumentos de un `cp`, un `tee` o un `sed -i` cruzando a la línea siguiente, así que el destino que
46
+ acusaba podía ser el comando de abajo: un bloqueo que hablaba de una escritura en `…/python3`, una
47
+ ruta que no aparecía en el comando. Falla hacia el lado seguro —frena de más— pero señala algo que no
48
+ existe, y un guard que señala mal es el que se termina apagando. **Qué cambia para vos**: si escribís
49
+ scripts de varias líneas en un solo comando, dejás de ver bloqueos por rutas inventadas; lo que sí
50
+ escribe fuera de las raíces se sigue frenando igual.
51
+
52
+ - **El cuerpo de un heredoc es texto, no un comando.** Escribir un archivo con
53
+ `cat > nota.md <<'FIN' … FIN` juzgaba cada línea del documento como si fuera a ejecutarse, así que no
54
+ se podía documentar lo que los guards vigilan: un párrafo que explica por qué no se borra la raíz se
55
+ bloqueaba por nombrarlo, y la salida era cambiar de herramienta para escribir un archivo. Un heredoc
56
+ es entrada estándar y no se ejecuta nunca. **Qué cambia para vos**: el cuerpo deja de juzgarse y la
57
+ línea que lo abre se sigue juzgando entera, con su redirección —también si la escribís después del
58
+ delimitador, como en `cat <<FIN > salida`—. Lo que se pierde a cambio: un cuerpo que después alguien
59
+ ejecuta; al escribirse no ejecuta nada, y cuando se corra el guard verá el comando de verdad.
60
+
61
+ - **Una variable delante de `git commit` ya no apaga tres guards.** `dependencies`, `governance` y el
62
+ control de generados de `verify` sólo corren sobre un commit, y decidían si lo era con un ancla que
63
+ no contempla lo que un shell admite antes del verbo. Con `VAR=1 git commit` dejaban de correr **sin
64
+ decir nada**, y con la ironía de que el prefijo que se escribe para un commit de gobernanza es una
65
+ asignación: `OPS_GOVERNANCE_OVERRIDE=1 git commit` no leía el override, hacía que el guard no se
66
+ ejecutara. **Qué cambia para vos**: si venías escribiendo ese prefijo, ahora el guard corre y te va a
67
+ frenar. La variable se lee del entorno del guard, no del comando, y el mensaje ahora lo dice.
68
+
69
+ - **Un índice que no se puede leer deja de autorizar el commit.** `stagedFiles` devolvía una lista vacía
70
+ tanto si el índice estaba vacío como si no se pudo leer, y los tres guards de arriba leen esa
71
+ respuesta: una lectura fallida se les presentaba como «no hay nada que revisar». Llegar a una es
72
+ fácil, porque el guard no expande variables: `git -C $OPS commit` resuelve la ruta literal `$OPS`.
73
+ **Qué cambia para vos**: ese comando ahora se frena con un mensaje que nombra la causa. Escribí la
74
+ ruta literal en `git -C`.
75
+
17
76
  ## [0.62.0] - 2026-09-06
18
77
 
19
78
  ### Corregido
@@ -102,7 +102,15 @@ function evaluationBench(root, agent, caso, force, kind) {
102
102
  // reintentos, rehacer un banco es una operación que falla de vez en cuando y deja la corrida sin
103
103
  // empezar.
104
104
  fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
105
- IN.scaffold(dir, { name: 'Banco de evaluación', mode: 'sidecar', quiet: true })
105
+ // Con `force`: el banco es desechable y se acaba de borrar, así que lo que sobreviva al `rmSync` se
106
+ // pisa en vez de cortar la corrida. Sin esto, `copyTemplate` se niega ante cualquier archivo que
107
+ // quede —«El destino contiene …/AGENTS.md»— y el mismo test falló así tres veces en un día, en las
108
+ // dos patas de la matriz. Por qué algo sobrevive a un borrado que no lanzó no está establecido.
109
+ //
110
+ // No ablanda ninguna protección: la pregunta «¿acá alguien trabajó?» la contesta el `git status` de
111
+ // arriba, que exige `--force` explícito para seguir. Esta segunda puerta no la eligió nadie y sólo
112
+ // se cerraba a veces, que es la clase de freno que enseña a re-correr sin leer.
113
+ IN.scaffold(dir, { name: 'Banco de evaluación', mode: 'sidecar', quiet: true, force: true })
106
114
  // El motor por symlink: la misma resolución que en una instancia real —`node_modules/@ingeniomaps`—
107
115
  // sin pagar un `npm install` por corrida. El cargo llega a un banco donde el CLI funciona.
108
116
  const scope = path.join(dir, 'node_modules', '@ingeniomaps')
@@ -11,6 +11,7 @@ const PC = require('../planning/contracts')
11
11
  const SZ = require('../planning/sizing')
12
12
  const ST = require('../planning/state')
13
13
  const AD = require('../planning/adoption')
14
+ const AP = require('../hooks/approval')
14
15
  const I = require('../integrations/registry')
15
16
  const O = require('../core/ownership')
16
17
  const OB = require('../core/onboarding')
@@ -85,6 +86,14 @@ function check(dir, cli) {
85
86
  epics, milestones, done, wip, roles, humanActions: P.readHumanActions(root), adopted: new Set(adopted),
86
87
  }))
87
88
  warnings.push(...AD.report({ done, epics, adopted }))
89
+ // Una aprobación de gobernanza vale para el conjunto que nombra, así que olvidada sigue autorizando
90
+ // esas mismas rutas la próxima vez que alguien las stagee. No caduca sola: lo que la cierra es que se
91
+ // vea en cada corrida y alguien la borre.
92
+ const aprobadas = AP.read(path.resolve(root, '..'))
93
+ if (aprobadas.length) {
94
+ warnings.push(`planning/${AP.APPROVAL}: ${aprobadas.length} ruta(s) de gobernanza aprobadas y sin `
95
+ + 'borrar; el archivo sigue autorizándolas')
96
+ }
88
97
 
89
98
  const integration = I.validate(path.resolve(root, '..'))
90
99
  errors.push(...integration.errors)
@@ -0,0 +1,31 @@
1
+ 'use strict'
2
+
3
+ // La aprobación de un commit de gobernanza: una lista de rutas que una persona escribió a mano para
4
+ // autorizar exactamente ese cambio. Existe porque la vía que había era una variable de entorno, y una
5
+ // variable es por sesión: prendida antes de lanzar el runner deja el guard apagado hasta que la sesión
6
+ // cierre. El `Makefile` de este repositorio ya dice cuál es el alcance correcto —«la autorización de R10
7
+ // es por operación y humana»—, y una llave que dura toda la sesión no lo es.
8
+ //
9
+ // **Se coteja, no se consume.** Borrar el archivo al leerlo daría el mismo alcance y traería dos cosas
10
+ // que no queremos: hoy ningún guard escribe en el repositorio, y `governance` corre antes que `verify`,
11
+ // así que un commit frenado por otra razón se habría llevado puesta la aprobación y habría que
12
+ // rehacerla. Cotejando, la aprobación vale para el conjunto que nombra y para ningún otro: en cuanto
13
+ // cambia lo que está en el índice deja de servir, que es «por operación» sin fecha ni contador.
14
+ //
15
+ // Queda a la vista porque `check` avisa mientras exista. Sin eso, un archivo olvidado sigue autorizando
16
+ // esas mismas rutas la próxima vez que alguien las stagee, que es la puerta abierta que esto evitaba.
17
+
18
+ const path = require('node:path')
19
+ const fs = require('node:fs')
20
+
21
+ const APPROVAL = '.governance-approval'
22
+
23
+ // Una ruta por línea, `#` para lo demás. El archivo ausente y el vacío son lo mismo: no hay nada
24
+ // aprobado, que es el estado normal.
25
+ function read(root) {
26
+ let text = ''
27
+ try { text = fs.readFileSync(path.join(root, 'planning', APPROVAL), 'utf8') } catch { return [] }
28
+ return text.split('\n').map((line) => line.replace(/#.*$/, '').trim()).filter(Boolean)
29
+ }
30
+
31
+ module.exports = { APPROVAL, read }
@@ -22,10 +22,27 @@ function readInput() {
22
22
  }
23
23
  }
24
24
 
25
+ // El cuerpo de un heredoc es entrada estándar: no se ejecuta, se escribe. Juzgarlo como comando frenaba
26
+ // documentar lo que los guards vigilan — escribir un archivo que explica por qué borrar la raíz es
27
+ // catastrófico se bloqueaba por nombrarlo—, y la salida era cambiar de herramienta, que es el rodeo que
28
+ // un guard no debería enseñar.
29
+ //
30
+ // La línea de apertura se conserva **entera**, porque sí es comando y `shell-boundary` tiene que seguir
31
+ // viendo dónde escribe. Entera incluye lo que va después del delimitador: `cat <<FIN > salida` es una
32
+ // forma válida y su destino está ahí. El cuerpo empieza en el salto de línea, no en el delimitador —
33
+ // recortar desde el delimitador se llevaba esa redirección, y nada lo notaba porque en la forma común
34
+ // el destino va antes del `<<`.
35
+ //
36
+ // Lo que se pierde: un cuerpo que después alguien ejecuta. `cat > script.sh <<EOF` no ejecuta nada al
37
+ // escribirse y el guard verá el comando de verdad cuando alguien corra el script; el borde filoso es
38
+ // `$(cat <<EOF …)`, donde el cuerpo sí corre y ya no se mira. Es el mismo trato que con el mensaje de un
39
+ // commit: se frena la forma habitual, no al que quiere pasar.
40
+ const HEREDOC = /<<(-?)\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2([^\n]*)\n[\s\S]*?^\s*\3\s*$/gm
41
+
25
42
  function commandOf(input) {
26
43
  const value = input.tool_input && (input.tool_input.command || input.tool_input.cmd)
27
44
  || input.command || input.input && input.input.command || process.env.OPS_HOOK_COMMAND || ''
28
- return Array.isArray(value) ? value.join(' ') : String(value)
45
+ return String(Array.isArray(value) ? value.join(' ') : value).replace(HEREDOC, '<<$1$2$3$2$4')
29
46
  }
30
47
 
31
48
  function fileOf(input) {
@@ -86,19 +103,59 @@ function configOf(root) {
86
103
  }
87
104
  }
88
105
 
106
+ // Vacía lo que va entre comillas, dejando una marca que ningún patrón confunde con una ruta ni con un
107
+ // comando. Vive acá porque la usan tres lugares por razones distintas, y cada uno explica la suya donde
108
+ // la llama.
109
+ const unquoted = (command) => String(command).replace(/'[^']*'|"[^"]*"/g, '\u0000')
110
+
111
+ // Sobre qué repositorio se lee el índice. En un commit se mira el comando con el mensaje vaciado, por
112
+ // la misma razón por la que `destructive` lo hace: un mensaje que menciona `git -C $VAR` no está
113
+ // eligiendo un repositorio, lo está citando. Sin esto, el commit que explica este arreglo se bloquea a
114
+ // sí mismo — pasó al escribirlo.
115
+ //
116
+ // El precio es una ruta entrecomillada en el propio `-C` de un commit —`git -C "mi carpeta" commit`—,
117
+ // que se pierde y cae al cwd. Es más raro que un mensaje que cita un comando, y el cwd de un commit
118
+ // suele ser el repositorio correcto; el caso contrario deja al guard leyendo un índice ajeno.
89
119
  function gitDirectory(command, cwd) {
90
- const flag = command.match(/(?:^|\s)git\s+-C\s+(['"]?)([^\s'";&|]+)\1/)
91
- const cd = command.match(/(?:^|[;&|]\s*)cd\s+(['"]?)([^\s'";&|]+)\1/)
120
+ const text = isCommit(command) ? unquoted(command) : command
121
+ const flag = text.match(/(?:^|\s)git\s+-C\s+(['"]?)([^\s'";&|]+)\1/)
122
+ const cd = text.match(/(?:^|[;&|]\s*)cd\s+(['"]?)([^\s'";&|]+)\1/)
92
123
  return path.resolve(cwd, flag ? flag[2] : cd ? cd[2] : '.')
93
124
  }
94
125
 
126
+ // Lo que un shell admite delante del verbo: asignaciones de entorno, `env` y `sudo`. `VAR=1 git commit`
127
+ // empieza por la asignación, así que un ancla que sólo acepta el principio del comando o un separador
128
+ // no ve el `git` que viene después.
129
+ //
130
+ // Falla en los dos sentidos y uno no avisa. Del lado ruidoso, el mensaje del commit vuelve a juzgarse
131
+ // como comando. Del silencioso —el que importa— los tres guards que sólo corren sobre un commit dejan
132
+ // de correr: gobernanza, dependencias y generados. Cualquier variable delante alcanza, y la ironía es
133
+ // que el prefijo que el procedimiento manda escribir para un commit de gobernanza es
134
+ // `OPS_GOVERNANCE_OVERRIDE=1`: escrito ahí, el guard no lee el override, directamente no se ejecuta.
135
+ const PREFIX = String.raw`(?:^|[;&|]\s*)(?:(?:env|sudo)\s+)*`
136
+ + String.raw`(?:[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\S*)\s+)*`
137
+ const COMMIT = new RegExp(PREFIX + String.raw`git(?:\s+-C\s+\S+)?\s+commit(?:\s|$)`)
138
+
95
139
  function isCommit(command) {
96
- return /(?:^|[;&|]\s*)git(?:\s+-C\s+\S+)?\s+commit(?:\s|$)/.test(command)
140
+ return COMMIT.test(command)
97
141
  }
98
142
 
143
+ // Un índice vacío y un índice ilegible no son la misma respuesta: la primera autoriza a seguir, la
144
+ // segunda no autoriza nada. Devolviendo `[]` en los dos casos, los tres guards que preguntan acá se
145
+ // apagaban en silencio ante cualquier lectura fallida — y llegar a una es fácil, porque `gitDirectory`
146
+ // no expande variables: `git -C $OPS commit` resuelve la ruta literal `$OPS`, que no existe.
147
+ //
148
+ // Es la regla que el propio shim ya tiene escrita —«Un guard que no encuentra su motor bloquea, nunca
149
+ // permite»—, aplicada donde faltaba. Bloquear desde acá es seguro: los tres llamadores son guards, así
150
+ // que no hay ningún consumidor que sólo quiera consultar el índice.
99
151
  function stagedFiles(dir) {
100
152
  const result = spawnSync('git', ['-C', dir, 'diff', '--cached', '--name-only'], { encoding: 'utf8' })
101
- return result.status === 0 ? result.stdout.trim().split('\n').filter(Boolean) : []
153
+ if (result.status !== 0) {
154
+ const why = (result.stderr || '').trim() || (result.error && result.error.message) || 'git falló'
155
+ block(`no se pudo leer el índice de ${dir} (${why}). Un guard que no puede verificar no autoriza. `
156
+ + 'Si usaste una variable en `git -C`, escribí la ruta literal.')
157
+ }
158
+ return result.stdout.trim().split('\n').filter(Boolean)
102
159
  }
103
160
 
104
161
  // R10 pide «la autorización configurada para el proyecto» y `runner.allowPush` es esa configuración:
@@ -157,5 +214,5 @@ const DECLARE_IT = 'Si el proyecto necesita escribir ahí, declaralo en writable
157
214
  module.exports = {
158
215
  readInput, commandOf, patchOf, filesOf, contentOf, cwdOf, block, configOf,
159
216
  gitDirectory, isCommit, stagedFiles, pushAllowed, findOpsRoot,
160
- writableRoots, outsideRoots, DECLARE_IT,
217
+ writableRoots, outsideRoots, DECLARE_IT, unquoted,
161
218
  }
@@ -10,8 +10,9 @@ const path = require('node:path')
10
10
  const { spawnSync } = require('node:child_process')
11
11
  const {
12
12
  commandOf, cwdOf, block, gitDirectory, isCommit, stagedFiles, pushAllowed,
13
- writableRoots, outsideRoots, DECLARE_IT,
13
+ writableRoots, outsideRoots, DECLARE_IT, unquoted, findOpsRoot,
14
14
  } = require('./input')
15
+ const AP = require('./approval')
15
16
 
16
17
  // Dónde empieza y dónde termina una palabra dentro de un comando. Tres reglas de la tabla de abajo lo
17
18
  // decidían por su cuenta admitiendo sólo un espacio, el principio o el fin, y en un shell una palabra
@@ -169,10 +170,17 @@ function dependencies(input) {
169
170
  // Tres familias, porque los comandos no nombran su destino igual: `tee` y `truncate` escriben en cada
170
171
  // argumento, `cp` y sus hermanos en el último, y `sed` sólo escribe con `-i` —sin él lee y manda a
171
172
  // stdout, y esa redirección la ve REDIRECT—.
173
+ //
174
+ // El salto de línea termina una lista de argumentos igual que `;`. Sin excluirlo, la de un `cp` seguía
175
+ // leyendo la línea de abajo y el destino terminaba siendo el comando siguiente: un bloqueo que nombraba
176
+ // `…/python3`, una ruta que no aparecía en el comando. Se veía con un heredoc debajo, pero el heredoc no
177
+ // era la causa —sólo hacía que la lectura frenara en un token que sobrevive—: sin él la lista cruzaba
178
+ // igual y el último token era la marca de lo entrecomillado, que el filtro final descarta. O sea que
179
+ // pasaba de casualidad, y aserciar que pasa no fijaba nada.
172
180
  const REDIRECT = /(?:^|[\s(])&?\d*>>?\s*(?![&(])([^\s;|&<>()]+)/g
173
- const EVERY_ARG = /(?:^|[\s;|&(])(tee|truncate)\s+([^;|&<>()]+)/g
174
- const LAST_ARG = /(?:^|[\s;|&(])(cp|mv|install|rsync)\s+([^;|&<>()]+)/g
175
- const SED = /(?:^|[\s;|&(])sed\s+([^;|&<>()]+)/g
181
+ const EVERY_ARG = /(?:^|[\s;|&(])(tee|truncate)\s+([^;|&<>()\n]+)/g
182
+ const LAST_ARG = /(?:^|[\s;|&(])(cp|mv|install|rsync)\s+([^;|&<>()\n]+)/g
183
+ const SED = /(?:^|[\s;|&(])sed\s+([^;|&<>()\n]+)/g
176
184
  const IN_PLACE = /(?:^|\s)-{1,2}i/
177
185
 
178
186
  // Los argumentos que no son flags. El valor de un flag se cuela —`truncate -s 0 log` trae el `0`— y no
@@ -180,10 +188,6 @@ const IN_PLACE = /(?:^|\s)-{1,2}i/
180
188
  // decide un bloqueo. Filtrarlo sería una rama que ninguna prueba puede ver caer.
181
189
  const positional = (text) => text.trim().split(/\s+/).filter((one) => one && !one.startsWith('-'))
182
190
 
183
- // Vacía lo que va entre comillas, dejando una marca que ningún patrón confunde con una ruta ni con un
184
- // comando. Lo usan dos guards por razones distintas, y cada uno explica la suya donde lo llama.
185
- const unquoted = (command) => String(command).replace(/'[^']*'|"[^"]*"/g, '\u0000')
186
-
187
191
  // Un `>` adentro de una cadena no redirige nada. Pierde el destino entrecomillado, que es un falso
188
192
  // negativo — el error barato en un guard que ya es incompleto, porque el caro es frenar un comando
189
193
  // legítimo y que alguien apague el guard entero.
@@ -247,11 +251,19 @@ function governance(input) {
247
251
  String.raw`|evaluations\/(?:cases\/|expected-behaviors\.yaml)|learning\/proposals\/))`,
248
252
  )
249
253
  const governed = stagedFiles(dir).filter((file) => governedPattern.test(file))
250
- if (governed.length) {
251
- const files = governed.map((file) => ` - ${file}`).join('\n')
252
- block(`El commit toca gobernanza protegida:\n${files}\n` +
253
- 'Usa OPS_GOVERNANCE_OVERRIDE=1 solo con aprobación.')
254
- }
254
+ if (!governed.length) return
255
+ // La aprobación vale para lo que nombra y para nada más: lo que quede sin cubrir es lo que se
256
+ // reporta. Así una aprobación vieja no autoriza el archivo que se sumó después, que es la diferencia
257
+ // entre una llave por operación y una puerta que quedó abierta.
258
+ const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
259
+ const aprobados = new Set(root ? AP.read(root) : [])
260
+ const pendientes = governed.filter((file) => !aprobados.has(file))
261
+ if (!pendientes.length) return
262
+ const files = pendientes.map((file) => ` - ${file}`).join('\n')
263
+ block(`El commit toca gobernanza protegida:\n${files}\n`
264
+ + `Aprobalo escribiendo esas rutas en planning/${AP.APPROVAL}, una por línea: vale para ese `
265
+ + 'conjunto y deja de valer en cuanto cambie. La variable OPS_GOVERNANCE_OVERRIDE=1 sigue '
266
+ + 'existiendo y apaga el guard para toda la sesión, que es por lo que no es la vía recomendada.')
255
267
  }
256
268
 
257
269
  function run(program, args, cwd) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.62.0",
3
+ "version": "0.64.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",
@@ -98,6 +98,34 @@ siquiera están acá — los lee el motor desde el paquete.
98
98
  Un guard existente **no se edita**: `upgrade` detecta el cambio y se detiene antes de pisarlo, y con
99
99
  `--force` deja registrado qué descartó.
100
100
 
101
+ ### Cuando un guard te frena con razón
102
+
103
+ Algunos bloqueos tienen salida, y conviene saber cuál antes de necesitarla — el momento en que un guard
104
+ te frena es el peor para elegir bien.
105
+
106
+ **Un commit que toca gobernanza** —reglas, ADRs, el contrato de un cargo o lo que lo mide— se autoriza
107
+ escribiendo las rutas en `planning/.governance-approval`, una por línea, con `#` para lo que no sea una
108
+ ruta. Vale para ese conjunto y para ningún otro: si después sumás un archivo, ese archivo no está
109
+ aprobado y el guard lo nombra. No se borra sola —así un commit frenado por otra cosa no te obliga a
110
+ rehacerla—, así que `check` te avisa mientras exista, y borrarla es parte de terminar.
111
+
112
+ **Las demás salidas son variables de entorno**, y hay que decir su alcance
113
+ porque no es el que uno espera: el guard la lee de **su propio proceso**, no del comando. Escribirla
114
+ delante —`VAR=1 git commit`— no llega. La forma que sí funciona es exportarla en el entorno desde el que
115
+ arranca tu runner, y eso deja el guard apagado **hasta que cierres la sesión**, no para un comando.
116
+
117
+ | variable | qué abre |
118
+ |---|---|
119
+ | `OPS_GOVERNANCE_OVERRIDE=1` | lo mismo que la aprobación de arriba, pero para toda la sesión |
120
+ | `OPS_MIGRATIONS_OVERRIDE=1` | escribir SQL destructivo en una migración |
121
+ | `OPS_TEST_EVIDENCE_OVERRIDE=1` | borrar o apagar una prueba |
122
+ | `OPS_DEPENDENCIES_OVERRIDE=1` | tocar manifiestos y lockfiles, publicar o instalar global |
123
+ | `OPS_SKIP_VERIFY=1` | saltear los gates del stack antes de un commit |
124
+
125
+ Son de sesión y no de operación, que es exactamente lo que la aprobación de gobernanza vino a corregir.
126
+ Mientras sigan así, lo que corresponde es prenderlas para lo que hacía falta y apagarlas después — y que
127
+ la razón quede escrita donde alguien la lea, no sólo en la memoria de quien la prendió.
128
+
101
129
  ## Cómo leer el estado
102
130
 
103
131
  Antes de abrir un archivo de `planning/`, preguntarle al CLI: es determinista, no gasta contexto y no