@ingeniomaps/cauce 0.81.0 → 0.83.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +248 -0
  2. package/automatization/hooks/README.md +10 -6
  3. package/automatization/hooks/guard-ops-config-shell.sh +3 -0
  4. package/automatization/hooks/guard-ops-config.sh +3 -0
  5. package/automatization/runners/antigravity/rules/cauce.md +7 -2
  6. package/automatization/runners/claude/CLAUDE.md +1 -4
  7. package/automatization/runners/codex/AGENTS.md +7 -3
  8. package/automatization/runners/gemini/GEMINI.md +1 -4
  9. package/automatization/shared/inbox.js +47 -0
  10. package/automatization/workflows/agent-eval.js +1 -1
  11. package/automatization/workflows/autobuild.js +56 -18
  12. package/automatization/workflows/flow.js +45 -8
  13. package/automatization/workflows/onboard.js +32 -3
  14. package/engine/automation/index.js +19 -4
  15. package/engine/automation/rules.js +122 -0
  16. package/engine/automation/runners.js +3 -1
  17. package/engine/cli/instance.js +35 -6
  18. package/engine/cli/planning.js +16 -2
  19. package/engine/config/validate.js +53 -3
  20. package/engine/core/onboarding.js +37 -7
  21. package/engine/core/ownership.js +64 -1
  22. package/engine/core/scan.js +23 -9
  23. package/engine/hooks/approval.js +50 -15
  24. package/engine/hooks/chat.js +122 -11
  25. package/engine/hooks/input.js +1 -11
  26. package/engine/hooks/ops-config.js +110 -0
  27. package/engine/hooks/push.js +194 -0
  28. package/engine/hooks/run.js +17 -2
  29. package/engine/hooks/secrets-shell.js +13 -2
  30. package/engine/hooks/self-approval.js +93 -13
  31. package/engine/hooks/shell.js +13 -11
  32. package/engine/integrations/registry.js +13 -1
  33. package/engine/planning/inbox.js +36 -0
  34. package/engine/planning/parser.js +22 -9
  35. package/engine/planning/recurring.js +13 -2
  36. package/engine/schemas/ops-config.schema.json +21 -0
  37. package/package.json +1 -1
  38. package/template/AGENTS.md +13 -5
  39. package/template/gitignore +5 -0
  40. package/template/planning/INBOX.md +2 -1
  41. package/template/planning/RECURRING.md +6 -5
  42. package/template/planning/delivery/teamwork.md +1 -1
  43. package/template/planning/rules/system/commits.md +3 -1
