@ingeniomaps/cauce 0.98.0 → 0.99.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/agents/roles/system/ai-product-manager/learning/HISTORY.md +1 -0
  3. package/agents/roles/system/analytics-engineer/learning/HISTORY.md +1 -0
  4. package/agents/roles/system/backend-engineer/learning/HISTORY.md +1 -0
  5. package/agents/roles/system/cloud-architect/learning/HISTORY.md +1 -0
  6. package/agents/roles/system/customer-success-manager/learning/HISTORY.md +1 -0
  7. package/agents/roles/system/data-engineer/learning/HISTORY.md +1 -0
  8. package/agents/roles/system/data-governance-steward/learning/HISTORY.md +1 -0
  9. package/agents/roles/system/devops-engineer/learning/HISTORY.md +1 -0
  10. package/agents/roles/system/engineering-manager/learning/HISTORY.md +1 -0
  11. package/agents/roles/system/engineering-manager/learning/sources.yaml +1 -1
  12. package/agents/roles/system/engineering-manager/references/operating-model.md +1 -1
  13. package/agents/roles/system/machine-learning-engineer/learning/HISTORY.md +1 -0
  14. package/agents/roles/system/mlops-engineer/learning/HISTORY.md +1 -0
  15. package/agents/roles/system/mobile-engineer/learning/HISTORY.md +1 -0
  16. package/agents/roles/system/product-manager/learning/HISTORY.md +1 -0
  17. package/agents/roles/system/site-reliability-engineer/learning/HISTORY.md +1 -0
  18. package/agents/roles/system/software-architect/learning/HISTORY.md +1 -0
  19. package/agents/roles/system/ui-designer/learning/HISTORY.md +1 -0
  20. package/automatization/hooks/README.md +6 -2
  21. package/automatization/runners/antigravity/README.md +4 -1
  22. package/automatization/runners/antigravity/hook.js +84 -22
  23. package/automatization/runners/antigravity/manifest.json +1 -0
  24. package/automatization/shared/acceptance.js +26 -0
  25. package/automatization/workflows/agent-promote.js +3 -1
  26. package/automatization/workflows/autobuild.js +69 -26
  27. package/engine/agents/learning-seal.js +20 -1
  28. package/engine/automation/index.js +11 -2
  29. package/engine/automation/registration.js +89 -0
  30. package/engine/cli/args.js +1 -1
  31. package/engine/cli/catalog.js +8 -2
  32. package/engine/cli/ops.js +1 -1
  33. package/engine/cli/validate.js +3 -0
  34. package/engine/cli/wiring.js +1 -1
  35. package/engine/config/validate.js +30 -11
  36. package/engine/core/changelog.js +60 -1
  37. package/engine/core/migrations.js +275 -0
  38. package/engine/core/repos.js +18 -4
  39. package/engine/hooks/approval.js +77 -14
  40. package/engine/hooks/chat.js +85 -27
  41. package/engine/hooks/files.js +9 -107
  42. package/engine/hooks/input.js +67 -11
  43. package/engine/hooks/migrations.js +93 -0
  44. package/engine/hooks/push.js +5 -3
  45. package/engine/hooks/run.js +10 -5
  46. package/engine/hooks/secrets-shell.js +2 -1
  47. package/engine/hooks/self-approval.js +39 -22
  48. package/engine/hooks/verify.js +79 -22
  49. package/engine/planning/acceptance.js +18 -0
  50. package/engine/planning/contracts.js +78 -45
  51. package/engine/schemas/ops-config.schema.json +9 -0
  52. package/package.json +1 -1
  53. package/template/AGENTS.md +11 -2
  54. package/template/planning/PROTOCOL.md +6 -2
@@ -126,11 +126,11 @@ function mentions(text, item) {
126
126
  })
127
127
  }
128
128
  }
129
- const pedidas = found.filter((one) => one.asked && !one.denied)
129
+ const asked = found.filter((one) => one.asked && !one.denied)
130
130
  return {
131
- named: pedidas.length > 0,
131
+ named: asked.length > 0,
132
132
  denied: found.some((one) => one.denied),
133
- scope: (pedidas.find((one) => one.scope) || {}).scope || '',
133
+ scope: (asked.find((one) => one.scope) || {}).scope || '',
134
134
  }
135
135
  }
136
136
 
@@ -139,7 +139,16 @@ function mentions(text, item) {
139
139
  // el remoto y la rama tienen que aparecer tal cual, como palabras enteras, en una frase que pida publicar
140
140
  // —un verbo de publicar, no cualquiera: «revisá feat/x en origin» no pide un push— y sin una negación
141
141
  // antes del último de los dos.
142
- const PUSHES = new Set('subi sube subir pushea pushear push publica publicar publish empuja empujar'.split(' '))
142
+ // Con las formas de una prohibición —«no subas», «no publiques»—, que es como se niega un push.
143
+ const PUSHES = new Set(('subi sube subir subas pushea pushear pushees push publica publicar publiques publish '
144
+ + 'empuja empujar empujes').split(' '))
145
+ const pushVerb = (clause) => (clause.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
146
+ .match(/[a-z]+/g) || []).some((word) => PUSHES.has(word) || PUSHES.has(word.replace(ENCLITIC, '')))
147
+
148
+ // Un push no se nombra como un archivo: «no hagas push todavía» lo prohíbe sin decir remoto ni rama. Basta una
149
+ // cláusula con un verbo de publicar y una negación.
150
+ const refusesPush = (text) => String(text).split(CLAUSE)
151
+ .some((clause) => pushVerb(clause) && NEGATION.test(clause))
143
152
  function ordersPush(text, item) {
144
153
  const [verb, remote, branch] = item.split(' ')
145
154
  if (verb !== 'push' || !remote || !branch) return false
@@ -150,10 +159,7 @@ function ordersPush(text, item) {
150
159
  .map((word) => word.replace(/^'+/, '').replace(/\.$/, '').replace(/'+$/, ''))
151
160
  const last = Math.max(words.indexOf(remote), words.indexOf(branch))
152
161
  if (words.indexOf(remote) < 0 || words.indexOf(branch) < 0) return false
153
- const plain = clause.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
154
- .match(/[a-z]+/g) || []
155
- return plain.some((word) => PUSHES.has(word) || PUSHES.has(word.replace(ENCLITIC, '')))
156
- && !NEGATION.test(words.slice(0, last + 1).join(' '))
162
+ return pushVerb(clause) && !NEGATION.test(words.slice(0, last + 1).join(' '))
157
163
  })
158
164
  }
