@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +1 -0
  3. package/agents/roles/system/accounting-specialist/learning/AUTOMATION.md +2 -2
  4. package/agents/roles/system/ai-governance-lead/learning/AUTOMATION.md +1 -1
  5. package/agents/roles/system/ai-product-manager/learning/AUTOMATION.md +1 -1
  6. package/agents/roles/system/analytics-engineer/learning/AUTOMATION.md +1 -1
  7. package/agents/roles/system/backend-engineer/learning/AUTOMATION.md +2 -2
  8. package/agents/roles/system/business-strategist/learning/AUTOMATION.md +2 -2
  9. package/agents/roles/system/cloud-architect/learning/AUTOMATION.md +1 -1
  10. package/agents/roles/system/community-manager/learning/AUTOMATION.md +1 -1
  11. package/agents/roles/system/content-specialist/learning/AUTOMATION.md +2 -2
  12. package/agents/roles/system/customer-success-manager/learning/AUTOMATION.md +2 -2
  13. package/agents/roles/system/customer-support-specialist/learning/AUTOMATION.md +2 -2
  14. package/agents/roles/system/data-analyst/learning/AUTOMATION.md +2 -2
  15. package/agents/roles/system/data-engineer/learning/AUTOMATION.md +1 -1
  16. package/agents/roles/system/data-governance-steward/learning/AUTOMATION.md +2 -2
  17. package/agents/roles/system/data-scientist/learning/AUTOMATION.md +1 -1
  18. package/agents/roles/system/database-administrator/learning/AUTOMATION.md +1 -1
  19. package/agents/roles/system/developer-relations-engineer/learning/AUTOMATION.md +1 -1
  20. package/agents/roles/system/devops-engineer/learning/AUTOMATION.md +2 -2
  21. package/agents/roles/system/engineering-manager/learning/AUTOMATION.md +1 -1
  22. package/agents/roles/system/financial-controller/learning/AUTOMATION.md +2 -2
  23. package/agents/roles/system/finops-engineer/learning/AUTOMATION.md +2 -2
  24. package/agents/roles/system/fraud-risk-analyst/learning/AUTOMATION.md +2 -2
  25. package/agents/roles/system/frontend-engineer/learning/AUTOMATION.md +2 -2
  26. package/agents/roles/system/growth-marketer/learning/AUTOMATION.md +2 -2
  27. package/agents/roles/system/implementation-manager/learning/AUTOMATION.md +1 -1
  28. package/agents/roles/system/integrations-engineer/learning/AUTOMATION.md +3 -3
  29. package/agents/roles/system/kyc-aml-specialist/learning/AUTOMATION.md +3 -3
  30. package/agents/roles/system/legal-counsel/learning/AUTOMATION.md +1 -1
  31. package/agents/roles/system/logistics-operations-manager/learning/AUTOMATION.md +2 -2
  32. package/agents/roles/system/machine-learning-engineer/learning/AUTOMATION.md +1 -1
  33. package/agents/roles/system/mlops-engineer/learning/AUTOMATION.md +1 -1
  34. package/agents/roles/system/mobile-engineer/learning/AUTOMATION.md +2 -2
  35. package/agents/roles/system/partnerships-manager/learning/AUTOMATION.md +1 -1
  36. package/agents/roles/system/people-operations-manager/learning/AUTOMATION.md +1 -1
  37. package/agents/roles/system/privacy-compliance-specialist/learning/AUTOMATION.md +2 -2
  38. package/agents/roles/system/procurement-manager/learning/AUTOMATION.md +1 -1
  39. package/agents/roles/system/product-manager/learning/AUTOMATION.md +3 -3
  40. package/agents/roles/system/product-marketing-manager/learning/AUTOMATION.md +2 -2
  41. package/agents/roles/system/project-manager/learning/AUTOMATION.md +1 -1
  42. package/agents/roles/system/qa-engineer/learning/AUTOMATION.md +2 -2
  43. package/agents/roles/system/release-manager/learning/AUTOMATION.md +1 -1
  44. package/agents/roles/system/sales-representative/learning/AUTOMATION.md +2 -2
  45. package/agents/roles/system/security-engineer/learning/AUTOMATION.md +2 -2
  46. package/agents/roles/system/site-reliability-engineer/learning/AUTOMATION.md +2 -2
  47. package/agents/roles/system/software-architect/learning/AUTOMATION.md +2 -2
  48. package/agents/roles/system/solutions-engineer/learning/AUTOMATION.md +1 -1
  49. package/agents/roles/system/tech-lead/learning/AUTOMATION.md +2 -2
  50. package/agents/roles/system/technical-program-manager/learning/AUTOMATION.md +1 -1
  51. package/agents/roles/system/technical-writer/learning/AUTOMATION.md +1 -1
  52. package/agents/roles/system/treasury-analyst/learning/AUTOMATION.md +2 -2
  53. package/agents/roles/system/ui-designer/learning/AUTOMATION.md +3 -3
  54. package/agents/roles/system/user-researcher/learning/AUTOMATION.md +3 -3
  55. package/agents/roles/system/ux-designer/learning/AUTOMATION.md +3 -3
  56. package/automatization/hooks/README.md +4 -3
  57. package/automatization/workflows/agent-propose.js +2 -2
  58. package/automatization/workflows/autobuild.js +219 -29
  59. package/automatization/workflows/flow.js +30 -14
  60. package/engine/agents/learning-files.js +18 -0
  61. package/engine/agents/learning-seal.js +24 -12
  62. package/engine/agents/learning-sources.js +12 -10
  63. package/engine/agents/learning.js +29 -7
  64. package/engine/cli/args.js +2 -2
  65. package/engine/cli/catalog.js +12 -12
  66. package/engine/cli/ops.js +2 -1
  67. package/engine/cli/planning.js +4 -1
  68. package/engine/hooks/approval.js +5 -3
  69. package/engine/hooks/chat.js +19 -7
  70. package/engine/hooks/push.js +3 -2
  71. package/engine/hooks/self-approval.js +2 -2
  72. package/engine/hooks/shell.js +63 -6
  73. package/engine/planning/contracts.js +19 -0
  74. package/engine/planning/parser.js +35 -0
  75. package/package.json +1 -1
  76. package/template/AGENTS.md +5 -4
  77. package/template/Makefile +1 -1
  78. 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
