@ingeniomaps/cauce 0.81.0 → 0.82.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 (35) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/automatization/hooks/README.md +2 -2
  3. package/automatization/runners/antigravity/rules/cauce.md +7 -2
  4. package/automatization/runners/claude/CLAUDE.md +1 -4
  5. package/automatization/runners/codex/AGENTS.md +7 -3
  6. package/automatization/runners/gemini/GEMINI.md +1 -4
  7. package/automatization/shared/inbox.js +28 -0
  8. package/automatization/workflows/agent-eval.js +1 -1
  9. package/automatization/workflows/autobuild.js +52 -18
  10. package/automatization/workflows/flow.js +33 -8
  11. package/automatization/workflows/onboard.js +25 -3
  12. package/engine/automation/index.js +19 -4
  13. package/engine/automation/rules.js +122 -0
  14. package/engine/automation/runners.js +3 -1
  15. package/engine/cli/instance.js +35 -6
  16. package/engine/cli/planning.js +16 -2
  17. package/engine/config/validate.js +53 -3
  18. package/engine/core/onboarding.js +37 -7
  19. package/engine/core/ownership.js +64 -1
  20. package/engine/core/scan.js +23 -9
  21. package/engine/hooks/approval.js +3 -2
  22. package/engine/hooks/chat.js +59 -10
  23. package/engine/hooks/input.js +1 -11
  24. package/engine/hooks/push.js +147 -0
  25. package/engine/hooks/shell.js +7 -5
  26. package/engine/integrations/registry.js +13 -1
  27. package/engine/planning/inbox.js +36 -0
  28. package/engine/planning/parser.js +22 -9
  29. package/engine/planning/recurring.js +13 -2
  30. package/engine/schemas/ops-config.schema.json +21 -0
  31. package/package.json +1 -1
  32. package/template/AGENTS.md +10 -4
  33. package/template/planning/INBOX.md +2 -1
  34. package/template/planning/RECURRING.md +6 -5
  35. package/template/planning/rules/system/commits.md +3 -1
