@ingeniomaps/cauce 0.96.0 → 0.98.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 +173 -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 +4 -3
- package/automatization/workflows/agent-propose.js +2 -2
- package/automatization/workflows/autobuild.js +219 -29
- package/automatization/workflows/flow.js +30 -14
- 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/cli/args.js +2 -2
- package/engine/cli/catalog.js +12 -12
- package/engine/cli/ops.js +2 -1
- package/engine/cli/planning.js +4 -1
- package/engine/hooks/approval.js +5 -3
- package/engine/hooks/chat.js +19 -7
- package/engine/hooks/push.js +3 -2
- package/engine/hooks/self-approval.js +2 -2
- package/engine/hooks/shell.js +63 -6
- package/engine/planning/contracts.js +19 -0
- package/engine/planning/parser.js +35 -0
- package/package.json +1 -1
- package/template/AGENTS.md +5 -4
- package/template/Makefile +1 -1
- package/template/planning/rules/system/commits.md +9 -2
|
@@ -111,16 +111,30 @@ const STAGE = {
|
|
|
111
111
|
properties: {
|
|
112
112
|
gate: { type: 'string', enum: ['cumplido', 'con-condiciones', 'no-cumplido'] },
|
|
113
113
|
// La ruta del análisis, no el análisis. Ver el comentario de arriba: mientras el campo pudo
|
|
114
|
-
// contener el texto entero, lo contuvo, y la respuesta no llegaba.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
// contener el texto entero, lo contuvo, y la respuesta no llegaba. Es la única excepción al techo:
|
|
115
|
+
// una ruta se acota sola.
|
|
116
|
+
analysis: { type: 'string' },
|
|
117
|
+
// Y el resto lleva su techo por la misma razón, que el arreglo de `analysis` no alcanzó a cubrir: la
|
|
118
|
+
// etapa que **frena** escribe largo en `missing` y `humanAction` —lo dice el párrafo de arriba y lo
|
|
119
|
+
// volvió a mostrar una corrida sobre un banco, con los cinco reintentos envueltos—. Sin tope, cada
|
|
120
|
+
// campo nuevo nace sin él y lo que revienta es la etapa que menos se ejercita.
|
|
121
|
+
//
|
|
122
|
+
// Los números salen de lo que el prompt ya pedía —`summary` en 150 palabras o menos, que es del orden
|
|
123
|
+
// de mil caracteres— y de R16: entre etapas viaja lo que la siguiente necesita para decidir, no todo
|
|
124
|
+
// lo que la anterior produjo. Lo que no entra no se pierde: vive en el archivo de análisis.
|
|
125
|
+
//
|
|
126
|
+
// Un techo por debajo de lo que el prompt pide no acota: contradice. Medido en una corrida real con
|
|
127
|
+
// `summary` en 400, el agente achicó en cada reintento —2766, 2078, 2064, 1934, 1854— y murió
|
|
128
|
+
// convergiendo hacia un número siete veces menor que el que la misma instrucción le pedía escribir.
|
|
129
|
+
summary: { type: 'string', maxLength: 1000 },
|
|
130
|
+
evidence: { type: 'array', maxItems: 5, items: { type: 'string', maxLength: 200 } },
|
|
131
|
+
assumptions: { type: 'array', maxItems: 4, items: { type: 'string', maxLength: 160 } },
|
|
132
|
+
openQuestions: { type: 'array', maxItems: 4, items: { type: 'object', additionalProperties: false,
|
|
119
133
|
required: ['detail', 'blocking'],
|
|
120
|
-
properties: { detail: { type: 'string' }, blocking: { type: 'boolean' } },
|
|
134
|
+
properties: { detail: { type: 'string', maxLength: 200 }, blocking: { type: 'boolean' } },
|
|
121
135
|
} },
|
|
122
|
-
missing: { type: 'string' },
|
|
123
|
-
humanAction: { type: 'string' },
|
|
136
|
+
missing: { type: 'string', maxLength: 500 },
|
|
137
|
+
humanAction: { type: 'string', maxLength: 500 },
|
|
124
138
|
},
|
|
125
139
|
}
|
|
126
140
|
// Tres salidas porque el contrato del equipo enumera tres —«hacer, no hacer o investigar»— y con un
|
|
@@ -281,14 +295,16 @@ const runStage = (stage, index) => {
|
|
|
281
295
|
`condiciona la decisión siguiente. ` +
|
|
282
296
|
`Escribí primero tu análisis completo en ${REPORTS} como ${stage.id}-analisis.md —ahí no hay ` +
|
|
283
297
|
`límite de extensión y es lo que lee quien sintetiza al final— y devolvé esa ruta en analysis.\n` +
|
|
284
|
-
`Lo que devolvés en el esquema es corto
|
|
285
|
-
`
|
|
286
|
-
`
|
|
298
|
+
`Lo que devolvés en el esquema es corto y cada campo tiene su tope, que el esquema rechaza si lo `
|
|
299
|
+
+ `pasás: summary 1000 caracteres, missing y humanAction 500, cada evidencia 200. Si algo no entra, `
|
|
300
|
+
+ `va al archivo y en el campo queda lo esencial. El tope rige sobre todo cuando el gate no se `
|
|
301
|
+
+ `cumple, que es cuando más se escribe. ` +
|
|
287
302
|
`Devolvé los campos directamente: nunca envuelvas la respuesta en {"raw": ..., "len": ...} ni ` +
|
|
288
303
|
`mandes el JSON como string adentro de un campo, porque eso no valida. ` +
|
|
289
|
-
`En summary va, en
|
|
290
|
-
`
|
|
291
|
-
`
|
|
304
|
+
`En summary va, en los 1000 caracteres que el esquema admite, lo que la etapa siguiente necesita ` +
|
|
305
|
+
`para decidir —no un resumen de tu análisis, sino lo que le cambia el trabajo—, porque eso se le ` +
|
|
306
|
+
`reenvía a cada etapa posterior. El límite va en caracteres y no en palabras porque es lo que el ` +
|
|
307
|
+
`esquema mide.`,
|
|
292
308
|
{ schema: STAGE, label: `stage:${stage.id}` })
|
|
293
309
|
}
|
|
294
310
|
|
|
@@ -138,6 +138,23 @@ function proposalState(text) {
|
|
|
138
138
|
return frontmatterState(text, 'proposed')
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
// Las propuestas que alguien descartó diciendo por qué. Es lo que el informe siguiente necesita para no
|
|
142
|
+
// volver a recomendar lo mismo: sin el motivo escrito, una archivada no se distingue de un olvido, y el
|
|
143
|
+
// hallazgo vuelve cada semana con el mismo texto — pasó con cuatro cargos en septiembre de 2026.
|
|
144
|
+
//
|
|
145
|
+
// Lee el documento y no el historial: la fila de `HISTORY.md` recorta la celda a 160 caracteres, y un
|
|
146
|
+
// motivo cortado a la mitad es justo el que se malinterpreta.
|
|
147
|
+
const REASON_LINE = /^-[ \t]*Motivo:[ \t]*(.+)$/m
|
|
148
|
+
|
|
149
|
+
function discardedProposals(dir) {
|
|
150
|
+
return proposalFiles(dir).map((name) => {
|
|
151
|
+
const text = fs.readFileSync(path.join(dir, name), 'utf8')
|
|
152
|
+
const reason = (text.match(REASON_LINE) || [])[1]
|
|
153
|
+
if (proposalState(text) !== 'archived' || !reason) return null
|
|
154
|
+
return { name, reason: reason.trim() }
|
|
155
|
+
}).filter(Boolean)
|
|
156
|
+
}
|
|
157
|
+
|
|
141
158
|
// El sufijo `-N` es la segunda corrida del mismo día, y es la que trae el veredicto más nuevo. Sin él
|
|
142
159
|
// en el patrón, más de una cuarta parte de los registros que existían —de cargos y de recorridos—
|
|
143
160
|
// quedaban fuera del ciclo: no entraban a ninguna propuesta y nada lo delataba, que es el modo de fallo
|
|
@@ -159,4 +176,5 @@ module.exports = {
|
|
|
159
176
|
REQUIRED_SECTIONS, SUMMARY_MAX, PROPOSAL_NAME, REPORT_NAME, undecided, blankProposal, SIGNED, CLOSED,
|
|
160
177
|
assertWritableTeam, assertWritable, isoDate, month,
|
|
161
178
|
proposalOrder, proposalFiles, frontmatterState, proposalState, reportFiles, lastOfPeriod,
|
|
179
|
+
discardedProposals,
|
|
162
180
|
}
|
|
@@ -93,7 +93,7 @@ function proposalFile(root, agent, period = '', kind = 'agent') {
|
|
|
93
93
|
return path.join(dir, name)
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
function archive(root, agent, period = '', kind = 'agent') {
|
|
96
|
+
function archive(root, agent, period = '', kind = 'agent', reason = '') {
|
|
97
97
|
const file = proposalFile(root, agent, period, kind)
|
|
98
98
|
const text = fs.readFileSync(file, 'utf8')
|
|
99
99
|
const state = proposalState(text)
|
|
@@ -116,31 +116,43 @@ function archive(root, agent, period = '', kind = 'agent') {
|
|
|
116
116
|
// que `claim` usa para decir de quién es una tarea. Sin esto la propuesta quedaba en «por definir» y no
|
|
117
117
|
// había forma de distinguir una decisión de un olvido.
|
|
118
118
|
const responsible = owner(root) || 'sin identificar'
|
|
119
|
-
|
|
119
|
+
// Descartar lo que alguien decidió exige decir por qué, y el motivo va al documento porque es lo que lee
|
|
120
|
+
// el informe siguiente (`discardedProposals`). Sin él el hallazgo volvía cada semana: nada distinguía
|
|
121
|
+
// «se miró y no va» de «nadie lo miró», y el cargo no tenía cómo saber que ya estaba contestado. Una
|
|
122
|
+
// propuesta con el molde intacto no lo exige: ahí no hubo decisión que explicar, y lo que traía sigue
|
|
123
|
+
// vivo para la próxima — a menos que quien archiva sí haya mirado los hallazgos y lo diga.
|
|
124
|
+
const why = reason.replace(/\s+/g, ' ').trim()
|
|
125
|
+
const blank = undecided(change)
|
|
126
|
+
if (!blank && !why) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`${path.basename(file)} decide un cambio: descartarlo pide --reason "<por qué no va>". `
|
|
129
|
+
+ 'El motivo es lo que evita que el próximo informe lo vuelva a recomendar.',
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
const stamped = text
|
|
120
133
|
.replace(/^status:\s*\S+\s*$/m, 'status: archived')
|
|
121
134
|
// `UNSEALED` y no sólo «pendiente»: desde que se puede archivar una firmada sin decidir, el cuerpo
|
|
122
135
|
// llega diciendo «aprobada» y se quedaba así con el frontmatter en `archived`. Es la contradicción
|
|
123
136
|
// que el comentario de `seal` nombra, por el otro destino.
|
|
124
137
|
.replace(UNSEALED, '- Estado: archivada')
|
|
125
138
|
.replace(/^-[ \t]*Responsable:[ \t]*por definir[ \t]*$/mi, `- Responsable: ${responsible}`)
|
|
126
|
-
.replace(/^-[ \t]*Fecha:[ \t]*por definir[ \t]*$/mi, `- Fecha: ${isoDate(new Date())}`)
|
|
139
|
+
.replace(/^-[ \t]*Fecha:[ \t]*por definir[ \t]*$/mi, `- Fecha: ${isoDate(new Date())}`)
|
|
140
|
+
atomicWrite(file, why ? stamped.replace(/^(-[ \t]*Estado:[ \t]*archivada[ \t]*)$/m, `$1\n- Motivo: ${why}`) : stamped)
|
|
127
141
|
// La fila va para los dos tipos, y no sólo para los recorridos como en `seal`: allá los cargos los
|
|
128
142
|
// registra `agent-promote` al aplicar, y archivar no pasa por ningún workflow que lo haga.
|
|
129
143
|
// La raíz del cargo, que es `<cargo>/learning/proposals/<archivo>` sin sus tres últimos tramos:
|
|
130
144
|
// `appendHistory` agrega `learning/` por su cuenta.
|
|
131
145
|
//
|
|
132
146
|
// La celda del cambio lleva el criterio y no queda vacía, para que la fila no se lea como un registro a
|
|
133
|
-
// medias. Son dos y dicen cosas distintas:
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
const
|
|
138
|
-
appendHistory(path.dirname(path.dirname(path.dirname(file))), file, responsible,
|
|
139
|
-
blank ? 'Se archivó sin decidir: el documento quedó con el molde.' : 'Se miró y no cambia nada.',
|
|
140
|
-
'archivada')
|
|
147
|
+
// medias. Son dos y dicen cosas distintas: descartar con motivo **es** decidir, y archivar un documento
|
|
148
|
+
// que nadie decidió es tirar el andamio — la firma se gastó sobre el molde intacto y no hubo nada que
|
|
149
|
+
// aprobar. Escribir el primero sobre el segundo pondría en el historial que se miró algo que nadie
|
|
150
|
+
// miró, y ese archivo es lo que alguien lee dentro de seis meses.
|
|
151
|
+
const verdict = why ? `Descartada: ${why}` : 'Se archivó sin decidir: el documento quedó con el molde.'
|
|
152
|
+
appendHistory(path.dirname(path.dirname(path.dirname(file))), file, responsible, verdict, 'archivada')
|
|
141
153
|
// `blank` sale acá y no lo recalcula quien imprime: son la misma pregunta, y dos lecturas de la misma
|
|
142
154
|
// sección se separan sin que nada falle.
|
|
143
|
-
return { file, already: false, blank }
|
|
155
|
+
return { file, already: false, blank, reason: why }
|
|
144
156
|
}
|
|
145
157
|
|
|
146
158
|
// Una fila por propuesta cerrada, cualquiera sea el destino. El cambio va en una línea: el documento
|
|
@@ -52,15 +52,17 @@ function evaluateTeam(root, slug) {
|
|
|
52
52
|
// un nombre compuesto. Cuando eran uno solo el catálogo llegó a 51 etiquetas para estas seis.
|
|
53
53
|
const SOURCE_TIERS = ['advisory', 'platform', 'project', 'regulation', 'standard', 'profession']
|
|
54
54
|
|
|
55
|
-
// Cada cuánto vale la pena volver a mirar cada tipo.
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
55
|
+
// Cada cuánto vale la pena volver a mirar cada tipo. La cadencia de un cargo la fija su fuente más
|
|
56
|
+
// rápida, y la más rápida es mensual aunque un aviso publique todos los días: lo que el ciclo produce es
|
|
57
|
+
// un cambio de contrato, que se firma una vez al mes y llega a cada empresa con `upgrade`, así que
|
|
58
|
+
// enterarse el día 7 en vez del 24 no adelanta nada. Hubo una cadencia semanal y se midió (caso 182):
|
|
59
|
+
// en tres semanas, ~353 hallazgos, ninguno urgente, ninguno aplicado y la mitad repetidos.
|
|
60
|
+
// Una norma, en cambio, se revisa por edición, y la profesión cambia más despacio que las dos.
|
|
59
61
|
const TIER_CADENCE = {
|
|
60
|
-
advisory: '
|
|
62
|
+
advisory: 'mensual', platform: 'mensual', project: 'mensual',
|
|
61
63
|
regulation: 'mensual', standard: 'mensual', profession: 'trimestral',
|
|
62
64
|
}
|
|
63
|
-
const CADENCES = ['
|
|
65
|
+
const CADENCES = ['mensual', 'trimestral']
|
|
64
66
|
|
|
65
67
|
// Sale del árbol y no de una lista escrita a mano, por la misma razón que la matriz del cron sale del
|
|
66
68
|
// árbol: una lista paralela se pudre el día que un cargo cambia sus fuentes y nadie la toca.
|
|
@@ -79,7 +81,7 @@ function sourceTiers(text) {
|
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
// El cuerpo de `sources:` termina donde empieza `pending:`. Sin este corte, una pendiente entraba como
|
|
82
|
-
// fuente declarada y
|
|
84
|
+
// fuente declarada y cada corrida la reportaba rota, una vez tras otra — que es exactamente el
|
|
83
85
|
// aviso permanente que la lista existe para no producir.
|
|
84
86
|
function sourcesBody(text) {
|
|
85
87
|
if (!text.includes('sources:')) return ''
|
|
@@ -207,8 +209,8 @@ function evaluate(root, agent) {
|
|
|
207
209
|
const sourcesFile = path.join(target, 'learning', 'sources.yaml')
|
|
208
210
|
if (fs.existsSync(sourcesFile)) {
|
|
209
211
|
const tiers = sourceTiers(fs.readFileSync(sourcesFile, 'utf8'))
|
|
210
|
-
// Sin fuentes el ciclo
|
|
211
|
-
//
|
|
212
|
+
// Sin fuentes el ciclo no tiene literatura que leer y devuelve un informe vacío en cada
|
|
213
|
+
// corrida. Avisa y no bloquea: un cargo que se está escribiendo todavía no las tiene.
|
|
212
214
|
if (!tiers.length) warnings.push('sources.yaml sin fuentes: la investigación no tiene qué leer')
|
|
213
215
|
for (const tier of tiers) {
|
|
214
216
|
if (!SOURCE_TIERS.includes(tier)) {
|
|
@@ -234,7 +236,7 @@ function evaluate(root, agent) {
|
|
|
234
236
|
errors.push(`sources.yaml: una pendiente no declara ${missing.join(' ni ')}`
|
|
235
237
|
+ `${one.name ? ` (${one.name})` : ''}`)
|
|
236
238
|
}
|
|
237
|
-
// Declarada y pendiente a la vez es una contradicción que el chequeo
|
|
239
|
+
// Declarada y pendiente a la vez es una contradicción que el chequeo de cada corrida no puede resolver:
|
|
238
240
|
// la reportaría rota como fuente y recuperada como pendiente en la misma corrida.
|
|
239
241
|
if (one.url && byUrl.has(one.url)) {
|
|
240
242
|
errors.push(`sources.yaml: ${one.url} está declarada como fuente y también como pendiente`)
|
|
@@ -9,6 +9,7 @@ const { atomicWrite } = require('../core/files')
|
|
|
9
9
|
const {
|
|
10
10
|
REQUIRED_SECTIONS, SUMMARY_MAX, PROPOSAL_NAME, REPORT_NAME, assertWritableTeam, assertWritable, lastOfPeriod,
|
|
11
11
|
isoDate, month, proposalOrder, proposalFiles, frontmatterState, proposalState, reportFiles, CLOSED,
|
|
12
|
+
discardedProposals,
|
|
12
13
|
} = require('./learning-files')
|
|
13
14
|
// Cerrar una propuesta vive en su propio módulo; se reexporta para no mover a cada llamador.
|
|
14
15
|
const { seal, archive } = require('./learning-seal')
|
|
@@ -42,7 +43,8 @@ function markConsolidated(file) {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
function prepareReport(root, agent, now = new Date()) {
|
|
45
|
-
const
|
|
46
|
+
const learning = path.join(assertWritable(root, agent), 'learning')
|
|
47
|
+
const reports = path.join(learning, 'reports')
|
|
46
48
|
const file = path.join(reports, `${isoDate(now)}.md`)
|
|
47
49
|
fs.mkdirSync(reports, { recursive: true })
|
|
48
50
|
if (fs.existsSync(file)) return { file, created: false }
|
|
@@ -61,8 +63,8 @@ status: draft
|
|
|
61
63
|
propone: por-completar
|
|
62
64
|
---
|
|
63
65
|
|
|
64
|
-
# Investigación
|
|
65
|
-
|
|
66
|
+
# Investigación — ${isoDate(now)}
|
|
67
|
+
${decided(discardedProposals(path.join(learning, 'proposals')))}
|
|
66
68
|
<!-- Tres convenciones que el ciclo necesita y que nada más sostiene:
|
|
67
69
|
|
|
68
70
|
· Etiquetá cada hallazgo H1, H2, … en el orden en que aparecen. «Evidencia» y «Recomendación» se
|
|
@@ -96,8 +98,28 @@ propuesta consolidada. -->
|
|
|
96
98
|
return { file, created: true }
|
|
97
99
|
}
|
|
98
100
|
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
+
// Lo que ya se descartó, dicho donde el cargo lo va a leer: en el informe que está por completar, y no
|
|
102
|
+
// en su prompt ni en el `AUTOMATION.md` de cada cargo. Un prompt lo repetiría en cincuenta archivos, y
|
|
103
|
+
// basta uno que no lo copie para que ese cargo vuelva a proponer lo descartado en cada corrida.
|
|
104
|
+
//
|
|
105
|
+
// Va en un comentario, fuera de toda sección, por lo mismo que las convenciones de abajo: dentro de
|
|
106
|
+
// «Recomendación» viajaría a la propuesta mensual.
|
|
107
|
+
function decided(discarded) {
|
|
108
|
+
if (!discarded.length) return ''
|
|
109
|
+
const lines = discarded.map(({ name, reason }) => ` · learning/proposals/${name}: ${reason}`)
|
|
110
|
+
return `
|
|
111
|
+
<!-- Ya se decidió no aplicar estas propuestas. Lo que proponían está en cada archivo, en «Cambio
|
|
112
|
+
propuesto» y «Hallazgos»; el motivo, acá:
|
|
113
|
+
|
|
114
|
+
${lines.join('\n')}
|
|
115
|
+
|
|
116
|
+
No vuelvas a recomendar lo descartado. Si encontrás un hecho que el motivo no contemplaba —una versión,
|
|
117
|
+
una fecha, una norma nueva—, recomendalo nombrando la propuesta y qué cambió desde entonces. -->
|
|
118
|
+
`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Abre la revisión siguiente. No vuelve a consolidar los informes de investigación: ya se consolidaron
|
|
122
|
+
// en la propuesta que ésta corrige, y repetirlos haría que el mismo hallazgo entre dos veces al contrato. El
|
|
101
123
|
// insumo de una revisión es otro —qué mostró la evaluación posterior a aplicar—, y por eso el molde
|
|
102
124
|
// pregunta eso y no otra cosa.
|
|
103
125
|
//
|
|
@@ -394,11 +416,11 @@ automatic_apply: false
|
|
|
394
416
|
|
|
395
417
|
## Hallazgos
|
|
396
418
|
|
|
397
|
-
${summaries.join('\n\n') || 'No hay informes
|
|
419
|
+
${summaries.join('\n\n') || 'No hay informes de investigación para este período.'}
|
|
398
420
|
|
|
399
421
|
## Evidencia
|
|
400
422
|
|
|
401
|
-
Revisar las fuentes primarias enlazadas desde cada informe
|
|
423
|
+
Revisar las fuentes primarias enlazadas desde cada informe de investigación.
|
|
402
424
|
|
|
403
425
|
## Cambio propuesto
|
|
404
426
|
|
package/engine/cli/args.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// Banderas que consumen el argumento siguiente: su valor no es un posicional.
|
|
8
8
|
const VALUED_FLAGS = new Set([
|
|
9
9
|
'--name', '--mode', '--fixture', '--period', '--record', '--runner', '--integration',
|
|
10
|
-
'--task', '--promote', '--hito',
|
|
10
|
+
'--task', '--promote', '--hito', '--reason',
|
|
11
11
|
])
|
|
12
12
|
|
|
13
13
|
// Qué acepta cada comando, y a la vez qué comandos existen. Una bandera desconocida se rechaza en vez
|
|
@@ -36,7 +36,7 @@ const FLAGS = {
|
|
|
36
36
|
integration: ['--fixture'],
|
|
37
37
|
secrets: [],
|
|
38
38
|
automation: ['--force'],
|
|
39
|
-
learn: ['--flow', '--proposal', '--applied', '--archived', '--period'],
|
|
39
|
+
learn: ['--flow', '--proposal', '--applied', '--archived', '--period', '--reason'],
|
|
40
40
|
evaluate: ['--cases', '--json', '--bench', '--force', '--record', '--flow'],
|
|
41
41
|
flow: ['--json'],
|
|
42
42
|
}
|
package/engine/cli/catalog.js
CHANGED
|
@@ -91,14 +91,14 @@ function learn(agent, cli) {
|
|
|
91
91
|
// y lo que se hacía era mergear el PR sin firmar — que deja el documento diciendo «sin aplicar»
|
|
92
92
|
// para siempre, indistinguible de una que espera trabajo.
|
|
93
93
|
if (cli.has('--archived')) {
|
|
94
|
-
const result = L.archive(opsRoot(), agent, cli.value('--period'), kind)
|
|
94
|
+
const result = L.archive(opsRoot(), agent, cli.value('--period'), kind, cli.value('--reason') || '')
|
|
95
95
|
const relative = path.relative(opsRoot(), result.file)
|
|
96
|
-
// Los dos archivados no son lo mismo y el mensaje lo dice: uno es una decisión
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
const porque = result.
|
|
100
|
-
?
|
|
101
|
-
: '
|
|
96
|
+
// Los dos archivados no son lo mismo y el mensaje lo dice: uno es una decisión, con su motivo, y
|
|
97
|
+
// el otro es tirar un andamio que nadie llegó a llenar. Afirmar el primero sobre el segundo le
|
|
98
|
+
// cuenta a quien archiva que hubo una revisión que no hubo.
|
|
99
|
+
const porque = result.reason
|
|
100
|
+
? `descartada — ${result.reason}`
|
|
101
|
+
: 'nadie decidió el cambio y el documento quedó con el molde'
|
|
102
102
|
return console.log(result.already
|
|
103
103
|
? `= ${relative} ya estaba archivada`
|
|
104
104
|
: `✓ ${relative} queda archivada: ${porque}`)
|
|
@@ -110,13 +110,13 @@ function learn(agent, cli) {
|
|
|
110
110
|
? `= ${relative} ya estaba aplicada`
|
|
111
111
|
: `✓ ${relative} queda aplicada: no se vuelve a aplicar`)
|
|
112
112
|
}
|
|
113
|
-
// Un recorrido no tiene informe
|
|
114
|
-
// corridas y nunca lee `learning/reports/`. La forma desnuda —la que para un cargo abre
|
|
115
|
-
//
|
|
113
|
+
// Un recorrido no tiene informe de investigación: su propuesta se compone de los veredictos de sus
|
|
114
|
+
// propias corridas y nunca lee `learning/reports/`. La forma desnuda —la que para un cargo abre su
|
|
115
|
+
// informe— no tiene entonces qué abrir acá, y `prepareReport` lo decía resolviendo con el
|
|
116
116
|
// `kind` por defecto: «no existe agents/<tipo>/<slug>/SKILL.md», que manda a crear un cargo que no
|
|
117
117
|
// falta. Negarse nombrando el comando que sí corresponde es lo que cierra R13.
|
|
118
118
|
if (kind === 'flow' && !cli.has('--proposal')) {
|
|
119
|
-
fail(`${agent} es un recorrido: aprende de sus corridas, no de informes
|
|
119
|
+
fail(`${agent} es un recorrido: aprende de sus corridas, no de informes de investigación.\n`
|
|
120
120
|
+ ` Abrí la propuesta con "ops learn ${agent} --flow --proposal".`, USAGE)
|
|
121
121
|
}
|
|
122
122
|
// `--period` es para consolidar a mano un mes que no es el de hoy. El ciclo automático no lo
|
|
@@ -137,7 +137,7 @@ function learn(agent, cli) {
|
|
|
137
137
|
if (typeof result.reports === 'number') {
|
|
138
138
|
console.log(kind === 'flow'
|
|
139
139
|
? ` ${result.reports} corrida(s) consolidada(s), ${result.findings} hallazgo(s)`
|
|
140
|
-
: ` ${result.reports} informe(s)
|
|
140
|
+
: ` ${result.reports} informe(s) de investigación incluidos`)
|
|
141
141
|
}
|
|
142
142
|
// Lo lee quien automatiza el ciclo para no pedir una firma por un documento que no decide nada, y
|
|
143
143
|
// también quien lo corre a mano: sin esta línea el archivo se ve terminado y no lo está. Se pregunta
|
package/engine/cli/ops.js
CHANGED
|
@@ -180,7 +180,8 @@ function usage() {
|
|
|
180
180
|
ops automation doctor <ops-root> claude|codex|gemini|antigravity
|
|
181
181
|
ops automation install <ops-root> claude|codex|gemini|antigravity
|
|
182
182
|
ops automation uninstall <ops-root> claude|codex|gemini|antigravity
|
|
183
|
-
ops learn <agent|flow> [--flow] [--proposal [--period <AAAA-MM>]] [--applied
|
|
183
|
+
ops learn <agent|flow> [--flow] [--proposal [--period <AAAA-MM>]] [--applied [--period <AAAA-MM>]]
|
|
184
|
+
ops learn <agent|flow> [--flow] --archived [--period <AAAA-MM>] [--reason <motivo>]
|
|
184
185
|
ops evaluate <agent|flow> [--flow] [--cases [--json]] [--bench [caso]] [--record [AAAA-MM-DD]]
|
|
185
186
|
ops agents list [ops-root] [--own|--system] [--json]
|
|
186
187
|
ops agents fork <cargo> [ops-root]
|
package/engine/cli/planning.js
CHANGED
|
@@ -162,11 +162,14 @@ function context(dir, cli) {
|
|
|
162
162
|
const report = {
|
|
163
163
|
// Toda la cola trabada por una persona no es lo mismo que no tener cola, y decir lo segundo manda a
|
|
164
164
|
// buscar trabajo que no existe en vez de a resolver la fila que lo destraba.
|
|
165
|
-
blocked:
|
|
165
|
+
blocked: P.checkpointHolds(root) ? 'awaiting-review' : (!task && skipped.length ? 'blocked-on-human' : ''),
|
|
166
166
|
task: task && {
|
|
167
167
|
slug: task.slug, hito: task.hito, tier: task.tier, cast: task.cast, service: task.service,
|
|
168
168
|
// Una tarea puede heredar su aceptación del criterio citado; el runner necesita el texto, no la cita.
|
|
169
169
|
acceptance: task.acceptance || criteria.map((criterion) => criterion.text).join(' '),
|
|
170
|
+
// Las decisiones que la línea ya tomó. Viaja con la tarea y no aparte porque es de ella: quien la
|
|
171
|
+
// reciba tiene que poder leerla al lado de su aceptación, que es contra lo que se contrasta.
|
|
172
|
+
description: task.description || '',
|
|
170
173
|
epic: task.epic,
|
|
171
174
|
},
|
|
172
175
|
criteria,
|
package/engine/hooks/approval.js
CHANGED
|
@@ -104,9 +104,11 @@ function HOW(variable, lines, input, pasteable = lines) {
|
|
|
104
104
|
// `check` la muestra al final de la corrida —es lo que trajo el 117—, y al concederla no la decía nadie
|
|
105
105
|
// (caso 170).
|
|
106
106
|
const ask = chat
|
|
107
|
-
? 'Decile a la persona qué se frenó y por qué, y
|
|
108
|
-
+ '
|
|
109
|
-
+ '
|
|
107
|
+
? 'Decile a la persona qué se frenó y por qué, y pedile que lo confirme con sus palabras: un «dale» '
|
|
108
|
+
+ 'alcanza, pero no hace falta esa palabra. Si lo que contesta es un sí, reintentá el mismo cambio y '
|
|
109
|
+
+ 'pasa; si duda, pregunta o dice que no, no reintentes. Juzgarlo te toca a vos: el guard sólo frena la '
|
|
110
|
+
+ 'respuesta que niega, frena o pregunta. Esa confirmación cubre lo que se frenó y nada más —algo nuevo '
|
|
111
|
+
+ 'vuelve a frenar— y sigue valiendo en los mensajes siguientes hasta que ella lo niegue. '
|
|
110
112
|
: ''
|
|
111
113
|
const paste = pasteable.length
|
|
112
114
|
? (chat ? 'Si prefiere aprobarlo a mano, que pegue ella tal cual en' : 'Aprobalo pegando tal cual en')
|
package/engine/hooks/chat.js
CHANGED
|
@@ -157,10 +157,15 @@ function ordersPush(text, item) {
|
|
|
157
157
|
})
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
// Quien contesta a un bloqueo que quedó pendiente
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
160
|
+
// Quien contesta a un bloqueo que quedó pendiente lo confirma con sus palabras, y juzgar si lo que dijo
|
|
161
|
+
// es un sí le toca al agente que lo lee. Antes el guard exigía que el mensaje **empezara** con una de once
|
|
162
|
+
// formas —«dale», «sí», «ok»…—, así que «listo», «claro», «confirmo» o «bueno dale» volvían a frenar lo
|
|
163
|
+
// que la persona acababa de aprobar (caso 184). Lo único que el guard sigue decidiendo es la dirección
|
|
164
|
+
// segura: un mensaje que niega, que arranca frenando o que pregunta no aprueba nada. Si eso se equivoca,
|
|
165
|
+
// el costo es volver a preguntar; si se equivocara al revés, un «no» o un «¿para qué sirve?» ejecutaría lo
|
|
166
|
+
// que la persona no aprobó.
|
|
167
|
+
const HALT = /^\s*(?:par[aá]|deten\p{L}*|esper[aá]|cancel\p{L}*|stop|wait|hold on)(?![\p{L}])/iu
|
|
168
|
+
const refuses = (text) => NEGATION.test(text) || HALT.test(text) || /[?¿]/.test(text)
|
|
164
169
|
|
|
165
170
|
// El hook de mensaje. Nunca frena: un mensaje de la persona no se bloquea, y sin registro los guards
|
|
166
171
|
// deciden como antes. Un texto que empieza con una etiqueta no lo escribió una persona —Claude avisa así
|
|
@@ -175,7 +180,7 @@ function record(input) {
|
|
|
175
180
|
const text = String(input.prompt || '')
|
|
176
181
|
const human = !process.env.CI && !/^\s*</.test(text)
|
|
177
182
|
const previous = load(input.session_id)
|
|
178
|
-
const approved = human && previous &&
|
|
183
|
+
const approved = human && previous && !refuses(text)
|
|
179
184
|
? previous.pending.filter((item) => !mentions(text, item).denied)
|
|
180
185
|
: []
|
|
181
186
|
const granted = previous ? (previous.granted || []).filter((one) => !mentions(text, one).denied) : []
|
|
@@ -322,13 +327,20 @@ function unauthorizedNow(input, items) {
|
|
|
322
327
|
return items.filter((item) => !cleared.has(item))
|
|
323
328
|
}
|
|
324
329
|
|
|
325
|
-
// Lo que quedó frenado, para que
|
|
330
|
+
// Lo que quedó frenado, para que la confirmación del mensaje siguiente apruebe exactamente eso y nada más.
|
|
326
331
|
// Devuelve si hay una persona a quien preguntarle.
|
|
332
|
+
//
|
|
333
|
+
// Si el mensaje en curso niega o frena, lo que se ataje en su turno no queda esperando: la persona ya
|
|
334
|
+
// contestó que no antes de que el agente lo intentara. Desde que confirmar no exige una palabra (caso 184),
|
|
335
|
+
// dejarlo pendiente hacía que un «seguí con lo tuyo» aprobara el `.env` o el push que ella acababa de
|
|
336
|
+
// prohibir. Se mira el mensaje y no el ítem porque un push no se nombra como un archivo.
|
|
327
337
|
function hold(input, items) {
|
|
328
338
|
const saved = present(input)
|
|
329
339
|
if (!saved) return false
|
|
330
340
|
try {
|
|
331
|
-
|
|
341
|
+
const text = saved.text || ''
|
|
342
|
+
const open = NEGATION.test(text) || HALT.test(text) ? [] : items
|
|
343
|
+
saved.pending = [...new Set([...saved.pending, ...open])]
|
|
332
344
|
fs.writeFileSync(recordPath(input.session_id), JSON.stringify(saved))
|
|
333
345
|
return true
|
|
334
346
|
} catch { return false }
|
package/engine/hooks/push.js
CHANGED
|
@@ -105,7 +105,7 @@ function liveMessage(live, input) {
|
|
|
105
105
|
// una sesión real, con «pegando tal cual» a secas el agente se ofreció a escribirse la aprobación.
|
|
106
106
|
return `'git push' publica cambios en ${names}, la rama viva, y requiere una acción humana: ni `
|
|
107
107
|
+ 'runner.allowPush ni una orden en el chat llegan ahí sin un permiso por rama. Decile a la persona qué '
|
|
108
|
-
+ 'se frenó y esperá:
|
|
108
|
+
+ 'se frenó y esperá: confirmarlo en el chat no lo destraba. Lo da ella, nombrando la rama en '
|
|
109
109
|
+ 'runner.pushToLiveBranches de ops.config.json, que la deja como una rama de trabajo'
|
|
110
110
|
+ (lines.length ? `, o pegando ella tal cual en ${AP.where(input)} estas líneas:\n`
|
|
111
111
|
+ lines.map((line) => ` ${line}\n`).join('') : '.')
|
|
@@ -115,7 +115,8 @@ function workMessage(items, input) {
|
|
|
115
115
|
const lines = items.filter((item) => item.startsWith('push '))
|
|
116
116
|
const chat = CHAT.hold(input, items)
|
|
117
117
|
const how = chat
|
|
118
|
-
? 'Decile a la persona qué se frenó y
|
|
118
|
+
? 'Decile a la persona qué se frenó y pedile que lo confirme con sus palabras: si lo que contesta es un '
|
|
119
|
+
+ 'sí, reintentá el mismo push y pasa; si duda, pregunta o dice que no, no reintentes. '
|
|
119
120
|
+ 'También pasa si lo pide nombrando el remoto y la rama, como «subí feat/x a origin».'
|
|
120
121
|
: 'Lo destraba una persona pidiéndolo en el chat con el remoto y la rama.'
|
|
121
122
|
const paste = lines.length
|
|
@@ -24,7 +24,7 @@ const CHAT_RECORD = (file) => `${file} es el registro de lo que la persona dijo
|
|
|
24
24
|
+ 'runner, nunca una herramienta.'
|
|
25
25
|
|
|
26
26
|
const SELF = (file) => `${file} es la aprobación de una persona, y escribírsela es aprobarse solo. Si la `
|
|
27
|
-
+ 'persona quiere autorizar algo, que lo diga en el chat —nombrándolo, o
|
|
27
|
+
+ 'persona quiere autorizar algo, que lo diga en el chat —nombrándolo, o confirmando el bloqueo— o '
|
|
28
28
|
+ 'que edite el archivo ella.'
|
|
29
29
|
|
|
30
30
|
// Las líneas van en el mensaje porque son lo que el «dale» va a aprobar: una confirmación que no muestra
|
|
@@ -32,7 +32,7 @@ const SELF = (file) => `${file} es la aprobación de una persona, y escribírsel
|
|
|
32
32
|
const UNASKED = (file, missing) => `${file} es la aprobación de una persona y estas líneas no las pidió:\n`
|
|
33
33
|
+ missing.map((line) => ` ${line}\n`).join('')
|
|
34
34
|
+ 'Escribí sólo lo que ella nombró en su mensaje. Si hacen falta las otras, decile cuáles y por qué, y '
|
|
35
|
-
+ '
|
|
35
|
+
+ 'pedile que lo confirme con sus palabras: si lo que contesta es un sí, reintentá la misma escritura y pasa.'
|
|
36
36
|
|
|
37
37
|
// **Por shell se frena toda escritura.** Es la misma decisión que toma `ops-config`, y por qué un comando
|
|
38
38
|
// no se puede comparar está escrito allá. Lo que la trae hasta acá es que el contenido es lo único que
|
package/engine/hooks/shell.js
CHANGED
|
@@ -45,6 +45,20 @@ const COMANDO = String.raw`$|[;&|)'"\`]`
|
|
|
45
45
|
// de frenar trabajo legítimo nombraba una violación que no estaba.
|
|
46
46
|
const MISMO = String.raw`[^;&|\n]`
|
|
47
47
|
|
|
48
|
+
// Si algún remoto alcanza al commit que está en HEAD. Lectura local e inocua: `branch -r` mira lo que ya
|
|
49
|
+
// está en `refs/remotes/`, no habla con ningún servidor. Un `fetch` que nadie corrió deja la respuesta
|
|
50
|
+
// desactualizada hacia el lado seguro — cree que no está publicado algo que sí lo está—, y por eso esto
|
|
51
|
+
// acota un bloqueo y no autoriza nada: lo que decide publicar sigue siendo `push`, que mira otra cosa.
|
|
52
|
+
//
|
|
53
|
+
// Sin git, sin repositorio o con el comando fallando, la respuesta es **sí**: cerrado por defecto (R27).
|
|
54
|
+
// No saber si algo se publicó no es lo mismo que saber que no.
|
|
55
|
+
function publishedHead(input) {
|
|
56
|
+
const cwd = (input && input.cwd) || process.cwd()
|
|
57
|
+
const result = spawnSync('git', ['branch', '-r', '--contains', 'HEAD'], { cwd, encoding: 'utf8' })
|
|
58
|
+
if (result.status !== 0) return true
|
|
59
|
+
return Boolean(result.stdout.trim())
|
|
60
|
+
}
|
|
61
|
+
|
|
48
62
|
// Un mensaje de commit es dato, no código. `git commit -m "fix: bloquear git push --force"` disparaba
|
|
49
63
|
// el guard de publicación, y lo mismo `rm -rf /` nombrado en una explicación; con el heredoc que se usa
|
|
50
64
|
// para un mensaje largo, el cuerpo entero entra en el comando, así que la línea que arregla esto no se
|
|
@@ -80,13 +94,22 @@ function destructive(input) {
|
|
|
80
94
|
publish(input, command)
|
|
81
95
|
const rules = [
|
|
82
96
|
[/\bgit\s+reset\s+--hard\b/, "'git reset --hard' destruye cambios locales.", true],
|
|
83
|
-
// R8
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
97
|
+
// Lo que R8 protege es la historia que **otro ya leyó**, y el motor ya hace esa distinción para el
|
|
98
|
+
// push: publicar se autoriza, reescribir lo publicado no. Acá se hace la misma, mirando si algún
|
|
99
|
+
// remoto alcanza a HEAD.
|
|
100
|
+
//
|
|
101
|
+
// Antes se bloqueaba siempre, «por política y no por daño», y eso no impedía el resultado sino el
|
|
102
|
+
// comando que lo nombra: `git reset --soft HEAD~1` y volver a commitear produce exactamente lo mismo,
|
|
103
|
+
// no lo frena ningún guard y no deja constancia de nada. Una regla que se cumple mejor esquivándola se
|
|
104
|
+
// termina esquivando siempre (caso 178).
|
|
105
|
+
//
|
|
106
|
+
// Y no lleva salida por chat ni publicado ni sin publicar: sin publicar no la necesita, y publicado es
|
|
107
|
+
// la misma reescritura que el force-push, que tampoco la tiene.
|
|
108
|
+
...(publishedHead(input) ? [[
|
|
87
109
|
new RegExp(String.raw`\bgit\s+commit\b${MISMO}*\s--amend\b`),
|
|
88
|
-
"'git commit --amend'
|
|
89
|
-
|
|
110
|
+
"'git commit --amend' sobre un commit ya publicado reescribe historia que otro leyó. R8 lo "
|
|
111
|
+
+ 'prohíbe: hacé otro commit encima.',
|
|
112
|
+
]] : []),
|
|
90
113
|
[/\bgit\s+clean\s+-[^\s]*f/, "'git clean -f' borra archivos sin seguimiento.", true],
|
|
91
114
|
// `git checkout -- .` destruye lo mismo que `reset --hard` y sin recuperación, pero se escribe como
|
|
92
115
|
// una limpieza. Se bloquea sólo la forma ancha —`.`, `*`, `:/`, o sin ruta—: revertir un archivo
|
|
@@ -170,6 +193,39 @@ function gitAdd(input) {
|
|
|
170
193
|
}
|
|
171
194
|
}
|
|
172
195
|
|
|
196
|
+
// Un `package.json` cambia sin tocar nada de lo que su lockfile registra: un script, una descripción, la
|
|
197
|
+
// configuración de una herramienta. Frenar eso por la sola presencia del archivo en el índice obligaba a
|
|
198
|
+
// pedir permiso por un cambio sin riesgo (caso 181). Se deja pasar sólo lo medido, y lo demás frena como
|
|
199
|
+
// antes, incluido lo que no se puede comparar.
|
|
200
|
+
//
|
|
201
|
+
// La lista salió de correr `npm install --package-lock-only` con npm 11.16.0 cambiando una clave por vez:
|
|
202
|
+
// las claves name, version, license, engines, bin, funding, workspaces, os y cpu, y los scripts preinstall,
|
|
203
|
+
// install y postinstall, sí mueven el lock. Sólo se midió el lock de npm, así que con otro gestor no se
|
|
204
|
+
// afloja nada.
|
|
205
|
+
const INERT_KEYS = new Set([
|
|
206
|
+
'scripts', 'description', 'keywords', 'author', 'repository', 'homepage', 'bugs', 'private', 'type',
|
|
207
|
+
'main', 'files', 'exports', 'config', 'jest', 'eslintConfig', 'prettier',
|
|
208
|
+
])
|
|
209
|
+
const INSTALL_SCRIPTS = ['preinstall', 'install', 'postinstall']
|
|
210
|
+
|
|
211
|
+
function lockUnaffected(dir, file, lockfiles) {
|
|
212
|
+
if (path.basename(file) !== 'package.json' || lockfiles.join() !== 'package-lock.json') return false
|
|
213
|
+
const read = (ref) => {
|
|
214
|
+
const shown = run('git', ['-C', dir, 'show', `${ref}:${file}`], dir)
|
|
215
|
+
if (!shown.ok) return null
|
|
216
|
+
try { return JSON.parse(shown.output) } catch { return null }
|
|
217
|
+
}
|
|
218
|
+
const before = read('HEAD')
|
|
219
|
+
const after = read('')
|
|
220
|
+
if (!before || !after || typeof before !== 'object' || typeof after !== 'object') return false
|
|
221
|
+
const same = (one, other) => JSON.stringify(one) === JSON.stringify(other)
|
|
222
|
+
const changed = [...new Set([...Object.keys(before), ...Object.keys(after)])]
|
|
223
|
+
.filter((key) => !same(before[key], after[key]))
|
|
224
|
+
if (changed.some((key) => !INERT_KEYS.has(key))) return false
|
|
225
|
+
const scripts = (one) => (one && typeof one.scripts === 'object' ? one.scripts : {}) || {}
|
|
226
|
+
return INSTALL_SCRIPTS.every((name) => same(scripts(before)[name], scripts(after)[name]))
|
|
227
|
+
}
|
|
228
|
+
|
|
173
229
|
function dependencies(input) {
|
|
174
230
|
if (process.env.OPS_DEPENDENCIES_OVERRIDE === '1') return
|
|
175
231
|
const command = commandOf(input)
|
|
@@ -227,6 +283,7 @@ function dependencies(input) {
|
|
|
227
283
|
block(`${parent}: hay varios lockfiles (${onDisk.join(', ')}). Conserva uno solo.`)
|
|
228
284
|
}
|
|
229
285
|
const manifests = sinAprobar(parent, state.manifests)
|
|
286
|
+
.filter((name) => !lockUnaffected(dir, name, existingLocks))
|
|
230
287
|
if (state.manifests.length && existingLocks.length && !state.locks.length && manifests.length) {
|
|
231
288
|
block(`${parent}: cambió ${state.manifests.join(', ')} sin actualizar su lockfile.\n`
|
|
232
289
|
+ AP.HOW('OPS_DEPENDENCIES_OVERRIDE', manifests, input))
|