@ingeniomaps/cauce 0.97.0 → 0.99.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 (93) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/README.md +1 -0
  3. package/agents/roles/system/accounting-specialist/learning/AUTOMATION.md +2 -2
  4. package/agents/roles/system/ai-governance-lead/learning/AUTOMATION.md +1 -1
  5. package/agents/roles/system/ai-product-manager/learning/AUTOMATION.md +1 -1
  6. package/agents/roles/system/analytics-engineer/learning/AUTOMATION.md +1 -1
  7. package/agents/roles/system/backend-engineer/learning/AUTOMATION.md +2 -2
  8. package/agents/roles/system/business-strategist/learning/AUTOMATION.md +2 -2
  9. package/agents/roles/system/cloud-architect/learning/AUTOMATION.md +1 -1
  10. package/agents/roles/system/community-manager/learning/AUTOMATION.md +1 -1
  11. package/agents/roles/system/content-specialist/learning/AUTOMATION.md +2 -2
  12. package/agents/roles/system/customer-success-manager/learning/AUTOMATION.md +2 -2
  13. package/agents/roles/system/customer-support-specialist/learning/AUTOMATION.md +2 -2
  14. package/agents/roles/system/data-analyst/learning/AUTOMATION.md +2 -2
  15. package/agents/roles/system/data-engineer/learning/AUTOMATION.md +1 -1
  16. package/agents/roles/system/data-governance-steward/learning/AUTOMATION.md +2 -2
  17. package/agents/roles/system/data-scientist/learning/AUTOMATION.md +1 -1
  18. package/agents/roles/system/database-administrator/learning/AUTOMATION.md +1 -1
  19. package/agents/roles/system/developer-relations-engineer/learning/AUTOMATION.md +1 -1
  20. package/agents/roles/system/devops-engineer/learning/AUTOMATION.md +2 -2
  21. package/agents/roles/system/engineering-manager/learning/AUTOMATION.md +1 -1
  22. package/agents/roles/system/financial-controller/learning/AUTOMATION.md +2 -2
  23. package/agents/roles/system/finops-engineer/learning/AUTOMATION.md +2 -2
  24. package/agents/roles/system/fraud-risk-analyst/learning/AUTOMATION.md +2 -2
  25. package/agents/roles/system/frontend-engineer/learning/AUTOMATION.md +2 -2
  26. package/agents/roles/system/growth-marketer/learning/AUTOMATION.md +2 -2
  27. package/agents/roles/system/implementation-manager/learning/AUTOMATION.md +1 -1
  28. package/agents/roles/system/integrations-engineer/learning/AUTOMATION.md +3 -3
  29. package/agents/roles/system/kyc-aml-specialist/learning/AUTOMATION.md +3 -3
  30. package/agents/roles/system/legal-counsel/learning/AUTOMATION.md +1 -1
  31. package/agents/roles/system/logistics-operations-manager/learning/AUTOMATION.md +2 -2
  32. package/agents/roles/system/machine-learning-engineer/learning/AUTOMATION.md +1 -1
  33. package/agents/roles/system/mlops-engineer/learning/AUTOMATION.md +1 -1
  34. package/agents/roles/system/mobile-engineer/learning/AUTOMATION.md +2 -2
  35. package/agents/roles/system/partnerships-manager/learning/AUTOMATION.md +1 -1
  36. package/agents/roles/system/people-operations-manager/learning/AUTOMATION.md +1 -1
  37. package/agents/roles/system/privacy-compliance-specialist/learning/AUTOMATION.md +2 -2
  38. package/agents/roles/system/procurement-manager/learning/AUTOMATION.md +1 -1
  39. package/agents/roles/system/product-manager/learning/AUTOMATION.md +3 -3
  40. package/agents/roles/system/product-marketing-manager/learning/AUTOMATION.md +2 -2
  41. package/agents/roles/system/project-manager/learning/AUTOMATION.md +1 -1
  42. package/agents/roles/system/qa-engineer/learning/AUTOMATION.md +2 -2
  43. package/agents/roles/system/release-manager/learning/AUTOMATION.md +1 -1
  44. package/agents/roles/system/sales-representative/learning/AUTOMATION.md +2 -2
  45. package/agents/roles/system/security-engineer/learning/AUTOMATION.md +2 -2
  46. package/agents/roles/system/site-reliability-engineer/learning/AUTOMATION.md +2 -2
  47. package/agents/roles/system/software-architect/learning/AUTOMATION.md +2 -2
  48. package/agents/roles/system/solutions-engineer/learning/AUTOMATION.md +1 -1
  49. package/agents/roles/system/tech-lead/learning/AUTOMATION.md +2 -2
  50. package/agents/roles/system/technical-program-manager/learning/AUTOMATION.md +1 -1
  51. package/agents/roles/system/technical-writer/learning/AUTOMATION.md +1 -1
  52. package/agents/roles/system/treasury-analyst/learning/AUTOMATION.md +2 -2
  53. package/agents/roles/system/ui-designer/learning/AUTOMATION.md +3 -3
  54. package/agents/roles/system/user-researcher/learning/AUTOMATION.md +3 -3
  55. package/agents/roles/system/ux-designer/learning/AUTOMATION.md +3 -3
  56. package/automatization/hooks/README.md +10 -5
  57. package/automatization/runners/antigravity/README.md +4 -1
  58. package/automatization/runners/antigravity/hook.js +84 -22
  59. package/automatization/runners/antigravity/manifest.json +1 -0
  60. package/automatization/shared/acceptance.js +26 -0
  61. package/automatization/workflows/agent-propose.js +2 -2
  62. package/automatization/workflows/autobuild.js +121 -38
  63. package/engine/agents/learning-files.js +18 -0
  64. package/engine/agents/learning-seal.js +24 -12
  65. package/engine/agents/learning-sources.js +12 -10
  66. package/engine/agents/learning.js +29 -7
  67. package/engine/automation/index.js +11 -2
  68. package/engine/automation/registration.js +89 -0
  69. package/engine/cli/args.js +2 -2
  70. package/engine/cli/catalog.js +12 -12
  71. package/engine/cli/ops.js +2 -1
  72. package/engine/cli/validate.js +3 -0
  73. package/engine/cli/wiring.js +1 -1
  74. package/engine/config/validate.js +30 -11
  75. package/engine/core/migrations.js +275 -0
  76. package/engine/core/repos.js +18 -4
  77. package/engine/hooks/approval.js +38 -7
  78. package/engine/hooks/chat.js +97 -27
  79. package/engine/hooks/files.js +6 -104
  80. package/engine/hooks/input.js +67 -11
  81. package/engine/hooks/migrations.js +93 -0
  82. package/engine/hooks/push.js +8 -5
  83. package/engine/hooks/run.js +10 -5
  84. package/engine/hooks/self-approval.js +2 -2
  85. package/engine/hooks/shell.js +34 -0
  86. package/engine/hooks/verify.js +79 -22
  87. package/engine/planning/acceptance.js +18 -0
  88. package/engine/planning/contracts.js +78 -45
  89. package/engine/schemas/ops-config.schema.json +9 -0
  90. package/package.json +1 -1
  91. package/template/AGENTS.md +8 -5
  92. package/template/Makefile +1 -1
  93. package/template/planning/PROTOCOL.md +6 -2