159
165
 
@@ -180,9 +186,16 @@ function record(input) {
180
186
  const text = String(input.prompt || '')
181
187
  const human = !process.env.CI && !/^\s*</.test(text)
182
188
  const previous = load(input.session_id)
183
- const approved = human && previous && !refuses(text)
189
+ // Lanzar un recorrido no es contestar el bloqueo: para la sesión principal ya no valía —`said` descarta un
190
+ // mensaje `flow`—, pero lo aprobado sí les llegaba a los subagentes del recorrido (`confirmed`), así que
191
+ // escribir `/autobuild` aprobaba lo que estaba esperando una respuesta.
192
+ const answered = human && previous && !refuses(text) && !flowCommand(text)
184
193
  ? previous.pending.filter((item) => !mentions(text, item).denied)
185
194
  : []
195
+ // Lo que ella confirmó también sobrevive al aviso, porque quien lo usa puede ser un subagente que
196
+ // reintenta mientras ella no escribe nada (caso 186). A la sesión principal no le cambia nada: su
197
+ // lectura, `said`, exige un mensaje de la persona.
198
+ const approved = human || !previous ? answered : previous.approved || []
186
199
  const granted = previous ? (previous.granted || []).filter((one) => !mentions(text, one).denied) : []
187
200
  // El acote viaja con lo concedido: lo que se negó pierde las dos cosas a la vez, y nada queda con un
188
201
  // alcance que ya no acota a nadie.
@@ -196,12 +209,20 @@ function record(input) {
196
209
  // ya era cierto. Lo vuelve a decidir el mensaje humano siguiente (caso 166).
197
210
  const flow = flowCommand(text)
198
211
  const askable = human ? !flow : Boolean(previous && previous.askable)
212
+ // Con la persona se arrastra también lo que dijo y lo que quedó esperando su respuesta. `hold` lee su
213
+ // negación en `spoken` y no en `text`, que en un aviso es la etiqueta del runner: sin esto, su «no
214
+ // toques el .env» dejaba de retener lo que se frenaba después del aviso, y el «seguí» siguiente lo
215
+ // aprobaba (caso 191). Y lo frenado en su turno sigue pendiente aunque el aviso llegue antes que ella.
216
+ // Un registro de antes de 0.99.0 no trae `spoken`: si lo escribió la persona, lo que dijo es su `text`.
217
+ const last = previous ? previous.spoken ?? (previous.human ? previous.text : '') : ''
218
+ const spoken = human ? text : String(last || '')
219
+ const pending = human || !previous ? [] : previous.pending
199
220
  fs.mkdirSync(DIR, { recursive: true })
200
221
  // Sobre qué instancia se está hablando, que es lo que después deja filtrar lo concedido: por qué hace
201
222
  // falta, en `grantedIn`.
202
223
  fs.writeFileSync(recordPath(input.session_id), JSON.stringify(
203
- { id: idOf(input), text, human, askable, flow, root: opsRoot(input), approved, granted,
204
- scopes, pending: [] }))
224
+ { id: idOf(input), text, spoken, human, askable, flow, root: opsRoot(input), approved, granted,
225
+ scopes, pending }))
205
226
  } catch { /* registrar es un extra: si falla, los guards siguen frenando lo que frenaban */ }
206
227
  }
207
228
 
