@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.
- package/CHANGELOG.md +113 -0
- package/agents/roles/system/ai-product-manager/learning/HISTORY.md +1 -0
- package/agents/roles/system/analytics-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/backend-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/cloud-architect/learning/HISTORY.md +1 -0
- package/agents/roles/system/customer-success-manager/learning/HISTORY.md +1 -0
- package/agents/roles/system/data-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/data-governance-steward/learning/HISTORY.md +1 -0
- package/agents/roles/system/devops-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/engineering-manager/learning/HISTORY.md +1 -0
- package/agents/roles/system/engineering-manager/learning/sources.yaml +1 -1
- package/agents/roles/system/engineering-manager/references/operating-model.md +1 -1
- package/agents/roles/system/machine-learning-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/mlops-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/mobile-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/product-manager/learning/HISTORY.md +1 -0
- package/agents/roles/system/site-reliability-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/software-architect/learning/HISTORY.md +1 -0
- package/agents/roles/system/ui-designer/learning/HISTORY.md +1 -0
- package/automatization/hooks/README.md +6 -2
- package/automatization/runners/antigravity/README.md +4 -1
- package/automatization/runners/antigravity/hook.js +84 -22
- package/automatization/runners/antigravity/manifest.json +1 -0
- package/automatization/shared/acceptance.js +26 -0
- package/automatization/workflows/agent-promote.js +3 -1
- package/automatization/workflows/autobuild.js +69 -26
- package/engine/agents/learning-seal.js +20 -1
- package/engine/automation/index.js +11 -2
- package/engine/automation/registration.js +89 -0
- package/engine/cli/args.js +1 -1
- package/engine/cli/catalog.js +8 -2
- package/engine/cli/ops.js +1 -1
- package/engine/cli/validate.js +3 -0
- package/engine/cli/wiring.js +1 -1
- package/engine/config/validate.js +30 -11
- package/engine/core/changelog.js +60 -1
- package/engine/core/migrations.js +275 -0
- package/engine/core/repos.js +18 -4
- package/engine/hooks/approval.js +77 -14
- package/engine/hooks/chat.js +85 -27
- package/engine/hooks/files.js +9 -107
- package/engine/hooks/input.js +67 -11
- package/engine/hooks/migrations.js +93 -0
- package/engine/hooks/push.js +5 -3
- package/engine/hooks/run.js +10 -5
- package/engine/hooks/secrets-shell.js +2 -1
- package/engine/hooks/self-approval.js +39 -22
- package/engine/hooks/verify.js +79 -22
- package/engine/planning/acceptance.js +18 -0
- package/engine/planning/contracts.js +78 -45
- package/engine/schemas/ops-config.schema.json +9 -0
- package/package.json +1 -1
- package/template/AGENTS.md +11 -2
- package/template/planning/PROTOCOL.md +6 -2
package/engine/hooks/chat.js
CHANGED
|
@@ -126,11 +126,11 @@ function mentions(text, item) {
|
|
|
126
126
|
})
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
|
-
const
|
|
129
|
+
const asked = found.filter((one) => one.asked && !one.denied)
|
|
130
130
|
return {
|
|
131
|
-
named:
|
|
131
|
+
named: asked.length > 0,
|
|
132
132
|
denied: found.some((one) => one.denied),
|
|
133
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
279
|
-
if (!
|
|
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, ...
|
|
307
|
+
saved.granted = [...before, ...added.map((one) => one.item)]
|
|
283
308
|
saved.scopes = { ...(saved.scopes || {}) }
|
|
284
|
-
for (const one of
|
|
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,
|
|
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)
|
|
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
|
|
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
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
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
|
|
342
|
-
const
|
|
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
|
|
403
|
+
return { dropped }
|
|
346
404
|
} catch { return false }
|
|
347
405
|
}
|
|
348
406
|
|
package/engine/hooks/files.js
CHANGED
|
@@ -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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
24
|
-
//
|
|
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
|
|
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
|
-
: `${
|
|
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
|
-
|
|
286
|
+
engineWrites,
|
|
385
287
|
}
|
package/engine/hooks/input.js
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
142
|
+
function blocked(message) {
|
|
91
143
|
const error = new Error(message)
|
|
92
144
|
error.blocked = true
|
|
93
|
-
|
|
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 }
|
package/engine/hooks/push.js
CHANGED
|
@@ -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
|
|
117
|
-
const
|
|
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('')
|
package/engine/hooks/run.js
CHANGED
|
@@ -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
|
|
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
|
|
149
|
-
+ '
|
|
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
|
-
|
|
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 = {
|