@@ -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,7 +32,8 @@ 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).
36
37
  function read(root) {
37
38
  let text = ''
38
39
  try { text = fs.readFileSync(path.join(root, 'planning', APPROVAL), 'utf8') } catch { return [] }
@@ -81,4 +82,4 @@ function HOW(variable, lines, input) {
81
82
  + 'y apaga el guard para toda la sesión, que es por lo que no es la vía recomendada.'
82
83
  }
83
84
 
84
- module.exports = { APPROVAL, read, pending, HOW }
85
+ module.exports = { APPROVAL, read, pending, where, HOW }
@@ -45,10 +45,33 @@ 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
+ const ASKS = new Set(('lee leer abri abre abrir mostra muestra mostrar ensena edita editar cambia cambiar borra '
57
+ + 'borrar elimina eliminar escribi escribe escribir corre correr ejecuta ejecutar desactiva desactivar apaga '
58
+ + 'apagar reescribi reescribe reescribir agrega agregar anadi anade anadir saca sacar quita quitar actualiza '
59
+ + 'actualizar modifica modificar revisa revisar mira mirar fijate chequea verifica verificar instala instalar '
60
+ + 'subi sube subir pushea pushear commitea commitear usa usar crea crear arregla arreglar carga cargar copia '
61
+ + 'copiar toca tocar aproba aprueba aprobar habilita habilitar reemplaza reemplazar renombra renombrar mueve '
62
+ + 'mover restaura restaurar imprimi imprime imprimir deci dime proba probar '
63
+ + 'read open show print display edit change delete remove write run execute disable rewrite add update modify '
64
+ + 'check review inspect look cat commit push install use create fix load copy touch approve enable replace '
65
+ + 'rename move restore skip').split(' '))
66
+ const ENCLITIC = /(?:selo|sela|melo|mela|telo|tela|los|las|lo|la|le|me)$/
67
+
68
+ function asks(clause) {
69
+ const words = clause.normalize('NFD').replace(/[̀-ͯ]/g, '').match(/[a-z]+/g) || []
70
+ return words.some((word) => ASKS.has(word) || ASKS.has(word.replace(ENCLITIC, '')))
71
+ }
72
+
73
+ // Cada aparición del nombre en el texto: si va negada y si su frase pide algo. Un nombre tiene que estar
74
+ // entero: `.env` no aparece en «el .env.example», y un punto sólo lo cierra si termina la frase.
52
75
  function mentions(text, item) {
53
76
  const lower = String(text).toLowerCase()
54
77
  const found = []
@@ -59,10 +82,34 @@ function mentions(text, item) {
59
82
  const rest = lower.slice(at + name.length)
60
83
  if (before && !/[\s'"`(/]/.test(before)) continue
61
84
  if (rest && !/^(?:[\s'"`),;:!?]|\.(?:\s|$)|$)/.test(rest)) continue
62
- found.push(NEGATION.test(lower.slice(0, at).split(CLAUSE).pop()))
85
+ const clause = lower.slice(0, at).split(CLAUSE).pop()
86
+ found.push({ denied: NEGATION.test(clause), asked: asks(`${clause} ${rest.split(CLAUSE)[0]}`) })
63
87
  }
64
88
  }
65
- return { named: found.includes(false), denied: found.includes(true) }
89
+ return { named: found.some((one) => one.asked && !one.denied), denied: found.some((one) => one.denied) }
90
+ }
91
+
92
+ // Una orden de publicar se lee aparte, porque `mentions` compara también el basename: para el ítem
93
+ // `push origin feat/login` eso es `login`, y «arreglá el login y no subas nada» publicaba (caso 103). Acá
94
+ // el remoto y la rama tienen que aparecer tal cual, como palabras enteras, en una frase que pida publicar
95
+ // —un verbo de publicar, no cualquiera: «revisá feat/x en origin» no pide un push— y sin una negación
96
+ // antes del último de los dos.
97
+ const PUSHES = new Set('subi sube subir pushea pushear push publica publicar publish empuja empujar'.split(' '))
98
+ function ordersPush(text, item) {
99
+ const [verb, remote, branch] = item.split(' ')
100
+ if (verb !== 'push' || !remote || !branch) return false
101
+ return String(text).split(CLAUSE).some((clause) => {
102
+ // La comilla simple se saca de los bordes y no se corta en ella: partida, «don't» dejaba de ser una
103
+ // negación.
104
+ const words = clause.split(/[\s"`()]+/)
105
+ .map((word) => word.replace(/^'+/, '').replace(/\.$/, '').replace(/'+$/, ''))
106
+ const last = Math.max(words.indexOf(remote), words.indexOf(branch))
107
+ if (words.indexOf(remote) < 0 || words.indexOf(branch) < 0) return false
108
+ const plain = clause.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
109
+ .match(/[a-z]+/g) || []
110
+ return plain.some((word) => PUSHES.has(word) || PUSHES.has(word.replace(ENCLITIC, '')))
111
+ && !NEGATION.test(words.slice(0, last + 1).join(' '))
112
+ })
66
113
  }
67
114
 
68
115
  // Quien contesta a un bloqueo que quedó pendiente. Sólo el principio del mensaje: «dale» es la respuesta
@@ -99,12 +146,14 @@ function said(input) {
99
146
  return current && saved.id && current !== saved.id ? null : saved
100
147
  }
101
148
 
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.
104
- function unauthorized(input, items) {
149
+ // Lo que la persona no autorizó de lo que un guard está por frenar: ni lo pidió en su mensaje ni lo
150
+ // aprobó contestando. Qué cuenta como pedirlo depende de qué se frena: un archivo se nombra, un push se
151
+ // ordena con su remoto y su rama.
152
+ const named = (text, item) => mentions(text, item).named
153
+ function unauthorized(input, items, asked = named) {
105
154
  const saved = said(input)
106
155
  if (!saved) return items
107
- return items.filter((item) => !saved.approved.includes(item) && !mentions(saved.text, item).named)
156
+ return items.filter((item) => !saved.approved.includes(item) && !asked(saved.text, item))
108
157
  }
109
158
 
110
159
  // Lo que quedó frenado, para que un «dale» en el mensaje siguiente apruebe exactamente eso y nada más.
@@ -119,4 +168,4 @@ function hold(input, items) {
119
168
  } catch { return false }
120
169
  }
121
170
 
122
- module.exports = { DIR, record, said, unauthorized, hold }
171
+ module.exports = { DIR, record, said, unauthorized, 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
  }
@@ -0,0 +1,147 @@
1
+ 'use strict'
2
+
3
+ // Si un `git push` se publica, y con qué autorización. R10 pide «la autorización configurada para el
4
+ // proyecto», y hasta 0.81.0 era un solo interruptor: `runner.allowPush` dejaba pasar cualquier push —a la
5
+ // rama viva, y de un subagente igual que de la persona— y, apagado, frenaba también el que la persona
6
+ // acababa de pedir con todas las letras (casos 103 y 108).
7
+ //
8
+ // Lo que garantiza, en orden:
9
+ //
10
+ // - Un subagente no publica, con ningún permiso: es trabajo que el agente delegó, dos pasos más lejos de
11
+ // la persona que la sesión. El push sale de la sesión principal.
12
+ // - Una rama viva —`main`, `master` y la rama por defecto de cada remoto— no la alcanza ni `allowPush` ni
13
+ // una orden del chat si el proyecto no la nombró en `runner.pushToLiveBranches`. La línea exacta en
14
+ // `.ops-approval` sí la alcanza: la escribe una persona a mano y los guards de límites no dejan que el
15
+ // agente se la escriba.
16
+ // - A una rama de trabajo, o a una viva ya nombrada, la alcanzan `allowPush`, la línea exacta en
17
+ // `.ops-approval`, un mensaje de la persona que ordena ese push con su remoto y su rama, o un «dale» al
18
+ // push que quedó frenado. Qué cuenta como orden lo decide `chat.js`.
19
+ //
20
+ // El `--force` no llega hasta acá: lo frena `destructive` antes, sin override (R8).
21
+
22
+ const { spawnSync } = require('node:child_process')
23
+ const { block, cwdOf, gitDirectory, opsRoot, configOf } = require('./input')
24
+ const AP = require('./approval')
25
+ const CHAT = require('./chat')
26
+
27
+ // Lo que va entre `git push` y el fin del comando. El salto de línea corta igual que `;`, por lo que
28
+ // `destructive` explica en MISMO.
29
+ const PUSH = /\bgit\s+push\b([^;&|\n]*)/g
30
+ // Las banderas que llevan su valor en la palabra siguiente: sin saltearlo, el valor se leía como remoto.
31
+ const VALUED = new Set(['-o', '--push-option', '--repo', '--receive-pack', '--exec'])
32
+ // Publican todas las ramas, así que entre ellas la viva: no hay una rama que nombrar ni que aprobar.
33
+ const EVERY = new Set(['--all', '--branches', '--mirror'])
34
+
35
+ // Lectura local e inocua del repositorio: ninguna de estas consultas habla con el remoto. Si falla, no
36
+ // hay dato, y quien pregunta decide sin él.
37
+ function git(dir, args) {
38
+ const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' })
39
+ return result.status === 0 ? result.stdout.trim() : ''
40
+ }
41
+
42
+ // La rama por defecto de un remoto es la que git anota en `refs/remotes/<remoto>/HEAD`: la escribe el
43
+ // `clone` y la cambia `git remote set-head`, así que el proyecto la declara sin un campo nuevo. `main` y
44
+ // `master` cuentan siempre, porque un remoto agregado a mano no la anota.
45
+ function liveBranches(dir) {
46
+ const live = new Set(['main', 'master'])
47
+ const refs = git(dir, ['for-each-ref', '--format=%(refname)%09%(symref)', 'refs/remotes/'])
48
+ for (const line of refs.split('\n')) {
49
+ const [name, target] = line.split('\t')
50
+ if (!target || !name.endsWith('/HEAD')) continue
51
+ live.add(target.slice(name.length - 'HEAD'.length))
52
+ }
53
+ return live
54
+ }
55
+
56
+ // A dónde publicaría un `git push` sin rama: lo que git resuelve como `@{push}`, que ya aplica
57
+ // push.default y el remoto de publicación que declare la rama. Sin upstream no resuelve, y git mismo
58
+ // tampoco sabría a dónde ir.
59
+ function pushTarget(dir, current) {
60
+ if (!current) return null
61
+ const remote = git(dir, ['for-each-ref', '--format=%(push:remotename)', `refs/heads/${current}`])
62
+ const full = git(dir, ['rev-parse', '--symbolic-full-name', '@{push}'])
63
+ const prefix = `refs/remotes/${remote}/`
64
+ return remote && full.startsWith(prefix) ? { remote, branch: full.slice(prefix.length) } : null
65
+ }
66
+
67
+ // Los destinos de un push: `{ item, remote, branch }` con el ítem que se aprueba, `push <remoto> <rama>`.
68
+ // El que no se puede resolver lleva por ítem el comando mismo, que no nombra ninguna rama: lo aprueba un
69
+ // «dale» a ese bloqueo y nada en `.ops-approval`.
70
+ function destinationsOf(args, dir) {
71
+ const words = args.trim().split(/\s+/).filter(Boolean).map((word) => word.replace(/^['"]|['"]$/g, ''))
72
+ const positional = []
73
+ const found = []
74
+ for (let at = 0; at < words.length; at += 1) {
75
+ if (!words[at].startsWith('-')) positional.push(words[at])
76
+ else if (EVERY.has(words[at])) found.push({ every: true })
77
+ else if (VALUED.has(words[at])) at += 1
78
+ else if (words[at] === '--tags') found.push({ tags: true })
79
+ }
80
+ const current = git(dir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
81
+ const target = pushTarget(dir, current)
82
+ const [remote, ...refspecs] = positional
83
+ const unknown = { item: 'git push', branch: current }
84
+ const one = (to, branch) => (branch ? { item: `push ${to} ${branch}`, remote: to, branch } : unknown)
85
+ const named = refspecs.map((spec) => {
86
+ const destination = spec.replace(/^\+/, '').split(':').pop().replace(/^refs\/heads\//, '')
87
+ return one(remote, destination === 'HEAD' ? current : destination)
88
+ })
89
+ const rest = found.map((flag) => (flag.every ? { every: true }
90
+ : one(remote || (target && target.remote) || 'origin', '--tags')))
91
+ if (named.length || found.length) return [...named, ...rest]
92
+ if (remote) return [one(remote, target && target.remote === remote ? target.branch : current)]
93
+ return [target ? one(target.remote, target.branch) : unknown]
94
+ }
95
+
96
+ const SUBAGENT = "'git push' desde un subagente no se publica, con ningún permiso: publicar lo decide una "
97
+ + 'persona (R10), y un subagente es trabajo que el agente delegó. Devolvé el resultado a la sesión '
98
+ + 'principal, que es la que publica.'
99
+
100
+ function liveMessage(live, input) {
101
+ const lines = live.filter((to) => to.remote).map((to) => to.item)
102
+ const names = [...new Set(live.map((to) => (to.every ? 'todas las ramas' : to.branch)))].join(', ')
103
+ // Le habla al agente y deja el permiso como cosa de la persona, por lo mismo que `AP.HOW`: medido en
104
+ // una sesión real, con «pegando tal cual» a secas el agente se ofreció a escribirse la aprobación.
105
+ return `'git push' publica cambios en ${names}, la rama viva, y requiere una acción humana: ni `
106
+ + 'runner.allowPush ni una orden en el chat llegan ahí sin un permiso por rama. Decile a la persona qué '
107
+ + 'se frenó y esperá: un «dale» no lo destraba. Lo da ella, nombrando la rama en '
108
+ + 'runner.pushToLiveBranches de ops.config.json, que la deja como una rama de trabajo'
109
+ + (lines.length ? `, o pegando ella tal cual en ${AP.where(input)} estas líneas:\n`
110
+ + lines.map((line) => ` ${line}\n`).join('') : '.')
111
+ }
112
+
113
+ function workMessage(items, input) {
114
+ const lines = items.filter((item) => item.startsWith('push '))
115
+ const chat = CHAT.hold(input, items)
116
+ const how = chat
117
+ ? 'Decile a la persona qué se frenó y esperá: si contesta «dale», reintentá el mismo push y pasa. '
118
+ + 'También pasa si lo pide nombrando el remoto y la rama, como «subí feat/x a origin».'
119
+ : 'Lo destraba una persona pidiéndolo en el chat con el remoto y la rama.'
120
+ const paste = lines.length
121
+ ? `\nSi prefiere aprobarlo a mano, que pegue ella tal cual en ${AP.where(input)} estas líneas:\n`
122
+ + lines.map((line) => ` ${line}\n`).join('')
123
+ : ' Sin remoto y rama que se puedan leer del comando no hay línea que aprobar: nombralos.\n'
124
+ return `'git push' publica cambios y requiere una acción humana. ${how}${paste}`
125
+ + 'El permiso permanente es runner.allowPush en ops.config.json, y lo decide una persona.'
126
+ }
127
+
128
+ function publish(input, command) {
129
+ const pushes = [...command.matchAll(PUSH)]
130
+ if (!pushes.length) return
131
+ if (input.agent_id) block(SUBAGENT)
132
+ const dir = gitDirectory(command, cwdOf(input))
133
+ const root = opsRoot(input)
134
+ const runner = (root && configOf(root).runner) || {}
135
+ const listed = new Set(Array.isArray(runner.pushToLiveBranches) ? runner.pushToLiveBranches : [])
136
+ const filed = new Set(root ? AP.read(root) : [])
137
+ const live = liveBranches(dir)
138
+ const left = pushes.flatMap((match) => destinationsOf(match[1], dir))
139
+ .filter((to) => !(to.item && to.item.startsWith('push ') && filed.has(to.item)))
140
+ const unlisted = left.filter((to) => to.every || (live.has(to.branch) && !listed.has(to.branch)))
141
+ if (unlisted.length) block(liveMessage(unlisted, input))
142
+ if (runner.allowPush === true) return
143
+ const unordered = CHAT.unauthorized(input, [...new Set(left.map((to) => to.item))], CHAT.ordersPush)
144
+ if (unordered.length) block(workMessage(unordered, input))
145
+ }
146
+
147
+ module.exports = { publish }
@@ -9,10 +9,11 @@ const os = require('node:os')
9
9
  const path = require('node:path')
10
10
  const { spawnSync } = require('node:child_process')
11
11
  const {
12
- commandOf, cwdOf, block, isCommit, stagedForCommit, pushAllowed,
12
+ commandOf, cwdOf, block, isCommit, stagedForCommit,
13
13
  writableRoots, outsideRoots, DECLARE_IT, unquoted, opsRoot, withoutGitGlobals,
14
14
  } = require('./input')
15
15
  const AP = require('./approval')
16
+ const { publish } = require('./push')
16
17
  const { selfApproval } = require('./self-approval')
17
18
  const EV = require('../core/evidence')
18
19
 
@@ -64,14 +65,15 @@ function destructive(input) {
64
65
  // matchea igual las dos formas, así que `allowPush` habilitaba el force-push sin que nadie lo decidiera
65
66
  // y el párrafo de autonomía de `AGENTS.md` tenía que confesarlo. R8 prohíbe `force` sin excepción
66
67
  // configurable, así que esta rama va antes del permiso y no lo consulta.
67
- if (new RegExp(String.raw`\bgit\s+push\b${MISMO}*\s(?:-f|--force(?:-with-lease|-if-includes)?)\b`)
68
+ //
69
+ // El `+` delante de una rama es el mismo force escrito en el refspec —`git push origin +main`—, y
70
+ // pasaba como un push normal: la regla miraba sólo las banderas (caso 103).
71
+ if (new RegExp(String.raw`\bgit\s+push\b${MISMO}*\s(?:(?:-f|--force(?:-with-lease|-if-includes)?)\b|\+\S)`)
68
72
  .test(command)) {
69
73
  block("'git push --force' reescribe historia ya publicada. R8 lo prohíbe y runner.allowPush no lo "
70
74
  + 'habilita: publicá con un push normal, o registrá una acción humana.')
71
75
  }
72
- if (/\bgit\s+push\b/.test(command) && !pushAllowed(input)) {
73
- block("'git push' publica cambios y requiere una acción humana. Se habilita con runner.allowPush.")
74
- }
76
+ publish(input, command)
75
77
  const rules = [
76
78
  [/\bgit\s+reset\s+--hard\b/, "'git reset --hard' destruye cambios locales."],
77
79
  // R8 lo prohíbe sin excepción configurable y ningún guard lo miraba: `grep -rn amend engine/hooks/`
@@ -64,11 +64,22 @@ function adapter(root, name, entry = {}) {
64
64
  return impl
65
65
  }
66
66
 
67
+ // Qué nombre tiene forma de secreto. Es una sola regla para los tres que la necesitan: la configuración
68
+ // de una integración, la declaración de secretos y el aviso de credenciales sin dueño de «ops check».
69
+ // La clave cuenta sólo como palabra propia —API_KEY sí, projectKey no—, porque el campo de Jira con la
70
+ // clave del proyecto está en instancias reales y rechazarlo les rompería la validación (caso 102, donde
71
+ // está la medición).
72
+ const SENSITIVE = /(password|secret|token|authorization|cookie|dsn|credentials?|(?:^|_)key)$/i
73
+
74
+ function sensitiveKey(key) {
75
+ return SENSITIVE.test(key)
76
+ }
77
+
67
78
  function sensitivePath(value, trail = '') {
68
79
  if (!value || typeof value !== 'object') return ''
69
80
  for (const [key, child] of Object.entries(value)) {
70
81
  const next = trail ? `${trail}.${key}` : key
71
- if (/(password|secret|token|authorization|cookie)$/i.test(key) && !/Env$/i.test(key)) return next
82
+ if (sensitiveKey(key)) return next
72
83
  const nested = sensitivePath(child, next)
73
84
  if (nested) return nested
74
85
  }
@@ -442,6 +453,7 @@ module.exports = {
442
453
  providerConfig,
443
454
  reconcile,
444
455
  safeSegment,
456
+ sensitiveKey,
445
457
  sensitivePath,
446
458
  sync,
447
459
  validate,
@@ -0,0 +1,36 @@
1
+ 'use strict'
2
+
3
+ // Lo que `check` dice sobre el INBOX. Son advertencias y nunca errores: el INBOX es de la persona, y un
4
+ // `check` rojo por lo que tiene adentro la frenaría a ella por lo que escribió un recorrido (caso 101).
5
+
6
+ const path = require('node:path')
7
+ const P = require('./parser')
8
+
9
+ const FILE = 'INBOX.md'
10
+ // Un archivo que todavía se recorre de una sentada; el molde vacío ocupa menos de treinta. La instancia
11
+ // lo cambia en `inbox.warnLines`.
12
+ const WARN_LINES = 300
13
+
14
+ function warnings(root, done, config) {
15
+ const text = P.read(path.join(root, FILE))
16
+ if (!text.trim()) return []
17
+ const found = []
18
+ const declared = config && config.inbox && config.inbox.warnLines
19
+ const limit = Number.isInteger(declared) && declared > 0 ? declared : WARN_LINES
20
+ const count = text.replace(/\n$/, '').split('\n').length
21
+ if (count > limit) {
22
+ found.push(`${FILE}: ${count} líneas, más que el umbral de ${limit} (inbox.warnLines en ops.config.json); `
23
+ + 'recorrelo y borrá lo que ya se decidió')
24
+ }
25
+ // Una entrada que se llama como una tarea cerrada probablemente se promovió y nadie la borró. Es un
26
+ // indicio y no una prueba —el molde no obliga a que la tarea conserve el nombre del ítem—, y por eso
27
+ // avisa en vez de fallar (caso 106).
28
+ for (const name of Object.values(P.inboxHeads(root)).flat()) {
29
+ if (done.set.has(name)) {
30
+ found.push(`${FILE}: **${name}** se llama como done/${name}.md; si ya se promovió, borrala`)
31
+ }
32
+ }
33
+ return found
34
+ }
35
+
36
+ module.exports = { warnings }
@@ -405,18 +405,31 @@ function readWips(dir) {
405
405
  // La plantilla no traía ningún ejemplo, así que quien escribía viñetas planas veía cero ítems sobre un
406
406
  // archivo con doce y nada se lo decía. `skipped` es lo que vuelve visible esa diferencia.
407
407
  function readInbox(dir) {
408
- const result = { deuda: 0, ideas: 0, propuestas: 0, lecciones: 0, skipped: 0 }
408
+ const { heads, skipped } = inboxSections(dir)
409
+ return { ...Object.fromEntries(Object.entries(heads).map(([key, names]) => [key, names.length])), skipped }
410
+ }
411
+
412
+ // Los nombres en negrita de cada sección, que es lo que un recorrido necesita para no volver a escribir
413
+ // lo que ya está: el nombre y no la entrada, porque pasar el archivo entero a cada tarea cuesta lo que
414
+ // el INBOX pesa (caso 101).
415
+ function inboxHeads(dir) {
416
+ return inboxSections(dir).heads
417
+ }
418
+
419
+ function inboxSections(dir) {
420
+ const heads = { deuda: [], ideas: [], propuestas: [], lecciones: [] }
421
+ let skipped = 0
409
422
  for (const part of read(path.join(dir, 'INBOX.md')).split(/^##\s+/m)) {
410
423
  const title = part.split('\n')[0]
411
424
  const bullets = (part.match(/^[-*]\s+(?:\[[ xX]\]\s+)?/gm) || []).length
412
- const count = (part.match(/^[-*]\s+(?:\[[ xX]\]\s+)?\*\*/gm) || []).length
413
- if (/Deuda|Ideas|Visi[oó]n|Propuestas|Lecciones/i.test(title)) result.skipped += bullets - count
414
- if (/Deuda/i.test(title)) result.deuda = count
415
- if (/Ideas|Visi[oó]n/i.test(title)) result.ideas = count
416
- if (/Propuestas/i.test(title)) result.propuestas = count
417
- if (/Lecciones/i.test(title)) result.lecciones = count
425
+ const names = [...part.matchAll(/^[-*]\s+(?:\[[ xX]\]\s+)?\*\*([^*\n]*)/gm)].map((hit) => hit[1].trim())
426
+ if (/Deuda|Ideas|Visi[oó]n|Propuestas|Lecciones/i.test(title)) skipped += bullets - names.length
427
+ if (/Deuda/i.test(title)) heads.deuda = names
428
+ if (/Ideas|Visi[oó]n/i.test(title)) heads.ideas = names
429
+ if (/Propuestas/i.test(title)) heads.propuestas = names
430
+ if (/Lecciones/i.test(title)) heads.lecciones = names
418
431
  }
419
- return result
432
+ return { heads, skipped }
420
433
  }
421
434
 
422
435
  module.exports = {
@@ -424,5 +437,5 @@ module.exports = {
424
437
  TASK_LINE, TASK_LINE_ANY_LANE,
425
438
  read, section, withoutComments, frontmatter, readEpics, readBacklog, readDone, readWip, readWips, wipName,
426
439
  acceptanceConditions, tableRows, taskFromLine,
427
- readInbox, readHumanActions,
440
+ readInbox, inboxHeads, readHumanActions,
428
441
  }
@@ -19,6 +19,10 @@ const CADENCES = { mensual: 1, trimestral: 3, semestral: 6, anual: 12 }
19
19
 
20
20
  const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
21
21
  const DATE = /^\d{4}-\d{2}-\d{2}$/
22
+ // La fila que el molde trae activa escribe su `Desde` como marcador, porque la fecha depende del día en
23
+ // que se crea la instancia y la reemplazan `init` y `upgrade`. Sin resolver sólo existe en el molde
24
+ // mismo —el que `npm run check` valida en el toolkit—, y ahí no vence: `status` la descarta por fecha.
25
+ const PLACEHOLDER = /^\{\{[A-Z_]+\}\}$/
22
26
  // `- **qué** AAAA-MM-DD — razón`. El nombre en negrita adelante es la misma convención del INBOX, y por
23
27
  // el mismo motivo: es con lo que se cita la fila desde otro lado.
24
28
  const POSTPONEMENT = /^-\s+\*\*([^*]+)\*\*\s+(\S+)\s+[—-]\s+(.+)$/
@@ -77,7 +81,7 @@ function validate({ exists, rows, postponements }) {
77
81
  if (!CADENCES[row.cadence]) {
78
82
  errors.push(`${at}: cadencia "${row.cadence}" fuera de ${Object.keys(CADENCES).join(' | ')}`)
79
83
  }
80
- if (!DATE.test(row.since)) errors.push(`${at}: Desde debe ser AAAA-MM-DD`)
84
+ if (!DATE.test(row.since) && !PLACEHOLDER.test(row.since)) errors.push(`${at}: Desde debe ser AAAA-MM-DD`)
81
85
  // Se juzga la línea armada y no la celda suelta: lo que se promueve es esa línea, y quien la va a
82
86
  // leer es el mismo lector de BACKLOG. Una celda que pasa acá y una línea que BACKLOG rechaza es el
83
87
  // error que aparece un mes después, con la tarea ya pegada.
@@ -145,4 +149,11 @@ function warnings(state) {
145
149
  return lines
146
150
  }
147
151
 
148
- module.exports = { FILE, read, validate, status, warnings, taskLine }
152
+ // Los marcadores de fecha del molde, resueltos para una instancia que nace hoy. `Desde` es la primera
153
+ // fecha de vencimiento y no el día en que se declara la fila, así que va un período después: con la
154
+ // fecha de hoy la fila nacía «vence hoy» (caso 106). Trimestral es la cadencia de la fila del molde.
155
+ function sinceValues(today) {
156
+ return { '{{INBOX_SINCE}}': addMonths(today, CADENCES.trimestral) }
157
+ }
158
+
159
+ module.exports = { FILE, read, validate, status, warnings, taskLine, sinceValues }