@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.
- package/CHANGELOG.md +162 -0
- package/README.md +1 -0
- package/agents/roles/system/accounting-specialist/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/ai-governance-lead/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/ai-product-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/analytics-engineer/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/backend-engineer/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/business-strategist/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/cloud-architect/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/community-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/content-specialist/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/customer-success-manager/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/customer-support-specialist/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/data-analyst/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/data-engineer/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/data-governance-steward/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/data-scientist/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/database-administrator/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/developer-relations-engineer/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/devops-engineer/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/engineering-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/financial-controller/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/finops-engineer/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/fraud-risk-analyst/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/frontend-engineer/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/growth-marketer/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/implementation-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/integrations-engineer/learning/AUTOMATION.md +3 -3
- package/agents/roles/system/kyc-aml-specialist/learning/AUTOMATION.md +3 -3
- package/agents/roles/system/legal-counsel/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/logistics-operations-manager/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/machine-learning-engineer/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/mlops-engineer/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/mobile-engineer/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/partnerships-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/people-operations-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/privacy-compliance-specialist/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/procurement-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/product-manager/learning/AUTOMATION.md +3 -3
- package/agents/roles/system/product-marketing-manager/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/project-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/qa-engineer/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/release-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/sales-representative/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/security-engineer/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/site-reliability-engineer/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/software-architect/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/solutions-engineer/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/tech-lead/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/technical-program-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/technical-writer/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/treasury-analyst/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/ui-designer/learning/AUTOMATION.md +3 -3
- package/agents/roles/system/user-researcher/learning/AUTOMATION.md +3 -3
- package/agents/roles/system/ux-designer/learning/AUTOMATION.md +3 -3
- package/automatization/hooks/README.md +10 -5
- 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-propose.js +2 -2
- package/automatization/workflows/autobuild.js +121 -38
- package/engine/agents/learning-files.js +18 -0
- package/engine/agents/learning-seal.js +24 -12
- package/engine/agents/learning-sources.js +12 -10
- package/engine/agents/learning.js +29 -7
- package/engine/automation/index.js +11 -2
- package/engine/automation/registration.js +89 -0
- package/engine/cli/args.js +2 -2
- package/engine/cli/catalog.js +12 -12
- package/engine/cli/ops.js +2 -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/migrations.js +275 -0
- package/engine/core/repos.js +18 -4
- package/engine/hooks/approval.js +38 -7
- package/engine/hooks/chat.js +97 -27
- package/engine/hooks/files.js +6 -104
- package/engine/hooks/input.js +67 -11
- package/engine/hooks/migrations.js +93 -0
- package/engine/hooks/push.js +8 -5
- package/engine/hooks/run.js +10 -5
- package/engine/hooks/self-approval.js +2 -2
- package/engine/hooks/shell.js +34 -0
- 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 +8 -5
- package/template/Makefile +1 -1
- package/template/planning/PROTOCOL.md +6 -2
package/engine/hooks/approval.js
CHANGED
|
@@ -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
|
|
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
|
-
? '
|
|
108
|
-
+ '
|
|
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
|
|
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 }
|
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,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
|
-
|
|
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
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
274
|
-
if (!
|
|
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, ...
|
|
307
|
+
saved.granted = [...before, ...added.map((one) => one.item)]
|
|
278
308
|
saved.scopes = { ...(saved.scopes || {}) }
|
|
279
|
-
for (const one of
|
|
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,
|
|
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)
|
|
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
|
|
326
|
-
// Devuelve si hay
|
|
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
|
-
|
|
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
|
|
403
|
+
return { dropped }
|
|
334
404
|
} catch { return false }
|
|
335
405
|
}
|
|
336
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,
|
|
@@ -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
|
|
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,
|