@@ -224,8 +245,12 @@ function said(input) {
224
245
  //
225
246
  // Un registro escrito antes de que `askable` existiera no lo trae y queda afuera: la sesión pierde la
226
247
  // salida por chat hasta el mensaje siguiente, que la vuelve a escribir. Es la dirección barata del error.
248
+ //
249
+ // Un subagente también tiene a quién preguntarle: la persona es de la sesión, no de la llamada, y el
250
+ // subagente hereda la sesión. Excluirlo acá cerraba la salida por chat en todo trabajo delegado —Build en
251
+ // cada `autobuild`— y la persona decía que sí sin que llegara a ningún lado (caso 186).
227
252
  function present(input) {
228
- if (process.env.CI || input.agent_id || !input.session_id) return null
253
+ if (process.env.CI || !input.session_id) return null
229
254
  const saved = load(input.session_id)
230
255
  return saved && saved.askable ? saved : null
231
256
  }
@@ -275,18 +300,18 @@ function why(saved, item, asked, inherit) {
275
300
  // antes que de menos.
276
301
  function grant(input, saved, entries) {
277
302
  const before = saved.granted || []
278
- const nuevas = entries.filter((one) => !before.includes(one.item))
279
- if (!nuevas.length) return
303
+ const added = entries.filter((one) => !before.includes(one.item))
304
+ if (!added.length) return
280
305
  const grantedAt = new Date().toISOString()
281
306
  try {
282
- saved.granted = [...before, ...nuevas.map((one) => one.item)]
307
+ saved.granted = [...before, ...added.map((one) => one.item)]
283
308
  saved.scopes = { ...(saved.scopes || {}) }
284
- for (const one of nuevas) if (one.scope) saved.scopes[one.item] = one.scope
309
+ for (const one of added) if (one.scope) saved.scopes[one.item] = one.scope
285
310
  fs.writeFileSync(recordPath(input.session_id), JSON.stringify(saved))
286
311
  } catch { /* sin anotarlo, se vuelve a pedir */ }
287
312
  // Y queda el rastro, que es lo que el registro de la sesión no puede dar: muere con ella, y lo que una
288
313
  // auditoría pregunta es quién concedió qué y con qué alcance, meses después (caso 127).
289
- TRAIL.append(saved.root, LOG, nuevas.map((one) => ({
314
+ TRAIL.append(saved.root, LOG, added.map((one) => ({
290
315
  grantedAt, item: one.item, scope: one.scope || null, via: one.via, session: input.session_id || null,
291
316
  })))
292
317
  }
@@ -301,14 +326,34 @@ function grant(input, saved, entries) {
301
326
  // en el mensaje en curso o aprobó con un «dale» (caso 119).
302
327
  function authorized(input, items, { asked = named, inherit = true } = {}) {
303
328
  const saved = said(input)
304
- if (!saved) return []
329
+ if (!saved) return confirmed(input, items).map(({ item, via }) => ({ item, via }))
305
330
  return items.map((item) => ({ item, via: why(saved, item, asked, inherit) })).filter((one) => one.via)
306
331
  }
307
332
 
333
+ // Lo que la persona confirmó y un subagente puede usar: eso y nada más. Una orden no, porque la da el mensaje
334
+ // y la llamada de un subagente no es ese mensaje —es lo que `said` cuida—; lo concedido antes tampoco. Lo
335
+ // confirmado sí: son los ítems exactos que un bloqueo nombró y ella aprobó al contestarle (caso 186). Sin
336
+ // la comparación de `id`, porque el subagente puede reintentar después del mensaje en que ella contestó.
337
+ function confirmed(input, items) {
338
+ if (process.env.CI || !input.agent_id || !input.session_id) return []
339
+ const saved = load(input.session_id)
340
+ const approved = (saved && saved.approved) || []
341
+ return items.filter((item) => approved.includes(item)).map((item) => ({ item, via: 'dale', saved }))
342
+ }
343
+
308
344
  // Lo que la persona no autorizó de lo que un guard está por frenar; lo que sí, queda concedido.
309
345
  function unauthorized(input, items) {
310
346
  const saved = said(input)
311
- if (!saved) return items
347
+ if (!saved) {
348
+ const passed = confirmed(input, items)
349
+ if (passed.length) {
350
+ const record = passed[0].saved
351
+ grant(input, record, passed.map((one) => ({ item: one.item, via: one.via,
352
+ scope: (record.scopes || {})[one.item] || '' })))
353
+ }
354
+ const cleared = new Set(passed.map((one) => one.item))
355
+ return items.filter((item) => !cleared.has(item))
356
+ }
312
357
  const passed = items.map((item) => ({ item, via: why(saved, item, named, true) })).filter((one) => one.via)
313
358
  // El alcance sale del mensaje cuando es éste el que lo concede, y del registro cuando se hereda: una
314
359
  // orden vieja no se reinterpreta contra un texto que no la nombraba.
@@ -328,21 +373,34 @@ function unauthorizedNow(input, items) {
328
373
  }
329
374
 
330
375
  // Lo que quedó frenado, para que la confirmación del mensaje siguiente apruebe exactamente eso y nada más.
331
- // Devuelve si hay una persona a quien preguntarle.
376
+ // Devuelve `false` si no hay persona a quien preguntarle, y si la hay, qué de lo frenado no quedó
377
+ // esperando: qué dice el bloqueo con eso, en `REFUSED` (approval.js).
378
+ //
379
+ // Si el último mensaje de la persona niega o frena, lo que se ataje después no queda esperando: ya contestó
380
+ // que no antes de que el agente lo intentara, y un aviso del runner en el medio no cambia eso. Desde que
381
+ // confirmar no exige una palabra (caso 184), dejarlo pendiente hacía que un «seguí con lo tuyo» aprobara
382
+ // el `.env` o el push que ella acababa de prohibir. Se mira el mensaje, y no sólo el ítem, porque un push
383
+ // no se nombra como un archivo.
332
384
  //
333
- // Si el mensaje en curso niega o frena, lo que se ataje en su turno no queda esperando: la persona ya
334
- // contestó que no antes de que el agente lo intentara. Desde que confirmar no exige una palabra (caso 184),
335
- // dejarlo pendiente hacía que un «seguí con lo tuyo» aprobara el `.env` o el push que ella acababa de
336
- // prohibir. Se mira el mensaje y no el ítem porque un push no se nombra como un archivo.
385
+ // Pero el mensaje se mira en su primera cláusula, que es donde está la respuesta: lo que viene después
386
+ // puede negar otra cosa, y «dale, fijate si esto no es un defecto» dejaba sin anotar lo que ella estaba
387
+ // aprobando (caso 188). Lo que niega nombrando el ítem se respeta en cualquier cláusula —«dale, pero no el
388
+ // .env»—. Queda afuera la prohibición que no nombra nada después de la coma, «dale, pero no toques nada
389
+ // más»: ninguna lectura de palabras la separa de una negación sobre otro tema.
337
390
  function hold(input, items) {
338
391
  const saved = present(input)
339
392
  if (!saved) return false
340
393
  try {
341
- const text = saved.text || ''
342
- const open = NEGATION.test(text) || HALT.test(text) ? [] : items
394
+ const spoken = String(saved.spoken ?? saved.text ?? '')
395
+ const head = spoken.split(CLAUSE)[0]
396
+ const dropped = NEGATION.test(head) || HALT.test(head)
397
+ ? items
398
+ : items.filter((item) => mentions(spoken, item).denied
399
+ || (item.startsWith('push ') && refusesPush(spoken)))
400
+ const open = items.filter((item) => !dropped.includes(item))
343
401
  saved.pending = [...new Set([...saved.pending, ...open])]
344
402
  fs.writeFileSync(recordPath(input.session_id), JSON.stringify(saved))
345
- return true
403
+ return { dropped }
346
404
  } catch { return false }
347
405
  }
348
406
 
@@ -1,12 +1,11 @@
1
1
  'use strict'
2
2
 
3
- // Los guards que juzgan lo que está por escribirse: un secreto, un archivo generado, una migración,
4
- // una prueba que se apaga, el motor de la dependencia. Todos leen el contenido entrante y no el disco
5
- // —lo que ya estaba no lo escribió este cambio— y son el grupo `pre-files` del registro.
3
+ // Los guards que juzgan lo que está por escribirse: un secreto, un archivo generado, una prueba que se
4
+ // apaga, el motor de la dependencia. Todos leen el contenido entrante y no el disco —lo que ya estaba no
5
+ // lo escribió este cambio— y son el grupo `pre-files` del registro, junto con `migrations.js`.
6
6
 
7
7
  const fs = require('node:fs')
8
8
  const path = require('node:path')
9
- const { spawnSync } = require('node:child_process')
10
9
  const {
11
10
  patchOf, filesOf, contentOf, cwdOf, block, configOf, opsRoot,
12
11
  writableRoots, outsideRoots, DECLARE_IT,
@@ -20,33 +19,10 @@ const { hasTasks } = require('../planning/state')
20
19
  const { TEMPLATE_PREFIXES } = require('../core/ownership')
21
20
 
22
21
  // Si la ruta que este guard está por bloquear está aprobada, no hay nada que decir. Es la salida
23
- // angosta: vale para esa ruta y deja de valer en cuanto cambie, a diferencia de la variable, que apaga
24
- // el guard hasta que cierre la sesión.
22
+ // angosta: vale para esa ruta y para ninguna otra, a diferencia de la variable, que apaga el guard hasta
23
+ // que cierre la sesión.
25
24
  const approved = (input, file) => !AP.pending(opsRoot(input), [file], input).length
26
25
 
27
- // Si la migración ya viajó a otra copia, que es lo que el bloqueo de abajo quiere saber y `existsSync`
28
- // no contesta. Devuelve el motivo del bloqueo o cadena vacía.
29
- //
30
- // **`HEAD` y no el índice**: un archivo apenas `git add`eado no viajó a ninguna parte, y `git ls-files`
31
- // lo daría por historial. Y **resolver la raíz es una pregunta aparte** de si el archivo está en `HEAD`:
32
- // las dos fallan con 128 y confundirlas repite el error que este caso arregla —decidir por la respuesta
33
- // equivocada—. Sin raíz resoluble se degrada a la conducta de antes, que bloquea de más, porque cuando
34
- // no se puede saber ése es el lado correcto para equivocarse. Es la degradación que `check` ya declara
35
- // cuando no puede resolver el repositorio de un servicio. Caso 086.
36
- function alreadyShipped(file) {
37
- if (!fs.existsSync(file)) return ''
38
- const cwd = path.dirname(file)
39
- const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' })
40
- if (top.status !== 0) {
41
- return 'existe, y acá no hay repositorio con el que saber si ya viajó a otra copia'
42
- }
43
- const rel = path.relative(top.stdout.trim(), file).split(path.sep).join('/')
44
- return spawnSync('git', ['cat-file', '-e', `HEAD:${rel}`], { cwd }).status === 0
45
- ? 'ya está en el historial del repositorio'
46
- : ''
47
- }
48
-
49
-
50
26
  // Qué archivo es una credencial, para los dos guards que la cuidan: `secrets`, que frena escribirla, y
51
27
  // `secrets-read`, que frena leerla. Devuelve el motivo, o vacío.
52
28
  function credential(input, raw) {
@@ -102,7 +78,7 @@ function secretsRead(input) {
102
78
  for (const file of [...filesOf(input), ...patterns]) {
103
79
  if (!patternNames(file).some((name) => credential(input, name)) || approved(input, file)) continue
104
80
  block(`${file} es una credencial: leerla la deja en el contexto de la sesión. Si hace falta un valor, `
105
- + `pedíselo a una persona.\n${AP.HOW('OPS_SECRETS_READ_OVERRIDE', [file], input)}`)
81
+ + `pedíselo a una persona.\n${AP.HOW('OPS_SECRETS_READ_OVERRIDE', [file], input, [file], { durable: true })}`)
106
82
  }
107
83
  }
108
84
 
@@ -243,13 +219,13 @@ function planFirst(input) {
243
219
  // Se lista recién acá, después de las dos salidas de arriba: quien tiene su plan sale por la primera y
244
220
  // no paga esta lectura, que es la misma razón por la que `hasTasks` se pregunta donde se pregunta.
245
221
  const foreign = readWips(planning).filter((one) => one.complete + one.pending > 0)
246
- const estado = wip ? `WIP tiene la tarea ${wip.task} y ningún paso` : 'WIP está en IDLE'
222
+ const state = wip ? `WIP tiene la tarea ${wip.task} y ningún paso` : 'WIP está en IDLE'
247
223
  const why = foreign.length
248
224
  ? `hay plan escrito, pero bajo otro id: ${foreign.map((one) => `${one.runner} (${one.task})`).join(', ')}.\n`
249
225
  + 'Si ese plan es tuyo, volvé a su id con `export CAUCE_RUNNER=<id>` —`ops runners <planning>` los lista '
250
226
  + 'con su tarea y su avance— y repetí el cambio. Si vas a trabajar en paralelo, montá tu propio árbol '
251
227
  + 'con `ops worktree <planning> <tarea>`, que te devuelve el id hecho.\n'
252
- : `${estado}, así que el plan todavía no está escrito.\n`
228
+ : `${state}, así que el plan todavía no está escrito.\n`
253
229
  + 'Escribí en tu planning/wip/<runner>.md la tarea y su plan aprobado —pasos numerados, cada uno con '
254
230
  + 'un estado verificable— y volvé al cambio. Si esto no es trabajo de una tarea, aprobá la ruta.\n'
255
231
  for (const raw of filesOf(input)) {
@@ -278,80 +254,6 @@ function workspaceBoundary(input) {
278
254
  }
279
255
  }
280
256
 
281
- // Qué archivos son migraciones para este proyecto. La ruta la fija el motor —`migrations/`, `migration/`
282
- // o `migrate/`, que es donde las ponen todas las herramientas— y **la extensión la declara el proyecto**,
283
- // con `sql` de default.
284
- //
285
- // Sin esto el guard sólo veía `.sql`, así que en TypeORM, Prisma, Django, Rails o Alembic no miraba nada:
286
- // ni frenaba el SQL destructivo, ni protegía una migración existente de ser reescrita. Y no lo decía —
287
- // aparecía cableado y en verde—. Medido en una instancia real: 64 migraciones `.sql` cubiertas y **409
288
- // TypeORM `.ts` invisibles** (caso 077).
289
- //
290
- // No se amplía el default a `.ts`/`.py`/`.rb` por su cuenta: eso reintroduciría el falso positivo del
291
- // caso 039 —un archivo de lenguaje que menciona `DROP TABLE` en un comentario o en un string— por otra
292
- // puerta. Declararlo es opt-in porque el que sabe si sus migraciones son de lenguaje es el proyecto, y
293
- // porque así el costo lo elige quien lo paga.
294
- //
295
- // La extensión inválida no se descarta en silencio: descartarla dejaría al proyecto creyendo que declaró
296
- // una cobertura que no tiene, que es exactamente el defecto que este helper vino a cerrar. La valida
297
- // `validateOpsConfig`, y acá se ignora lo que no pasa ese filtro porque el guard no es el lugar donde se
298
- // enseña a escribir la configuración.
299
- const DEFAULT_MIGRATION_EXTENSIONS = ['sql']
300
-
301
- function migrationPattern(input) {
302
- const root = opsRoot(input)
303
- const declared = root ? (configOf(root).migrations || {}).extensions : null
304
- const extensions = (Array.isArray(declared) ? declared : DEFAULT_MIGRATION_EXTENSIONS)
305
- .filter((one) => typeof one === 'string' && /^[a-z0-9]+$/.test(one))
306
- const usable = extensions.length ? extensions : DEFAULT_MIGRATION_EXTENSIONS
307
- return new RegExp(`(?:^|/)(?:migrations?|migrate)/.*\\.(?:${usable.join('|')})$`, 'i')
308
- }
309
-
310
- function migrations(input) {
311
- if (process.env.OPS_MIGRATIONS_OVERRIDE === '1') return
312
- // Cada rama cierra su propio límite. Cuando el `\b` estaba al final del grupo se aplicaba a las tres, y
313
- // la de `delete` termina a propósito en `;`: después de un punto y coma no hay límite de palabra, así que
314
- // `DELETE FROM pedidos;` —la forma que tiene en cualquier migración— pasaba y sólo frenaba la variante sin
315
- // punto y coma. `drop column` y `drop constraint` faltaban: pierden datos y garantías igual que `drop table`.
316
- const destructiveSql = new RegExp(
317
- String.raw`\bdrop\s+(?:table|database|schema|column|constraint)\b` +
318
- String.raw`|\btruncate\b` +
319
- String.raw`|\bdelete\s+from\s+\S+\s*(?:;|$)`,
320
- 'i',
321
- )
322
- // Las dos condiciones deciden sobre el mismo alcance, y por eso comparten el filtro. El bloqueo por
323
- // SQL destructivo corría antes de este bucle, o sea sobre el contenido y sin mirar la ruta que ya
324
- // tenía a mano: frenaba un ADR que citaba la migración o un comentario que advertía que eso no se
325
- // hace, y encima afirmaba «La migración contiene…» sobre un archivo que no lo era. Un guard que
326
- // frena donde no corresponde enseña a apagarlo, que es la salida más ancha que hay.
327
- //
328
- // El mensaje nombra el archivo por lo mismo: un falso positivo se lee igual que un bloqueo correcto
329
- // mientras no diga sobre qué está decidiendo.
330
- //
331
- // El precio de compartir el filtro es que una migración escrita fuera de un directorio con ese nombre
332
- // deja de frenarse. Es deliberado: el otro chequeo ya vivía con esa convención, y dos condiciones de
333
- // la misma función con dos alcances distintos es lo que hizo falta arreglar acá.
334
- const esMigracion = migrationPattern(input)
335
- for (const raw of filesOf(input)) {
336
- const normalized = raw.replace(/\\/g, '/')
337
- if (!esMigracion.test(normalized)) continue
338
- if (approved(input, normalized)) continue
339
- if (destructiveSql.test(contentOf(input))) {
340
- block(`${raw} contiene SQL destructivo.\n${AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized], input)}`)
341
- }
342
- // El mensaje nombra el hecho que sostiene el bloqueo y no su interpretación: «historial» era una
343
- // lectura que `existsSync` no podía dar, y se la daba igual sobre stubs de la misma sesión. Y lleva
344
- // la salida angosta, que hasta 0.79.0 sólo tenía el bloqueo hermano: éste es el que aparece en el
345
- // flujo normal de escribir una migración, así que era justo el que no podía quedarse sin decirla.
346
- const file = path.resolve(cwdOf(input), raw)
347
- const shipped = alreadyShipped(file)
348
- if (shipped) {
349
- block(`${raw} ${shipped}. Crea una nueva en vez de reescribirla.\n`
350
- + AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized], input))
351
- }
352
- }
353
- }
354
-
355
257
  // Hace falta un guard aparte porque `workspace-boundary` no lo cubre: `node_modules/` cae dentro de
356
258
  // la raíz declarada, así que editar el motor le parece legítimo.
357
259
  //
@@ -381,5 +283,5 @@ function engineWrites(input) {
381
283
  module.exports = {
382
284
  credential, patternNames,
383
285
  secrets, secretsRead, integrationSnapshot, generated, testEvidence, planFirst, workspaceBoundary,
384
- migrations, engineWrites,
286
+ engineWrites,
385
287
  }
@@ -3,23 +3,75 @@
3
3
  // Cómo un guard lee lo que el runner le mandó, y cómo se niega. Es una sola pregunta —qué hay en la
4
4
  // entrada y cómo se interpreta— y la comparten las tres familias de guards, así que vive acá y no en
5
5
  // ninguna de ellas: copiada, una copia dejaría de reconocer un formato y su guard permitiría todo en
6
- // silencio, que es la falla que ninguna prueba verde delata.
6
+ // silencio, que es la falla que ninguna prueba verde delata. `readInput` lo usa además el puente de
7
+ // Antigravity, que tenía su copia y la tenía rota así (caso 198).
7
8
 
8
9
  const fs = require('node:fs')
9
10
  const path = require('node:path')
10
11
  const { spawnSync } = require('node:child_process')
11
12
  const { writableOutsideRoots } = require('../config/paths')
12
13
 
14
+ // Cuánto se espera el primer byte de stdin. El runner escribe su JSON al lanzar el hook, así que en el
15
+ // camino real ya está en el búfer cuando Node termina de arrancar: el plazo sólo cubre una máquina
16
+ // cargada, y dos segundos le sobran. Lo que agota el plazo es un stdin heredado que nadie va a escribir
17
+ // —una terminal, un pipe o un socket abiertos— y ahí cada segundo de más es un segundo colgado. Fijo y
18
+ // no configurable: una variable que lo estire no arregla nada que el runner necesite (caso 190).
19
+ const FIRST_BYTE_MS = 2000
20
+
21
+ // Cómo se invoca a mano quien lee. Es lo único del mensaje que cambia entre un guard y el puente de
22
+ // Antigravity, que lee con esta misma función pero recibe otro JSON y se lanza con otro comando.
23
+ const GUARD_USAGE = 'pasale el JSON del hook —printf \'%s\' \'{"tool_input":{"command":"…"}}\' | guard-….sh—'
24
+
25
+ const noInput = (waitMs, usage) => `no llegó nada por stdin en ${waitMs} ms: stdin está abierto y nadie escribe`
26
+ + ' (una terminal, o un pipe o un socket que no se cierran). Un guard que no sabe qué juzgar no autoriza.'
27
+ + ` Para invocarlo a mano, ${usage} o correlo sin entrada con </dev/null.`
28
+
13
29
  // Sin stdin no hay nada que leer y los guards caen a las variables de entorno; con stdin ilegible sí
14
30
  // hay algo y no se entiende, que es otra cosa. Devolver `{}` ahí dejaba a cada guard sin comando ni
15
31
  // archivos, o sea permitiendo todo, y en silencio.
16
- function readInput() {
17
- let raw = ''
18
- try { raw = fs.readFileSync(0, 'utf8') } catch { /* sin stdin */ }
19
- if (!raw.trim()) return {}
20
- try { return JSON.parse(raw) } catch (error) {
21
- block(`la entrada del hook no es JSON válido (${error.message}).`)
22
- }
32
+ //
33
+ // Hay un tercer estado, y es el del caso 190: stdin abierto que no manda nada. Leerlo sincrónico
34
+ // esperaba hasta que el otro extremo cerrara —horas, en la corrida que lo encontró— sin que el guard
35
+ // llegara a correr. Se lee asíncrono porque es la única forma de ponerle plazo a un socket: reabrir el 0
36
+ // con `O_NONBLOCK` falla con `ENXIO` sobre un socket, que era el descriptor del colgado original.
37
+ //
38
+ // El plazo es sobre el primer byte y no sobre la lectura: un `Write` grande llega en tramos, y cortarlo
39
+ // a mitad de camino bloquearía una escritura legítima. Agotado, **bloquea**: tratarlo como entrada vacía
40
+ // dejaba pasar todo lo que no estuviera en `OPS_HOOK_COMMAND`, que es un guard apagado sin rastro (R27).
41
+ function readInput(stream = process.stdin, waitMs = FIRST_BYTE_MS, usage = GUARD_USAGE) {
42
+ return new Promise((resolve, reject) => {
43
+ const chunks = []
44
+ const timer = setTimeout(() => {
45
+ stream.destroy()
46
+ reject(blocked(noInput(waitMs, usage)))
47
+ }, waitMs)
48
+ // Llegado el primer byte, el plazo cambia de sentido: si pasa sin nada nuevo y lo recibido ya es un JSON
49
+ // entero, el documento está completo aunque quien escribió no haya cerrado el pipe, y esperar el cierre
50
+ // colgaba al guard igual que sin datos. Un JSON a medias sigue esperando: un `Write` grande puede llegar
51
+ // en tramos con pausas, y cortarlo bloquearía una escritura legítima.
52
+ let idle
53
+ stream.on('data', (chunk) => {
54
+ clearTimeout(timer)
55
+ clearTimeout(idle)
56
+ chunks.push(chunk)
57
+ idle = setTimeout(() => {
58
+ let parsed
59
+ try { parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { return }
60
+ stream.destroy()
61
+ resolve(parsed)
62
+ }, waitMs)
63
+ })
64
+ stream.on('error', () => { clearTimeout(timer); clearTimeout(idle); resolve({}) })
65
+ stream.on('end', () => {
66
+ clearTimeout(timer)
67
+ clearTimeout(idle)
68
+ const raw = Buffer.concat(chunks).toString('utf8')
69
+ if (!raw.trim()) return resolve({})
70
+ try { resolve(JSON.parse(raw)) } catch (error) {
71
+ reject(blocked(`la entrada del hook no es JSON válido (${error.message}).`))
72
+ }
73
+ })
74
+ })
23
75
  }
24
76
 
25
77
  // El cuerpo de un heredoc es entrada estándar: no se ejecuta, se escribe. Juzgarlo como comando frenaba
@@ -87,10 +139,14 @@ function cwdOf(input) {
87
139
  return path.resolve(String(cwd))
88
140
  }
89
141
 
90
- function block(message) {
142
+ function blocked(message) {
91
143
  const error = new Error(message)
92
144
  error.blocked = true
93
- throw error
145
+ return error
146
+ }
147
+
148
+ function block(message) {
149
+ throw blocked(message)
94
150
  }
95
151
 
96
152
  // La configuración de la raíz ops. Un guard que no puede leerla bloquea: `findOpsRoot` sólo devuelve
@@ -262,7 +318,7 @@ function opsRoot(input) {
262
318
  }
263
319
 
264
320
  module.exports = {
265
- readInput, commandOf, patchOf, filesOf, contentOf, cwdOf, block, configOf,
321
+ readInput, FIRST_BYTE_MS, commandOf, patchOf, filesOf, contentOf, cwdOf, block, configOf,
266
322
  gitDirectory, isCommit, withoutGitGlobals, stagedFiles, stagedForCommit,
267
323
  findOpsRoot, opsRoot,
268
324
  writableRoots, outsideRoots, DECLARE_IT, unquoted,
@@ -0,0 +1,93 @@
1
+ 'use strict'
2
+
3
+ // El guard `migrations`: no reescribir una migración que ya viajó y no aplicar un borrado sin que una
4
+ // persona lo apruebe. Qué archivo es una migración, qué parte aplica y qué cuenta como destruir lo decide
5
+ // `core/migrations`, que `check` también lee.
6
+
7
+ const fs = require('node:fs')
8
+ const path = require('node:path')
9
+ const { spawnSync } = require('node:child_process')
10
+ const { filesOf, contentOf, patchOf, cwdOf, block, configOf, opsRoot } = require('./input')
11
+ const AP = require('./approval')
12
+ const M = require('../core/migrations')
13
+
14
+ // Si la migración ya viajó a otra copia, que es lo que el bloqueo de abajo quiere saber y `existsSync`
15
+ // no contesta. Devuelve el motivo del bloqueo o cadena vacía.
16
+ //
17
+ // **`HEAD` y no el índice**: un archivo apenas `git add`eado no viajó a ninguna parte, y `git ls-files`
18
+ // lo daría por historial. Y **resolver la raíz es una pregunta aparte** de si el archivo está en `HEAD`:
19
+ // las dos fallan con 128 y confundirlas repite el error que este caso arregla —decidir por la respuesta
20
+ // equivocada—. Sin raíz resoluble se degrada a la conducta de antes, que bloquea de más, porque cuando
21
+ // no se puede saber ése es el lado correcto para equivocarse. Es la degradación que `check` ya declara
22
+ // cuando no puede resolver el repositorio de un servicio. Caso 086.
23
+ function alreadyShipped(file) {
24
+ if (!fs.existsSync(file)) return ''
25
+ const cwd = path.dirname(file)
26
+ const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' })
27
+ if (top.status !== 0) {
28
+ return 'existe, y acá no hay repositorio con el que saber si ya viajó a otra copia'
29
+ }
30
+ const rel = path.relative(top.stdout.trim(), file).split(path.sep).join('/')
31
+ return spawnSync('git', ['cat-file', '-e', `HEAD:${rel}`], { cwd }).status === 0
32
+ ? 'ya está en el historial del repositorio'
33
+ : ''
34
+ }
35
+
36
+ const approved = (input, file) => !AP.pending(opsRoot(input), [file], input).length
37
+
38
+ // El `Edit` trae lo que reemplaza; el archivo en disco dice dónde cae (caso 185). Se lo lee sólo para
39
+ // ubicar el fragmento: lo que ya estaba no se juzga.
40
+ function editOf(input, file) {
41
+ const fields = input.tool_input || {}
42
+ if (typeof fields.old_string !== 'string' || typeof fields.content === 'string') return {}
43
+ let disk = null
44
+ try { disk = fs.readFileSync(file, 'utf8') } catch { /* sin archivo se juzga el fragmento */ }
45
+ return { disk, old: fields.old_string, all: fields.replace_all === true }
46
+ }
47
+
48
+ function migrations(input) {
49
+ if (process.env.OPS_MIGRATIONS_OVERRIDE === '1') return
50
+ // Las dos condiciones deciden sobre el mismo alcance, y por eso comparten el filtro. El bloqueo por
51
+ // SQL destructivo corría antes de este bucle, o sea sobre el contenido y sin mirar la ruta que ya
52
+ // tenía a mano: frenaba un ADR que citaba la migración o un comentario que advertía que eso no se
53
+ // hace, y encima afirmaba «La migración contiene…» sobre un archivo que no lo era. Un guard que
54
+ // frena donde no corresponde enseña a apagarlo, que es la salida más ancha que hay.
55
+ //
56
+ // El precio de compartir el filtro es que una migración escrita fuera de las carpetas declaradas deja de
57
+ // frenarse. Es deliberado: el otro chequeo ya vivía con esa convención, y dos condiciones de la misma
58
+ // función con dos alcances distintos es lo que hizo falta arreglar acá.
59
+ const root = opsRoot(input)
60
+ const isMigration = M.pattern(root ? configOf(root) : {})
61
+ // Un parche trae varios archivos en un sobre: cada uno se juzga por su sección, no por el sobre entero.
62
+ const sections = M.patchSections(patchOf(input))
63
+ for (const raw of filesOf(input)) {
64
+ const normalized = raw.replace(/\\/g, '/')
65
+ if (!isMigration.test(normalized)) continue
66
+ if (approved(input, normalized)) continue
67
+ const file = path.resolve(cwdOf(input), raw)
68
+ // El mensaje nombra el archivo y la sentencia: un falso positivo se lee igual que un bloqueo correcto
69
+ // mientras no diga sobre qué está decidiendo. Y cuando la migración se partió, dice que la reversión
70
+ // no se juzgó, para que quien lo lea no busque la sentencia en el bloque equivocado.
71
+ const section = sections.get(raw)
72
+ const scope = section
73
+ ? M.judgedPatch(normalized, section)
74
+ : M.judged(normalized, contentOf(input), editOf(input, file))
75
+ const found = M.destructive(scope.text)
76
+ if (found) {
77
+ const where = scope.label ? ` en el bloque que aplica (la reversión, \`${scope.label}\`, no se juzga)` : ''
78
+ block(`${raw} contiene ${found.kind}${where}: \`${found.what}\`.\n`
79
+ + AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized], input))
80
+ }
81
+ // El mensaje nombra el hecho que sostiene el bloqueo y no su interpretación: «historial» era una
82
+ // lectura que `existsSync` no podía dar, y se la daba igual sobre stubs de la misma sesión. Y lleva
83
+ // la salida angosta, que hasta 0.79.0 sólo tenía el bloqueo hermano: éste es el que aparece en el
84
+ // flujo normal de escribir una migración, así que era justo el que no podía quedarse sin decirla.
85
+ const shipped = alreadyShipped(file)
86
+ if (shipped) {
87
+ block(`${raw} ${shipped}. Crea una nueva en vez de reescribirla.\n`
88
+ + AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized], input))
89
+ }
90
+ }
91
+ }
92
+
93
+ module.exports = { migrations }
@@ -113,12 +113,14 @@ function liveMessage(live, input) {
113
113
 
114
114
  function workMessage(items, input) {
115
115
  const lines = items.filter((item) => item.startsWith('push '))
116
- const chat = CHAT.hold(input, items)
117
- const how = chat
116
+ const held = CHAT.hold(input, items)
117
+ const dropped = held ? held.dropped : []
118
+ const chat = held && dropped.length < items.length
119
+ const how = (dropped.length ? AP.REFUSED(dropped, input) : '') + (chat
118
120
  ? 'Decile a la persona qué se frenó y pedile que lo confirme con sus palabras: si lo que contesta es un '
119
121
  + 'sí, reintentá el mismo push y pasa; si duda, pregunta o dice que no, no reintentes. '
120
122
  + 'También pasa si lo pide nombrando el remoto y la rama, como «subí feat/x a origin».'
121
- : 'Lo destraba una persona pidiéndolo en el chat con el remoto y la rama.'
123
+ : 'Lo destraba una persona pidiéndolo en el chat con el remoto y la rama.')
122
124
  const paste = lines.length
123
125
  ? `\nSi prefiere aprobarlo a mano, que pegue ella tal cual en ${AP.where(input)} estas líneas:\n`
124
126
  + lines.map((line) => ` ${line}\n`).join('')
@@ -13,6 +13,7 @@ const { readInput, block, opsRoot } = require('./input')
13
13
  const shell = require('./shell')
14
14
  const { verify } = require('./verify')
15
15
  const files = require('./files')
16
+ const { migrations } = require('./migrations')
16
17
  const chat = require('./chat')
17
18
  const { secretsShell } = require('./secrets-shell')
18
19
  const { opsConfig, opsConfigShell } = require('./ops-config')
@@ -50,7 +51,7 @@ const guards = {
50
51
  generated: files.generated,
51
52
  'workspace-boundary': files.workspaceBoundary,
52
53
  engine: files.engineWrites,
53
- migrations: files.migrations,
54
+ migrations,
54
55
  'integration-snapshot': files.integrationSnapshot,
55
56
  'test-evidence': files.testEvidence,
56
57
  'plan-first': files.planFirst,
@@ -145,8 +146,10 @@ const hookMetadata = [
145
146
  {
146
147
  name: 'migrations',
147
148
  event: 'PreToolUse · files',
148
- purpose: 'Protege migraciones existentes y bloquea SQL destructivo, sobre las extensiones que el '
149
- + 'proyecto declare en migrations.extensions — sólo .sql si no declara ninguna.',
149
+ purpose: 'Protege migraciones existentes y bloquea SQL destructivo o el borrado con la API del ORM en la '
150
+ + 'parte que aplica —no en su reversión—, sobre las extensiones y carpetas que el proyecto declare en '
151
+ + 'migrations.extensions y migrations.paths — sólo .sql bajo migrations/, migration/ o migrate/ si no '
152
+ + 'declara nada.',
150
153
  },
151
154
  {
152
155
  name: 'integration-snapshot',
@@ -194,11 +197,13 @@ function executeAll(names, input) {
194
197
  for (const name of resolve(names)) execute(name, input)
195
198
  }
196
199
 
200
+ // Asíncrono sólo por la lectura de stdin, que necesita un plazo (`input.js`); los guards siguen siendo
201
+ // sincrónicos y `executeAll` también, así que quien los llama directo no cambia.
197
202
  if (require.main === module) {
198
- try { executeAll(process.argv.slice(2), readInput()) } catch (error) {
203
+ readInput().then((input) => executeAll(process.argv.slice(2), input)).catch((error) => {
199
204
  console.error(`BLOQUEADO: ${error.message}`)
200
205
  process.exit(error.blocked ? 2 : 1)
201
- }
206
+ })
202
207
  }
203
208
 
204
209
  module.exports = {