- analysis: { type: 'string' }, summary: { type: 'string' },
116
- evidence: { type: 'array', items: { type: 'string' } },
117
- assumptions: { type: 'array', items: { type: 'string' } },
118
- openQuestions: { type: 'array', items: { type: 'object', additionalProperties: false,
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, todo junto por debajo de 2000 caracteres: si algo no ` +
285
- `entra, va al archivo y en el campo queda lo esencial. Vale también para missing y humanAction ` +
286
- `cuando el gate no se cumple, que es cuando más se escribe. ` +
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 150 ` +
290
- `palabras o menos, lo que la etapa siguiente necesita para decidir —no un resumen de tu análisis, ` +
291
- `sino lo que le cambia el trabajo—, porque eso se le reenvía a cada etapa posterior.`,
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
- atomicWrite(file, text
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: archivar lo que alguien miró **es** decidir que no cambia
134
- // nada, y archivar un documento que nadie decidió es tirar el andamio — la firma se gastó sobre el molde
135
- // intacto y no hubo nada que aprobar. Escribir el primero sobre el segundo pondría en el historial que se
136
- // miró algo que nadie miró, y ese archivo es lo que alguien lee dentro de seis meses.
137
- const blank = undecided(change)
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. Un aviso publica todos los días y llegar un mes
56
- // tarde es llegar tarde; una norma se revisa por edición y mirarla cada lunes devuelve el mismo texto.
57
- // La cadencia de un cargo la fija su fuente más rápida: basta una que corra para que la semana traiga
58
- // algo, y ninguna otra pierde nada por mirarse antes.
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: 'semanal', platform: 'semanal', project: 'semanal',
62
+ advisory: 'mensual', platform: 'mensual', project: 'mensual',
61
63
  regulation: 'mensual', standard: 'mensual', profession: 'trimestral',
62
64
  }
63
- const CADENCES = ['semanal', 'mensual', 'trimestral']
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 el chequeo semanal la reportaba rota todas las semanas — que es exactamente el
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 semanal no tiene literatura que leer y devuelve un informe vacío cada
211
- // semana. Avisa y no bloquea: un cargo que se está escribiendo todavía no las tiene.
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 semanal no puede resolver:
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 reports = path.join(assertWritable(root, agent), 'learning', 'reports')
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 semanal — ${isoDate(now)}
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
- // Abre la revisión siguiente. No vuelve a consolidar los informes semanales: ya se consolidaron en la
100
- // propuesta que ésta corrige, y repetirlos haría que el mismo hallazgo entre dos veces al contrato. El
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 semanales para este período.'}
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 semanal.
423
+ Revisar las fuentes primarias enlazadas desde cada informe de investigación.
402
424
 
403
425
  ## Cambio propuesto
404
426
 
@@ -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
  }
@@ -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 —se miró y no
97
- // cambia nada— y el otro es tirar un andamio que nadie llegó a llenar. Afirmar el primero sobre
98
- // el segundo le cuenta a quien archiva que hubo una revisión que no hubo.
99
- const porque = result.blank
100
- ? 'nadie decidió el cambio y el documento quedó con el molde'
101
- : 'se miró y no cambia nada'
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 semanal: su propuesta se compone de los veredictos de sus propias
114
- // corridas y nunca lee `learning/reports/`. La forma desnuda —la que para un cargo abre el informe
115
- // de la semana— no tiene entonces qué abrir acá, y `prepareReport` lo decía resolviendo con el
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 semanales.\n`
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) semanal(es) incluidos`)
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|--archived [--period <AAAA-MM>]]
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]
@@ -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: fs.existsSync(gate) ? 'awaiting-review' : (!task && skipped.length ? 'blocked-on-human' : ''),
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,
@@ -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 esperá: si contesta «dale», reintentá el mismo cambio y '
108
- + 'pasa. Ese «dale» cubre lo que se frenó y nada más —algo nuevo vuelve a frenar— y sigue valiendo en '
109
- + 'los mensajes siguientes hasta que ella lo niegue. '
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')
@@ -157,10 +157,15 @@ function ordersPush(text, item) {
157
157
  })