@@ -79,6 +79,17 @@ function where(input) {
79
79
  return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file
80
80
  }
81
81
 
82
+ // Lo que el último mensaje de la persona negaba no quedó esperando su confirmación, así que un «dale» no
83
+ // lo aprobaría: ofrecerlo sería mandarla a contestar para volver a frenar (caso 188). Lo que sí lo pasa es
84
+ // pedirlo nombrándolo, que es una orden y no una confirmación.
85
+ function REFUSED(items, input = {}) {
86
+ return `Lo último que dijo la persona niega o frena ${items.join(', ')}, así que no quedó esperando su `
87
+ + 'confirmación y un sí no lo aprobaría: no reintentes. '
88
+ + (input.agent_id
89
+ ? 'Devolvele el bloqueo a quien te lanzó. '
90
+ : 'Si lo quiere, que lo pida en el chat nombrándolo. ')
91
+ }
92
+
82
93
  // Cómo se toma la salida angosta, dicho una vez porque lo dicen todos los bloqueos que la tienen. Lleva
83
94
  // las líneas exactas porque cada guard coteja la ruta en la forma que tiene a mano —absoluta la que llega
84
95
  // de un Write, relativa al repositorio la que sale del índice— y una línea en la otra forma no pega: sin
@@ -94,7 +105,9 @@ function where(input) {
94
105
  // que tiene a mano no sirve para pegar —por qué, en `secrets-shell.js` (caso 118)—. Lo frenado se anota
95
106
  // igual, así que el «dale» sigue cubriendo todo.
96
107
  function HOW(variable, lines, input, pasteable = lines) {
97
- const chat = CHAT.hold(input, lines)
108
+ const held = CHAT.hold(input, lines)
109
+ const dropped = held ? held.dropped : []
110
+ const chat = held && dropped.length < lines.length
98
111
  const stuck = lines.filter((one) => !pasteable.includes(one))
99
112
  // El alcance del «dale» va con la oferta y no después. La rama del pegado ya decía el suyo —«valen para
100
113
  // ese conjunto»— y la del chat no decía ninguno, siendo la que se ofrece primero. Las dos mitades que
@@ -103,16 +116,34 @@ function HOW(variable, lines, input, pasteable = lines) {
103
116
  // siguientes** es la dirección permisiva, la que nadie nota porque lo que no ocurre es un bloqueo.
104
117
  // `check` la muestra al final de la corrida —es lo que trajo el 117—, y al concederla no la decía nadie
105
118
  // (caso 170).
119
+ // Un subagente no puede esperar la respuesta —su turno termina antes—, así que lo que le toca es
120
+ // devolver el bloqueo: preguntar lo hace quien lo lanzó, y el reintento pasa aunque vuelva a ser delegado
121
+ // (caso 186).
122
+ const lead = input.agent_id
123
+ ? 'Sos un subagente y no podés esperar la respuesta: devolvele a quien te lanzó qué se frenó y por qué, '
124
+ + 'para que se lo pregunte a la persona. Si ella lo confirma, el mismo cambio pasa aunque lo reintente '
125
+ + 'un subagente. '
126
+ : 'Decile a la persona qué se frenó y por qué, y pedile que lo confirme con sus palabras: un «dale» '
127
+ + 'alcanza, pero no hace falta esa palabra. Si lo que contesta es un sí, reintentá el mismo cambio y '
128
+ + 'pasa; si duda, pregunta o dice que no, no reintentes. Juzgarlo te toca a vos: el guard sólo frena la '
129
+ + 'respuesta que niega, frena o pregunta. '
106
130
  const ask = chat
107
- ? 'Decile a la persona qué se frenó y por qué, y esperá: si contesta «dale», reintentá el mismo cambio y '
108
- + 'pasa. Ese «dale» cubre lo que se frenó y nada más —algo nuevo vuelve a frenar— y sigue valiendo en '
109
- + 'los mensajes siguientes hasta que ella lo niegue. '
131
+ ? lead + 'Esa confirmación cubre lo que se frenó y nada más —algo nuevo vuelve a frenar— y sigue valiendo '
132
+ + 'en los mensajes siguientes hasta que ella lo niegue. '
110
133
  : ''
134
+ const refused = dropped.length ? REFUSED(dropped, input) : ''
135
+ // Sin chat la salida es la misma, y también se dice como cosa de ella: el imperativo que el párrafo de
136
+ // arriba sacó de la rama con chat seguía acá, y es lo único que ve quien no tiene a nadie en el chat
137
+ // (caso 194). A quien corre el guard le toca otra cosa, y se le dice cuál. Vale también cuando hay persona
138
+ // pero no se le ofrece contestar, porque su último mensaje negaba todo lo frenado.
111
139
  const paste = pasteable.length
112
- ? (chat ? 'Si prefiere aprobarlo a mano, que pegue ella tal cual en' : 'Aprobalo pegando tal cual en')
140
+ ? (chat ? 'Si prefiere aprobarlo a mano, que pegue ella' : 'Esto lo aprueba una persona: que pegue ella')
141
+ + ' tal cual en'
113
142
  + ` ${where(input)} estas líneas:\n`
114
143
  + pasteable.map((line) => ` ${line}\n`).join('')
115
144
  + 'Valen para ese conjunto y dejan de valer en cuanto cambie. '
145
+ + (chat ? '' : 'Vos no lo escribas —un guard lo frena—: decí qué se frenó y dónde, y reintentá cuando '
146
+ + 'esté; si sos un subagente, devolvele el bloqueo a quien te lanzó. ')
116
147
  : ''
117
148
  const unresolved = stuck.length
118
149
  ? `Por archivo no hay línea que pegar para ${stuck.join(', ')}: la ruta llegó con una expansión del shell `
@@ -126,7 +157,7 @@ function HOW(variable, lines, input, pasteable = lines) {
126
157
  ? `La variable ${variable}=1 sigue existiendo y apaga el guard para toda la sesión, que es por lo que no `
127
158
  + 'es la vía recomendada.'
128
159
  : ''
129
- return ask + paste + unresolved + off
160
+ return ask + refused + paste + unresolved + off
130
161
  }
131
162
 
132
163
  // Las dos exenciones que sobreviven a un bloqueo, para que `check` las muestre juntas: la lista que una
@@ -151,4 +182,4 @@ function warnings(root) {
151
182
  return out
152
183
  }
153
184
 
154
- module.exports = { APPROVAL, lines, read, pending, pendingNow, where, HOW, warnings }
185
+ module.exports = { APPROVAL, lines, read, pending, pendingNow, where, HOW, REFUSED, warnings }
@@ -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,17 +159,19 @@ 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
 
160
- // Quien contesta a un bloqueo que quedó pendiente. Sólo el principio del mensaje: «dale» es la respuesta
161
- // entera o su primera palabra, no algo que aparece en medio de otra frase.
162
- const YES = new RegExp(String.raw`^\s*(?:s[ií]|dale|ok(?:ay)?|hac[eé]lo|hazlo|adelante|aprobado|apruebo`
163
- + String.raw`|aprob[aá]lo|de acuerdo|yes)(?![\p{L}])`, 'iu')
166
+ // Quien contesta a un bloqueo que quedó pendiente lo confirma con sus palabras, y juzgar si lo que dijo
167
+ // es un sí le toca al agente que lo lee. Antes el guard exigía que el mensaje **empezara** con una de once
168
+ // formas —«dale», «sí», «ok»…—, así que «listo», «claro», «confirmo» o «bueno dale» volvían a frenar lo
169
+ // que la persona acababa de aprobar (caso 184). Lo único que el guard sigue decidiendo es la dirección
170
+ // segura: un mensaje que niega, que arranca frenando o que pregunta no aprueba nada. Si eso se equivoca,
171
+ // el costo es volver a preguntar; si se equivocara al revés, un «no» o un «¿para qué sirve?» ejecutaría lo
172
+ // que la persona no aprobó.
173
+ const HALT = /^\s*(?:par[aá]|deten\p{L}*|esper[aá]|cancel\p{L}*|stop|wait|hold on)(?![\p{L}])/iu
174
+ const refuses = (text) => NEGATION.test(text) || HALT.test(text) || /[?¿]/.test(text)
164
175
 
165
176
  // El hook de mensaje. Nunca frena: un mensaje de la persona no se bloquea, y sin registro los guards
166
177
  // deciden como antes. Un texto que empieza con una etiqueta no lo escribió una persona —Claude avisa así
@@ -175,9 +186,16 @@ function record(input) {
175
186
  const text = String(input.prompt || '')
176
187
  const human = !process.env.CI && !/^\s*</.test(text)
177
188
  const previous = load(input.session_id)
178
- const approved = human && previous && YES.test(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)
179
193
  ? previous.pending.filter((item) => !mentions(text, item).denied)
180
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 || []
181
199
  const granted = previous ? (previous.granted || []).filter((one) => !mentions(text, one).denied) : []
182
200
  // El acote viaja con lo concedido: lo que se negó pierde las dos cosas a la vez, y nada queda con un
183
201
  // alcance que ya no acota a nadie.
@@ -191,12 +209,20 @@ function record(input) {
191
209
  // ya era cierto. Lo vuelve a decidir el mensaje humano siguiente (caso 166).
192
210
  const flow = flowCommand(text)
193
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
194
220
  fs.mkdirSync(DIR, { recursive: true })
195
221
  // Sobre qué instancia se está hablando, que es lo que después deja filtrar lo concedido: por qué hace
196
222
  // falta, en `grantedIn`.
197
223
  fs.writeFileSync(recordPath(input.session_id), JSON.stringify(
198
- { id: idOf(input), text, human, askable, flow, root: opsRoot(input), approved, granted,
199
- scopes, pending: [] }))
224
+ { id: idOf(input), text, spoken, human, askable, flow, root: opsRoot(input), approved, granted,
225
+ scopes, pending }))
200
226
  } catch { /* registrar es un extra: si falla, los guards siguen frenando lo que frenaban */ }
201
227
  }
202
228
 
@@ -219,8 +245,12 @@ function said(input) {
219
245
  //
220
246
  // Un registro escrito antes de que `askable` existiera no lo trae y queda afuera: la sesión pierde la
221
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).
222
252
  function present(input) {
223
- if (process.env.CI || input.agent_id || !input.session_id) return null
253
+ if (process.env.CI || !input.session_id) return null
224
254
  const saved = load(input.session_id)
225
255
  return saved && saved.askable ? saved : null
226
256
  }
@@ -270,18 +300,18 @@ function why(saved, item, asked, inherit) {
270
300
  // antes que de menos.
271
301
  function grant(input, saved, entries) {
272
302
  const before = saved.granted || []
273
- const nuevas = entries.filter((one) => !before.includes(one.item))
274
- if (!nuevas.length) return
303
+ const added = entries.filter((one) => !before.includes(one.item))
304
+ if (!added.length) return
275
305
  const grantedAt = new Date().toISOString()
276
306
  try {
277
- saved.granted = [...before, ...nuevas.map((one) => one.item)]
307
+ saved.granted = [...before, ...added.map((one) => one.item)]
278
308
  saved.scopes = { ...(saved.scopes || {}) }
279
- 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
280
310
  fs.writeFileSync(recordPath(input.session_id), JSON.stringify(saved))
281
311
  } catch { /* sin anotarlo, se vuelve a pedir */ }
282
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
283
313
  // auditoría pregunta es quién concedió qué y con qué alcance, meses después (caso 127).
284
- TRAIL.append(saved.root, LOG, nuevas.map((one) => ({
314
+ TRAIL.append(saved.root, LOG, added.map((one) => ({
285
315
  grantedAt, item: one.item, scope: one.scope || null, via: one.via, session: input.session_id || null,
286
316
  })))
287
317
  }
@@ -296,14 +326,34 @@ function grant(input, saved, entries) {
296
326
  // en el mensaje en curso o aprobó con un «dale» (caso 119).
297
327
  function authorized(input, items, { asked = named, inherit = true } = {}) {
298
328
  const saved = said(input)
299
- if (!saved) return []
329
+ if (!saved) return confirmed(input, items).map(({ item, via }) => ({ item, via }))
300
330
  return items.map((item) => ({ item, via: why(saved, item, asked, inherit) })).filter((one) => one.via)
301
331
  }
302
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
+
303
344
  // Lo que la persona no autorizó de lo que un guard está por frenar; lo que sí, queda concedido.
304
345
  function unauthorized(input, items) {
305
346
  const saved = said(input)
306
- 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
+ }
307
357
  const passed = items.map((item) => ({ item, via: why(saved, item, named, true) })).filter((one) => one.via)
308
358
  // El alcance sale del mensaje cuando es éste el que lo concede, y del registro cuando se hereda: una
309
359
  // orden vieja no se reinterpreta contra un texto que no la nombraba.
@@ -322,15 +372,35 @@ function unauthorizedNow(input, items) {
322
372
  return items.filter((item) => !cleared.has(item))
323
373
  }
324
374
 
325
- // Lo que quedó frenado, para que un «dale» en el mensaje siguiente apruebe exactamente eso y nada más.
326
- // Devuelve si hay una persona a quien preguntarle.
375
+ // Lo que quedó frenado, para que la confirmación del mensaje siguiente apruebe exactamente eso y nada más.
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.
384
+ //
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.
327
390
  function hold(input, items) {
328
391
  const saved = present(input)
329
392
  if (!saved) return false
330
393
  try {
331
- saved.pending = [...new Set([...saved.pending, ...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))
401
+ saved.pending = [...new Set([...saved.pending, ...open])]
332
402
  fs.writeFileSync(recordPath(input.session_id), JSON.stringify(saved))
333
- return true
403
+ return { dropped }
334
404
  } catch { return false }
335
405
  }
336
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,
@@ -24,29 +23,6 @@ const { TEMPLATE_PREFIXES } = require('../core/ownership')
24
23
  // el guard hasta 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) {
@@ -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,