@@ -20,7 +20,7 @@ function validateOpsConfig(config) {
20
20
  // `cauceVersion` la escribe el toolkit, no la persona: registra de qué versión salió la instancia.
21
21
  const allowed = new Set([
22
22
  '$schema', 'cauceVersion', 'project', 'mode', 'workspaceRoots', 'writableOutsideRoots', 'runner',
23
- 'migrations',
23
+ 'migrations', 'inbox',
24
24
  ])
25
25
  for (const key of Object.keys(config)) {
26
26
  if (RETIRED[key]) errors.push(`ops.config.json: ${key} ya no se usa: ${RETIRED[key]}`)
@@ -34,9 +34,26 @@ function validateOpsConfig(config) {
34
34
  validateWritable(config.writableOutsideRoots, errors)
35
35
  validateRunner(config.runner, errors)
36
36
  validateMigrations(config.migrations, errors)
37
+ validateInbox(config.inbox, errors)
37
38
  return errors
38
39
  }
39
40
 
41
+ // El umbral del aviso de tamaño del INBOX. Sólo un entero positivo: un cero o un texto se leerían como
42
+ // «avisá siempre» o «nunca», y ninguno de los dos es algo que alguien escriba a propósito.
43
+ function validateInbox(inbox, errors) {
44
+ if (inbox === undefined) return
45
+ if (!inbox || typeof inbox !== 'object' || Array.isArray(inbox)) {
46
+ errors.push('ops.config.json: inbox debe ser un objeto')
47
+ return
48
+ }
49
+ for (const key of Object.keys(inbox)) {
50
+ if (key !== 'warnLines') errors.push(`ops.config.json: inbox.${key} no está permitido`)
51
+ }
52
+ if ('warnLines' in inbox && !(Number.isInteger(inbox.warnLines) && inbox.warnLines > 0)) {
53
+ errors.push('ops.config.json: inbox.warnLines debe ser un entero mayor que cero, o no estar')
54
+ }
55
+ }
56
+
40
57
  // Qué cuenta como migración para el guard. Sin declararlo, sólo `.sql` — y ése es el default que hace
41
58
  // falta decir, porque un proyecto TypeORM, Prisma, Django o Rails tiene el guard cableado y en verde sin
42
59
  // que mire una sola migración (caso 077).
@@ -68,11 +85,36 @@ function validateMigrations(migrations, errors) {
68
85
  }
69
86
  }
70
87
 
88
+ // Lo que la configuración tiene de dudoso y no de inválido. Va aparte de `validateOpsConfig` porque lo que
89
+ // aquélla devuelve son errores y la leen también los guards: una advertencia ahí sería un rechazo.
90
+ //
91
+ // Hoy sólo el nombre repetido: dos raíces con el mismo `name` dejan dos servicios indistinguibles en
92
+ // `check`, `onboard` y `scan`, y una credencial que no se puede atribuir a ninguno (caso 113). Avisa y no
93
+ // falla porque nadie eligió su `name` pensando que fuera único, así que rechazarlo le rompería la puerta a
94
+ // una instancia que hoy funciona, y lo que está en juego es un nombre ambiguo, no algo que se pierda.
95
+ function configWarnings(config) {
96
+ const roots = Array.isArray(config && config.workspaceRoots) ? config.workspaceRoots : []
97
+ const found = []
98
+ const named = new Map()
99
+ for (const workspace of roots) {
100
+ const name = workspace && typeof workspace.name === 'string' ? workspace.name.trim() : ''
101
+ if (!name) continue
102
+ if (named.has(name)) {
103
+ found.push(`ops.config.json: ${name} nombra dos raíces (${named.get(name)} y ${workspace.path}): `
104
+ + 'sus servicios salen con el mismo nombre. Renombrá una')
105
+ } else named.set(name, workspace.path)
106
+ }
107
+ return found
108
+ }
109
+
71
110
  function validateWorkspaces(workspaces, errors) {
72
111
  if (!Array.isArray(workspaces) || !workspaces.length) {
73
112
  errors.push('ops.config.json: workspaceRoots debe contener al menos una raíz')
74
113
  return
75
114
  }
115
+ // El nombre repetido no se rechaza acá: lo avisa `check`, que es quien puede hacerlo sin romperle la
116
+ // puerta a una instancia que hoy funciona (caso 113). Este validador lo leen también los guards, y lo
117
+ // que devuelve son errores: meterlo acá era decidir que la configuración es inválida.
76
118
  for (const [index, workspace] of workspaces.entries()) {
77
119
  if (!workspace || typeof workspace !== 'object' || Array.isArray(workspace)) {
78
120
  errors.push(`ops.config.json: workspaceRoots[${index}] debe ser un objeto`)
@@ -119,10 +161,18 @@ function validateRunner(runner, errors) {
119
161
  return
120
162
  }
121
163
  const booleans = ['humanCheckpointBetweenMilestones', 'commitPerTask', 'allowPush']
122
- const allowed = new Set(['maxTaskHours', ...booleans])
164
+ const allowed = new Set(['maxTaskHours', 'pushToLiveBranches', ...booleans])
123
165
  for (const key of Object.keys(runner)) {
124
166
  if (!allowed.has(key)) errors.push(`ops.config.json: runner.${key} no está permitido`)
125
167
  }
168
+ // Un patrón haría de un permiso por rama un permiso por familia, que es lo que el campo vino a evitar
169
+ // (caso 108): el guard compara el nombre tal cual, y `release/*` no publicaría en ninguna.
170
+ const live = runner.pushToLiveBranches
171
+ if (live !== undefined && (!Array.isArray(live)
172
+ || live.some((branch) => typeof branch !== 'string' || !/^[^\s*?[]+$/.test(branch)))) {
173
+ errors.push('ops.config.json: runner.pushToLiveBranches debe ser una lista de nombres de rama exactos, '
174
+ + 'sin espacios ni patrones')
175
+ }
126
176
  if (typeof runner.maxTaskHours !== 'number' || runner.maxTaskHours <= 0) {
127
177
  errors.push('ops.config.json: runner.maxTaskHours debe ser mayor que cero')
128
178
  }
@@ -133,4 +183,4 @@ function validateRunner(runner, errors) {
133
183
  }
134
184
  }
135
185
 
136
- module.exports = { validateOpsConfig }
186
+ module.exports = { configWarnings, validateOpsConfig }
@@ -9,6 +9,7 @@
9
9
  const fs = require('node:fs')
10
10
  const path = require('node:path')
11
11
  const { inventory } = require('./scan')
12
+ const { sensitiveKey } = require('../integrations/registry')
12
13
 
13
14
  // La raíz del paquete: el molde contra el que se compara lo que una instancia escribió.
14
15
  const PACKAGE_ROOT = path.resolve(__dirname, '..', '..')
@@ -91,11 +92,23 @@ function missingSections(root) {
91
92
  return warnings
92
93
  }
93
94
 
95
+ // Una aparición cuenta sólo si lo que la rodea no puede ser parte de otro nombre de variable: buscada
96
+ // como subcadena, API_SECRET_ROTATION daba por cargada a API_SECRET (caso 111). El escaneo sólo deja
97
+ // pasar identificadores, así que el nombre entra a la expresión sin nada que escapar.
98
+ function named(text, name) {
99
+ return new RegExp(`(?<![A-Za-z0-9_])${name}(?![A-Za-z0-9_])`).test(text)
100
+ }
101
+
94
102
  // Credenciales que el proyecto declara y que no aparecen en ningún contrato. El arranque tiene que
95
103
  // dejar una fila por cada una —quién la carga y dónde— y en la práctica cubre las que se hablaron en la
96
104
  // conversación: las que sólo estaban en el inventario se pierden, y con ellas el servicio externo que
97
105
  // hay detrás. Una variable sin dueño no rompe nada hoy; rompe el día que alguien tiene que desplegar.
98
106
  //
107
+ // Credencial es lo que tiene nombre de secreto según `sensitiveKey`, la misma regla con la que la
108
+ // declaración de secretos y la configuración de una integración rechazan un valor. Contando cualquier
109
+ // variable, el aviso listaba ciento ocho nombres de build y la única credencial quedaba en «y 1 más»
110
+ // (caso 102). El precio es el secreto con nombre de configuración, y el aviso lo dice.
111
+ //
99
112
  // Sólo cuando la instancia ya tiene contexto escrito: antes del arranque no hay dónde estuvieran.
100
113
  function orphanCredentials(root) {
101
114
  if (guide(root).fresh) return []
@@ -108,16 +121,33 @@ function orphanCredentials(root) {
108
121
  .join('\n')
109
122
  if (!contracts) return []
110
123
  const orphans = []
124
+ const cut = []
111
125
  for (const service of inventory(root)) {
112
- for (const name of (service.env || {}).names || []) {
113
- if (!contracts.includes(name)) orphans.push(`${name} (${service.path})`)
126
+ const env = service.env || {}
127
+ for (const name of env.names || []) {
128
+ if (sensitiveKey(name) && !named(contracts, name)) orphans.push({ name, service: service.path })
114
129
  }
130
+ if (env.truncated) cut.push(`${service.path} (${env.truncated} de ${env.names.length + env.truncated})`)
115
131
  }
116
- if (!orphans.length) return []
117
- const summary = orphans.length > 4
118
- ? `${orphans.slice(0, 4).join(', ')} y ${orphans.length - 4} más`
119
- : orphans.join(', ')
120
- return [`el proyecto declara ${summary} y no aparecen en el mapa ni en HUMAN_ACTIONS: nadie las carga`]
132
+ const warnings = []
133
+ if (orphans.length) {
134
+ const listed = orphans.map((one) => `${one.name} (${one.service})`)
135
+ const summary = listed.length > 4
136
+ ? `${listed.slice(0, 4).join(', ')} y ${listed.length - 4} más`
137
+ : listed.join(', ')
138
+ const services = [...new Set(orphans.map((one) => one.service))].join(', ')
139
+ warnings.push(`credenciales por nombre sin dueño (${orphans.length}, en ${services}): ${summary} — no `
140
+ + 'aparecen en el mapa ni en HUMAN_ACTIONS: nadie las carga. El dueño se escribe en '
141
+ + 'organization/workspace.md o en una fila de planning/HUMAN_ACTIONS.md; el criterio es el nombre, así '
142
+ + 'que una credencial con nombre de configuración no aparece acá')
143
+ }
144
+ // El escaneo corta cada ejemplo en un tope, y lo que quedó afuera no se miró: con el filtro, puede ser
145
+ // justo la credencial.
146
+ if (cut.length) {
147
+ warnings.push(`sin revisar por credenciales sin dueño, pasado el tope de variables por servicio: `
148
+ + `${cut.join(', ')} — lo que quedó afuera puede incluir una credencial que nadie carga`)
149
+ }
150
+ return warnings
121
151
  }
122
152
 
123
153
  module.exports = {
@@ -8,6 +8,9 @@
8
8
  const fs = require('node:fs')
9
9
  const path = require('node:path')
10
10
 
11
+ // El paquete que corre: contra él se decide qué del runtime es una entrega de Cauce (casos 100 y 110).
12
+ const PACKAGE_ROOT = path.resolve(__dirname, '..', '..')
13
+
11
14
  // Archivos de los que el toolkit es único autor. Un proyecto que necesite cambiarlos no los
12
15
  // edita: agrega una regla propia junto a las de `system/`, que sí sobrevive al upgrade.
13
16
  const SYSTEM_FILES = [
@@ -176,6 +179,27 @@ function overrides(root) {
176
179
  return found
177
180
  }
178
181
 
182
+ // Las reglas que rigen una instancia, relativas a su raíz: cada `planning/rules/*.md` del proyecto y cada una de
183
+ // `system/` que el proyecto no sobrescribió. `overrides()` ya sabía cuál reemplaza a cuál y sólo servía para
184
+ // avisar; esto es lo que se entrega a quien trabaja (casos 099 y 105). Sólo el primer nivel, igual que `check`.
185
+ function effectiveRules(root) {
186
+ const dir = path.join(root, 'planning', 'rules')
187
+ const markdown = (sub) => {
188
+ try {
189
+ return fs.readdirSync(path.join(dir, sub), { withFileTypes: true })
190
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'README.md'
191
+ && !entry.name.startsWith('.'))
192
+ .map((entry) => entry.name).sort()
193
+ } catch { return [] }
194
+ }
195
+ const replaced = new Set(overrides(root).filter((one) => one.collection === 'planning/rules')
196
+ .map((one) => one.system))
197
+ return [
198
+ ...markdown('system').filter((name) => !replaced.has(name)).map((name) => `planning/rules/system/${name}`),
199
+ ...markdown('').map((name) => `planning/rules/${name}`),
200
+ ]
201
+ }
202
+
179
203
  // Rutas que el toolkit dejó de materializar. Sin esto una instancia arrastra para siempre lo que
180
204
  // alguna versión suya copió: `upgrade` agrega y reemplaza, pero nunca quitaba nada.
181
205
  // Cada archivo propio del molde, con cómo llega a una instancia que **ya existe**. `upgrade` sólo
@@ -302,7 +326,7 @@ function localChanges(root) {
302
326
  for (const target of trackedPaths()) {
303
327
  const dir = path.join(root, target)
304
328
  if (!fs.existsSync(dir)) continue
305
- for (const file of manifest.edited(root, target, treeFiles(dir))) changed.push(`${target}/${file}`)
329
+ for (const file of manifest.edited(root, target, deliveredFiles(root, target))) changed.push(`${target}/${file}`)
306
330
  }
307
331
  // Los archivos sueltos del sistema entran por la misma puerta. Quedaban afuera, así que `upgrade`
308
332
  // los reemplazaba en silencio: un cargo escribió el índice de ADR que el propio README le pedía
@@ -311,8 +335,47 @@ function localChanges(root) {
311
335
  return changed
312
336
  }
313
337
 
338
+ // Lo que el paquete que corre trae en una ruta del runtime. El resto de esa carpeta es de la empresa —un
339
+ // guard propio—, y no es una entrega: registrado, pasaba a «editado» en cuanto la empresa lo tocaba, y
340
+ // `upgrade --check` salía con 1 por algo que nunca fue de Cauce (caso 100).
341
+ function shippedFiles(relative) {
342
+ return new Set(treeFiles(path.join(PACKAGE_ROOT, sourceOf(relative))))
343
+ }
344
+
345
+ // Lo que de una ruta rastreada cuenta como entregado: todo, salvo en el runtime, donde sólo lo que el
346
+ // paquete trae. Lo usan el registro de `init` y de `upgrade` y la detección de ediciones, que tienen que
347
+ // contar lo mismo.
348
+ function deliveredFiles(root, relative) {
349
+ const files = treeFiles(path.join(root, relative))
350
+ return RUNTIME_PATHS.includes(relative) ? files.filter((file) => shippedFiles(relative).has(file)) : files
351
+ }
352
+
353
+ // Lo que el paquete empieza a traer con un nombre que la instancia ya usaba para algo suyo: un guard propio
354
+ // que se llama como uno nuevo del toolkit (caso 110). Sin huella en el registro no cuenta como edición, así
355
+ // que copiar encima lo borraba sin decirlo. Sólo si el registro ya conoce la ruta: en una instancia
356
+ // anterior al registro, «sin huella» también es «lo entregó una versión vieja».
357
+ function collisions(root) {
358
+ const manifest = require('./manifest')
359
+ const recorded = manifest.read(root)
360
+ const found = []
361
+ for (const relative of RUNTIME_PATHS) {
362
+ if (!Object.keys(recorded).some((key) => key.startsWith(`${relative}/`))) continue
363
+ for (const file of shippedFiles(relative)) {
364
+ const local = path.join(root, relative, file)
365
+ if (recorded[`${relative}/${file}`] || !fs.existsSync(local)) continue
366
+ const shipped = path.join(PACKAGE_ROOT, sourceOf(relative), file)
367
+ if (manifest.digest(local) !== manifest.digest(shipped)) found.push(`${relative}/${file}`)
368
+ }
369
+ }
370
+ return found
371
+ }
372
+
314
373
  module.exports = {
315
374
  RETIRED,
375
+ collisions,
376
+ deliveredFiles,
377
+ effectiveRules,
378
+ shippedFiles,
316
379
  RETIRED_COMPARTIDO,
317
380
  TEMPLATE_OWN,
318
381
  TEMPLATE_PREFIXES,
@@ -171,17 +171,28 @@ function scan(root, skip = '') {
171
171
  }
172
172
  }
173
173
 
174
- // Dónde puede mirar una instancia: exactamente las raíces que declara, y nada por encima de ellas. Sale
174
+ // Dónde puede mirar una instancia: exactamente las raíces que declara, con el nombre que les puso. Sale
175
175
  // de `ops.config.json` en vez de suponerse —el sidecar declara `..`, el embebido `.`— para que acotar las
176
176
  // raíces acote también el escaneo, y para que nadie termine recorriendo la carpeta de al lado.
177
- function workspaceRoots(root) {
177
+ //
178
+ // El nombre cae a la carpeta cuando la raíz no lo declara: `check` lo exige, pero `scan` y `onboard`
179
+ // corren igual sobre una configuración que nunca pasó por ahí, y ahí el prefijo saldría `undefined`.
180
+ function declaredRoots(root) {
181
+ const fallback = [{ name: path.basename(root), dir: root }]
178
182
  try {
179
183
  const config = JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8'))
180
- const declared = (config.workspaceRoots || []).map((entry) => path.resolve(root, entry.path || '.'))
181
- return declared.length ? declared : [root]
182
- } catch { return [root] }
184
+ const declared = (config.workspaceRoots || []).map((entry) => {
185
+ const dir = path.resolve(root, entry.path || '.')
186
+ return { name: String(entry.name || '').trim() || path.basename(dir), dir }
187
+ })
188
+ return declared.length ? declared : fallback
189
+ } catch { return fallback }
183
190
  }
184
191
 
192
+ // Sólo las rutas. Es lo que mira quien acota una escritura, y `onboard --json` las emite tal cual en
193
+ // `roots`: darles forma de objeto habría cambiado ese contrato para quien no necesita el nombre.
194
+ const workspaceRoots = (root) => declaredRoots(root).map((one) => one.dir)
195
+
185
196
  // Qué hay en las raíces declaradas, antes de que nadie razone sobre ello. La raíz ops se saltea: no es
186
197
  // un servicio del proyecto, y su `package.json` sólo declara el motor.
187
198
  // Los candidatos de una raíz, con el proyecto que vive en ella misma primero: un monolito declara sus
@@ -197,12 +208,15 @@ function candidates(workspace, skip = '') {
197
208
  // Con varias raíces, cada repositorio es la raíz de su propio escaneo y su candidato principal se llama
198
209
  // `.`: tres servicios con el mismo nombre y nada que los distinga. El prefijo los vuelve nombrables, que
199
210
  // es la única forma de que una credencial pueda atribuirse a un servicio en vez de quedar suelta.
211
+ //
212
+ // El prefijo es el `name` declarado y no la carpeta, que es lo que dejaba a `gouduet/keycloak` y
213
+ // `hypixo/keycloak` llamándose las dos `keycloak` (caso 113). Que no se repita lo exige el validador.
200
214
  function inventory(root) {
201
- const roots = workspaceRoots(root)
202
- if (roots.length === 1) return candidates(roots[0], root)
203
- return roots.flatMap((workspace) => candidates(workspace, root).map((service) => ({
215
+ const roots = declaredRoots(root)
216
+ if (roots.length === 1) return candidates(roots[0].dir, root)
217
+ return roots.flatMap(({ name, dir }) => candidates(dir, root).map((service) => ({
204
218
  ...service,
205
- path: service.path === '.' ? path.basename(workspace) : `${path.basename(workspace)}/${service.path}`,
219
+ path: service.path === '.' ? name : `${name}/${service.path}`,
206
220
  })))
207
221
  }
208
222
 
@@ -32,19 +32,39 @@ const CHAT = require('./chat')
32
32
  const APPROVAL = '.ops-approval'
33
33
 
34
34
  // Una ruta por línea, `#` para lo demás. El archivo ausente y el vacío son lo mismo: no hay nada
35
- // aprobado, que es el estado normal.
35
+ // aprobado, que es el estado normal. Un push se aprueba igual, con la línea `push <remoto> <rama>`
36
+ // tal cual y sin patrones: `feat/*` convertiría una aprobación puntual en un permiso (caso 103).
37
+ //
38
+ // El texto se lee aparte del archivo porque `self-approval` compara lo que se está por escribir contra lo
39
+ // que ya está en disco: con dos parsers, una línea podría contar de un lado y no del otro (caso 119).
40
+ const lines = (text) => text.split('\n').map((line) => line.replace(/#.*$/, '').trim()).filter(Boolean)
41
+
36
42
  function read(root) {
37
- let text = ''
38
- try { text = fs.readFileSync(path.join(root, 'planning', APPROVAL), 'utf8') } catch { return [] }
39
- return text.split('\n').map((line) => line.replace(/#.*$/, '').trim()).filter(Boolean)
43
+ try { return lines(fs.readFileSync(path.join(root, 'planning', APPROVAL), 'utf8')) } catch { return [] }
40
44
  }
41
45
 
42
46
  // Qué queda sin aprobar de lo que un guard está por bloquear. Se reporta sólo eso: mandar a revisar lo
43
47
  // que ya se aprobó es lo que hace que la próxima vez nadie lea el mensaje. Cuenta también lo que la
44
48
  // persona pidió en el chat.
45
- function pending(root, files, input) {
49
+ const left = (root, files) => {
46
50
  const approved = new Set(root ? read(root) : [])
47
- return CHAT.unauthorized(input, files.filter((file) => !approved.has(file)))
51
+ return files.filter((file) => !approved.has(file))
52
+ }
53
+
54
+ function pending(root, files, input) {
55
+ return CHAT.unauthorized(input, left(root, files))
56
+ }
57
+
58
+ // Lo mismo para los gates de commit —`governance`, `verify` y `dependencies`—, que preguntan cada vez: no
59
+ // conceden nada y no heredan lo que la sesión venía concediendo.
60
+ //
61
+ // Commitear está del lado de publicar y no del de leer, y por qué esa diferencia decide quién hereda está
62
+ // en `push.js`. Lo propio de un commit es que el objeto del permiso se mueve solo: lo que autoriza uno no
63
+ // dice nada del siguiente, porque el índice ya es otro. Nadie lo había decidido para los gates —heredaban
64
+ // por venir todos de `pending`—, y se midió: con otro mensaje en curso, un commit de gobernanza pasaba
65
+ // (caso 119).
66
+ function pendingNow(root, files, input) {
67
+ return CHAT.unauthorizedNow(input, left(root, files))
48
68
  }
49
69
 
50
70
  // El archivo que el guard va a leer, nombrado desde la carpeta en la que está la sesión. En sidecar la
@@ -69,16 +89,31 @@ function where(input) {
69
89
  // primero: contestar es más corto que editar un archivo, y es lo que la persona ya está haciendo. El
70
90
  // archivo queda como cosa de ella: dicho en imperativo, el agente leía «aprobalo» como una orden para él
71
91
  // e intentaba escribírselo en vez de reintentar, medido en una sesión real de Claude Code.
72
- function HOW(variable, lines, input) {
92
+ //
93
+ // `pasteable` es lo que se puede aprobar por archivo, y por defecto es todo: un guard lo angosta cuando lo
94
+ // que tiene a mano no sirve para pegar —por qué, en `secrets-shell.js` (caso 118)—. Lo frenado se anota
95
+ // igual, así que el «dale» sigue cubriendo todo.
96
+ function HOW(variable, lines, input, pasteable = lines) {
73
97
  const chat = CHAT.hold(input, lines)
74
- return (chat
98
+ const stuck = lines.filter((one) => !pasteable.includes(one))
99
+ const ask = chat
75
100
  ? 'Decile a la persona qué se frenó y por qué, y esperá: si contesta «dale», reintentá el mismo cambio y '
76
- + 'pasa. Si prefiere aprobarlo a mano, que pegue ella tal cual en'
77
- : 'Aprobalo pegando tal cual en')
78
- + ` ${where(input)} estas líneas:\n`
79
- + lines.map((line) => ` ${line}\n`).join('')
80
- + `Valen para ese conjunto y dejan de valer en cuanto cambie. La variable ${variable}=1 sigue existiendo `
81
- + 'y apaga el guard para toda la sesión, que es por lo que no es la vía recomendada.'
101
+ + 'pasa. '
102
+ : ''
103
+ const paste = pasteable.length
104
+ ? (chat ? 'Si prefiere aprobarlo a mano, que pegue ella tal cual en' : 'Aprobalo pegando tal cual en')
105
+ + ` ${where(input)} estas líneas:\n`
106
+ + pasteable.map((line) => ` ${line}\n`).join('')
107
+ + 'Valen para ese conjunto y dejan de valer en cuanto cambie. '
108
+ : ''
109
+ const unresolved = stuck.length
110
+ ? `Por archivo no hay línea que pegar para ${stuck.join(', ')}: la ruta llegó con una expansión del shell `
111
+ + 'sin resolver, y la aprobación compara texto, así que esa línea sólo valdría para un comando escrito '
112
+ + 'igual. Volvé a correrlo con la ruta escrita y el bloqueo va a decir qué pegar. '
113
+ : ''
114
+ return ask + paste + unresolved
115
+ + `La variable ${variable}=1 sigue existiendo y apaga el guard para toda la sesión, que es por lo que no `
116
+ + 'es la vía recomendada.'
82
117
  }
83
118
 
84
- module.exports = { APPROVAL, read, pending, HOW }
119
+ module.exports = { APPROVAL, lines, read, pending, pendingNow, where, HOW }
@@ -45,10 +45,41 @@ function load(session) {
45
45
  // Nombrar no es pedir: «no toques el .env» nombra el .env. Cuenta la negación que está en la misma frase y
46
46
  // antes del nombre; la coma corta, porque «leé el config, no el .env» son dos pedidos.
47
47
  const NEGATION = /(?:^|[^\p{L}])(?:no|nunca|jam[aá]s|ni|sin|not|never|don'?t)(?![\p{L}])/iu
48
- const CLAUSE = /[.,;:!?\n]/
48
+ // Un punto corta sólo si cierra la oración: el de `x.js` es parte del nombre, y cortar ahí dejaba el verbo de
49
+ // «agregá src/x.js a .ops-approval» en otra frase que la del archivo.
50
+ const CLAUSE = /[,;:!?\n]|\.(?=\s|$)/
49
51
 
50
- // Cada aparición del nombre en el texto y si va negada. Un nombre tiene que estar entero: `.env` no
51
- // aparece en «el .env.example», y un punto sólo lo cierra si termina la frase.
52
+ // Y no negarlo tampoco alcanza: «¿para qué sirven las credentials?» o «el .env tiene algo raro» nombran el
53
+ // archivo sin pedir nada (caso 109). La frase del nombre tiene que traer un verbo que pida una acción, en
54
+ // español o en inglés; se compara sin tildes y sin el pronombre pegado —«leelo», «abrime»—. La lista va a
55
+ // quedar corta, y lo que no reconoce no se pierde: se frena, y un «dale» lo aprueba.
56
+ //
57
+ // Autorizar también es pedir, y era lo que faltaba: «autorizo la lectura del .env» es la forma más
58
+ // explícita de decir que sí y se frenaba igual, porque ninguna de sus palabras estaba (caso 118). Entran
59
+ // las formas con que una persona **concede** —primera persona e imperativo— y no los sustantivos que
60
+ // nombran el acto: `lectura`, `permiso` y `autorizacion` aparecen igual en una pregunta que no autoriza
61
+ // nada, y dejarlos afuera es lo que el 109 decidió cuando dijo que nombrar no alcanza.
62
+ const ASKS = new Set(('lee leer abri abre abrir mostra muestra mostrar ensena edita editar cambia cambiar borra '
63
+ + 'borrar elimina eliminar escribi escribe escribir corre correr ejecuta ejecutar desactiva desactivar apaga '
64
+ + 'apagar reescribi reescribe reescribir agrega agregar anadi anade anadir saca sacar quita quitar actualiza '
65
+ + 'actualizar modifica modificar revisa revisar mira mirar fijate chequea verifica verificar instala instalar '
66
+ + 'subi sube subir pushea pushear commitea commitear usa usar crea crear arregla arreglar carga cargar copia '
67
+ + 'copiar toca tocar aproba aprueba aprobar habilita habilitar reemplaza reemplazar renombra renombrar mueve '
68
+ + 'mover restaura restaurar imprimi imprime imprimir deci dime proba probar '
69
+ + 'autorizo autoriza autorizar autorizado permito permiti permite permitir apruebo habilito '
70
+ + 'read open show print display edit change delete remove write run execute disable rewrite add update modify '
71
+ + 'check review inspect look cat commit push install use create fix load copy touch approve enable replace '
72
+ + 'rename move restore skip '
73
+ + 'authorize authorized allow allowed permit permitted grant granted approved').split(' '))
74
+ const ENCLITIC = /(?:selo|sela|melo|mela|telo|tela|los|las|lo|la|le|me)$/
75
+
76
+ function asks(clause) {
77
+ const words = clause.normalize('NFD').replace(/[̀-ͯ]/g, '').match(/[a-z]+/g) || []
78
+ return words.some((word) => ASKS.has(word) || ASKS.has(word.replace(ENCLITIC, '')))
79
+ }
80
+
81
+ // Cada aparición del nombre en el texto: si va negada y si su frase pide algo. Un nombre tiene que estar
82
+ // entero: `.env` no aparece en «el .env.example», y un punto sólo lo cierra si termina la frase.
52
83
  function mentions(text, item) {
53
84
  const lower = String(text).toLowerCase()
54
85
  const found = []
@@ -59,10 +90,34 @@ function mentions(text, item) {
59
90
  const rest = lower.slice(at + name.length)
60
91
  if (before && !/[\s'"`(/]/.test(before)) continue
61
92
  if (rest && !/^(?:[\s'"`),;:!?]|\.(?:\s|$)|$)/.test(rest)) continue
62
- found.push(NEGATION.test(lower.slice(0, at).split(CLAUSE).pop()))
93
+ const clause = lower.slice(0, at).split(CLAUSE).pop()
94
+ found.push({ denied: NEGATION.test(clause), asked: asks(`${clause} ${rest.split(CLAUSE)[0]}`) })
63
95
  }
64
96
  }
65
- return { named: found.includes(false), denied: found.includes(true) }
97
+ return { named: found.some((one) => one.asked && !one.denied), denied: found.some((one) => one.denied) }
98
+ }
99
+
100
+ // Una orden de publicar se lee aparte, porque `mentions` compara también el basename: para el ítem
101
+ // `push origin feat/login` eso es `login`, y «arreglá el login y no subas nada» publicaba (caso 103). Acá
102
+ // el remoto y la rama tienen que aparecer tal cual, como palabras enteras, en una frase que pida publicar
103
+ // —un verbo de publicar, no cualquiera: «revisá feat/x en origin» no pide un push— y sin una negación
104
+ // antes del último de los dos.
105
+ const PUSHES = new Set('subi sube subir pushea pushear push publica publicar publish empuja empujar'.split(' '))
106
+ function ordersPush(text, item) {
107
+ const [verb, remote, branch] = item.split(' ')
108
+ if (verb !== 'push' || !remote || !branch) return false
109
+ return String(text).split(CLAUSE).some((clause) => {
110
+ // La comilla simple se saca de los bordes y no se corta en ella: partida, «don't» dejaba de ser una
111
+ // negación.
112
+ const words = clause.split(/[\s"`()]+/)
113
+ .map((word) => word.replace(/^'+/, '').replace(/\.$/, '').replace(/'+$/, ''))
114
+ const last = Math.max(words.indexOf(remote), words.indexOf(branch))
115
+ if (words.indexOf(remote) < 0 || words.indexOf(branch) < 0) return false
116
+ const plain = clause.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
117
+ .match(/[a-z]+/g) || []
118
+ return plain.some((word) => PUSHES.has(word) || PUSHES.has(word.replace(ENCLITIC, '')))
119
+ && !NEGATION.test(words.slice(0, last + 1).join(' '))
120
+ })
66
121
  }
67
122
 
68
123
  // Quien contesta a un bloqueo que quedó pendiente. Sólo el principio del mensaje: «dale» es la respuesta
@@ -73,6 +128,10 @@ const YES = new RegExp(String.raw`^\s*(?:s[ií]|dale|ok(?:ay)?|hac[eé]lo|hazlo|
73
128
  // El hook de mensaje. Nunca frena: un mensaje de la persona no se bloquea, y sin registro los guards
74
129
  // deciden como antes. Un texto que empieza con una etiqueta no lo escribió una persona —Claude avisa así
75
130
  // que terminó un subagente, con `<task-notification>`—, y en CI no hay persona.
131
+ //
132
+ // Lo concedido es lo único que cruza de un mensaje al siguiente, y se hereda aunque este mensaje no lo
133
+ // haya escrito una persona: un aviso del runner en el medio no le quita a nadie lo que ya autorizó. La
134
+ // negación se aplica venga de donde venga, porque revocar es la dirección segura.
76
135
  function record(input) {
77
136
  try {
78
137
  if (!input.session_id) return
@@ -82,9 +141,10 @@ function record(input) {
82
141
  const approved = human && previous && YES.test(text)
83
142
  ? previous.pending.filter((item) => !mentions(text, item).denied)
84
143
  : []
144
+ const granted = previous ? (previous.granted || []).filter((one) => !mentions(text, one).denied) : []
85
145
  fs.mkdirSync(DIR, { recursive: true })
86
- fs.writeFileSync(recordPath(input.session_id),
87
- JSON.stringify({ id: idOf(input), text, human, flow: flowCommand(text), approved, pending: [] }))
146
+ fs.writeFileSync(recordPath(input.session_id), JSON.stringify(
147
+ { id: idOf(input), text, human, flow: flowCommand(text), approved, granted, pending: [] }))
88
148
  } catch { /* registrar es un extra: si falla, los guards siguen frenando lo que frenaban */ }
89
149
  }
90
150
 
@@ -99,12 +159,63 @@ function said(input) {
99
159
  return current && saved.id && current !== saved.id ? null : saved
100
160
  }
101
161
 
102
- // Lo que la persona no autorizó de lo que un guard está por frenar: ni lo nombró en su mensaje ni lo
103
- // aprobó contestando.
162
+ // Con qué autorización pasa un ítem, o vacío si no pasa: lo pidió este mensaje, un «dale» aprobó lo que
163
+ // había quedado frenado, o se lo concedieron antes en esta sesión. Qué cuenta como pedirlo depende de qué
164
+ // se frena: un archivo se nombra, un push se ordena con su remoto y su rama.
165
+ const named = (text, item) => mentions(text, item).named
166
+ function why(saved, item, asked, inherit) {
167
+ if (asked(saved.text, item)) return 'orden'
168
+ if (saved.approved.includes(item)) return 'dale'
169
+ if (!inherit) return ''
170
+ return (saved.granted || []).includes(item) ? 'concedido' : ''
171
+ }
172
+
173
+ // Lo que un guard dejó pasar queda anotado, que es la contracara de `hold`: hasta 0.82.0 sólo se anotaba
174
+ // lo frenado, así que una autorización usada moría con el mensaje y lo mismo se frenaba una y otra vez.
175
+ // Ahí la salida barata era apagar el guard para toda la sesión con una variable, o sea el permiso más
176
+ // ancho de los dos (caso 116).
177
+ //
178
+ // Se anota el ítem **como el guard lo nombró** —la ruta en la forma que ese guard tiene a mano— y no el
179
+ // archivo que hay detrás: es el mismo alcance que tiene una línea de `.ops-approval`, angosto de más
180
+ // antes que de menos.
181
+ function grant(input, saved, items) {
182
+ const before = saved.granted || []
183
+ const granted = [...new Set([...before, ...items])]
184
+ if (granted.length === before.length) return
185
+ try {
186
+ saved.granted = granted
187
+ fs.writeFileSync(recordPath(input.session_id), JSON.stringify(saved))
188
+ } catch { /* sin anotarlo, se vuelve a pedir */ }
189
+ }
190
+
191
+ // Con qué autorización pasa cada uno de los que pasan. Lo pregunta quien necesita el porqué y no sólo el
192
+ // qué —el rastro de un push lo anota (caso 112)—, y no concede nada: preguntar no cambia qué va a valer
193
+ // en el mensaje siguiente.
194
+ //
195
+ // Conceder y heredar son dos cosas distintas y hasta 0.83.0 se movían juntas: conceder es escribir en el
196
+ // registro, heredar es leer lo que escribió un mensaje anterior. Quien decide algo que vuelve a tener
197
+ // consecuencia cada vez que ocurre apaga lo segundo con `inherit: false`, y ahí vale lo que la persona pidió
198
+ // en el mensaje en curso o aprobó con un «dale» (caso 119).
199
+ function authorized(input, items, { asked = named, inherit = true } = {}) {
200
+ const saved = said(input)
201
+ if (!saved) return []
202
+ return items.map((item) => ({ item, via: why(saved, item, asked, inherit) })).filter((one) => one.via)
203
+ }
204
+
205
+ // Lo que la persona no autorizó de lo que un guard está por frenar; lo que sí, queda concedido.
104
206
  function unauthorized(input, items) {
105
207
  const saved = said(input)
106
208
  if (!saved) return items
107
- return items.filter((item) => !saved.approved.includes(item) && !mentions(saved.text, item).named)
209
+ const passed = items.filter((item) => why(saved, item, named, true))
210
+ grant(input, saved, passed)
211
+ return items.filter((item) => !passed.includes(item))
212
+ }
213
+
214
+ // Lo mismo sin conceder y sin heredar: lo que no está en el mensaje en curso queda pendiente aunque la
215
+ // sesión lo haya dejado pasar antes.
216
+ function unauthorizedNow(input, items) {
217
+ const cleared = new Set(authorized(input, items, { inherit: false }).map((one) => one.item))
218
+ return items.filter((item) => !cleared.has(item))
108
219
  }
109
220
 
110
221
  // Lo que quedó frenado, para que un «dale» en el mensaje siguiente apruebe exactamente eso y nada más.
@@ -119,4 +230,4 @@ function hold(input, items) {
119
230
  } catch { return false }
120
231
  }
121
232
 
122
- module.exports = { DIR, record, said, unauthorized, hold }
233
+ module.exports = { DIR, record, said, authorized, unauthorized, unauthorizedNow, hold, ordersPush }
@@ -183,9 +183,6 @@ function stagedFiles(dir) {
183
183
  return result.stdout.trim().split('\n').filter(Boolean)
184
184
  }
185
185
 
186
- // R10 pide «la autorización configurada para el proyecto» y `runner.allowPush` es esa configuración:
187
- // sin esto era un interruptor que nadie leía, y un cargo que lo leyó dio por imposible un push que el
188
- // guard bloqueaba igual. Sin raíz legible no hay permiso que verificar, así que no se autoriza.
189
186
  // El índice que un hook de pre-ejecución lee es el de **antes** del comando, y el comando puede ser
190
187
  // justamente el que lo llene. Ahí los tres guards que juzgan mirando el índice no fallan: leen bien,
191
188
  // encuentran cero archivos y concluyen que no hay nada que revisar.
@@ -212,13 +209,6 @@ function stagedForCommit(command, cwd) {
212
209
  return { dir, staged: stagedFiles(dir) }
213
210
  }
214
211
 
215
- function pushAllowed(input) {
216
- const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
217
- if (!root) return false
218
- const runner = configOf(root).runner
219
- return Boolean(runner && runner.allowPush === true)
220
- }
221
-
222
212
  function findOpsRoot(start) {
223
213
  let current = path.resolve(start)
224
214
  while (true) {
@@ -270,7 +260,7 @@ function opsRoot(input) {
270
260
 
271
261
  module.exports = {
272
262
  readInput, commandOf, patchOf, filesOf, contentOf, cwdOf, block, configOf,
273
- gitDirectory, isCommit, withoutGitGlobals, stagedFiles, stagedForCommit, pushAllowed,
263
+ gitDirectory, isCommit, withoutGitGlobals, stagedFiles, stagedForCommit,
274
264
  findOpsRoot, opsRoot,
275
265
  writableRoots, outsideRoots, DECLARE_IT, unquoted,
276
266
  }