158
158
  }
159
159
 
160
- // Quien contesta a un bloqueo que quedó pendiente. Sólo el principio del mensaje: «dale» es la respuesta
161
- // entera o su primera palabra, no algo que aparece en medio de otra frase.
162
- const YES = new RegExp(String.raw`^\s*(?:s[ií]|dale|ok(?:ay)?|hac[eé]lo|hazlo|adelante|aprobado|apruebo`
163
- + String.raw`|aprob[aá]lo|de acuerdo|yes)(?![\p{L}])`, 'iu')
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 && YES.test(text)
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 un «dale» en el mensaje siguiente apruebe exactamente eso y nada más.
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
- saved.pending = [...new Set([...saved.pending, ...items])]
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 }
@@ -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á: un «dale» no lo destraba. Lo da ella, nombrando la rama en '
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 esperá: si contesta «dale», reintentá el mismo push y pasa. '
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 contestando «dale» al bloqueo— 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
- + 'esperá: si contesta «dale», reintentá la misma escritura y pasa.'
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
@@ -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 lo prohíbe sin excepción configurable y ningún guard lo miraba: `grep -rn amend engine/hooks/`
84
- // no devolvía una línea. Se bloquea por política y no por daño —un `--amend` sobre algo que nadie vio
85
- // no rompe nada—, así que el mensaje manda a lo que sí corresponde: otro commit.
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' reescribe un commit ya creado. R8 pide uno nuevo en su lugar.",
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))