@ingeniomaps/cauce 0.52.0 → 0.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/agents/roles/system/backend-engineer/learning/sources.yaml +2 -2
  3. package/agents/roles/system/community-manager/learning/sources.yaml +2 -2
  4. package/agents/roles/system/content-specialist/learning/sources.yaml +1 -1
  5. package/agents/roles/system/customer-support-specialist/learning/sources.yaml +1 -1
  6. package/agents/roles/system/data-analyst/learning/sources.yaml +2 -2
  7. package/agents/roles/system/data-governance-steward/learning/sources.yaml +1 -1
  8. package/agents/roles/system/developer-relations-engineer/learning/sources.yaml +3 -3
  9. package/agents/roles/system/devops-engineer/learning/sources.yaml +1 -1
  10. package/agents/roles/system/financial-controller/learning/sources.yaml +1 -1
  11. package/agents/roles/system/frontend-engineer/learning/sources.yaml +1 -1
  12. package/agents/roles/system/growth-marketer/learning/sources.yaml +2 -2
  13. package/agents/roles/system/integrations-engineer/learning/sources.yaml +6 -6
  14. package/agents/roles/system/mobile-engineer/learning/sources.yaml +1 -1
  15. package/agents/roles/system/people-operations-manager/learning/sources.yaml +1 -1
  16. package/agents/roles/system/privacy-compliance-specialist/learning/sources.yaml +1 -1
  17. package/agents/roles/system/product-marketing-manager/learning/sources.yaml +1 -1
  18. package/agents/roles/system/qa-engineer/learning/sources.yaml +2 -2
  19. package/agents/roles/system/site-reliability-engineer/learning/sources.yaml +1 -1
  20. package/agents/roles/system/software-architect/learning/sources.yaml +2 -2
  21. package/agents/roles/system/solutions-engineer/learning/sources.yaml +2 -2
  22. package/agents/roles/system/technical-writer/learning/sources.yaml +4 -4
  23. package/agents/roles/system/treasury-analyst/learning/sources.yaml +2 -2
  24. package/agents/roles/system/ui-designer/learning/sources.yaml +1 -1
  25. package/agents/roles/system/ux-designer/learning/sources.yaml +1 -1
  26. package/automatization/hooks/guard-dependencies.sh +1 -1
  27. package/automatization/hooks/guard-destructive.sh +1 -1
  28. package/automatization/hooks/guard-engine.sh +1 -1
  29. package/automatization/hooks/guard-generated.sh +1 -1
  30. package/automatization/hooks/guard-git-add.sh +1 -1
  31. package/automatization/hooks/guard-governance.sh +1 -1
  32. package/automatization/hooks/guard-integration-snapshot.sh +1 -1
  33. package/automatization/hooks/guard-migrations.sh +1 -1
  34. package/automatization/hooks/guard-planning-drift.sh +1 -1
  35. package/automatization/hooks/guard-secrets.sh +1 -1
  36. package/automatization/hooks/guard-test-evidence.sh +1 -1
  37. package/automatization/hooks/guard-verify.sh +1 -1
  38. package/automatization/hooks/guard-workspace-boundary.sh +1 -1
  39. package/automatization/hooks/run-hook.sh +3 -2
  40. package/automatization/runners/antigravity/hook.js +13 -14
  41. package/automatization/shared/eval-only.js +3 -3
  42. package/automatization/workflows/agent-eval.js +3 -3
  43. package/automatization/workflows/agent-promote.js +4 -4
  44. package/automatization/workflows/autobuild.js +12 -12
  45. package/automatization/workflows/flow-eval.js +10 -7
  46. package/automatization/workflows/flow.js +9 -10
  47. package/automatization/workflows/onboard.js +4 -5
  48. package/engine/agents/evaluations.js +18 -16
  49. package/engine/agents/learning-files.js +111 -0
  50. package/engine/agents/learning-sources.js +166 -0
  51. package/engine/agents/learning.js +93 -272
  52. package/engine/automation/config.js +175 -0
  53. package/engine/automation/hooks.js +96 -0
  54. package/engine/automation/index.js +17 -431
  55. package/engine/automation/roles.js +72 -0
  56. package/engine/automation/runners.js +162 -0
  57. package/engine/cli/catalog.js +7 -12
  58. package/engine/cli/instance.js +11 -10
  59. package/engine/cli/io.js +1 -1
  60. package/engine/cli/ops.js +12 -10
  61. package/engine/cli/wiring.js +2 -4
  62. package/engine/config/validate.js +2 -1
  63. package/engine/core/frontmatter.js +2 -1
  64. package/engine/core/ownership.js +5 -4
  65. package/engine/core/scan.js +3 -0
  66. package/engine/flows/registry.js +2 -2
  67. package/engine/hooks/files.js +160 -0
  68. package/engine/hooks/input.js +129 -0
  69. package/engine/hooks/run.js +24 -449
  70. package/engine/hooks/shell.js +197 -0
  71. package/engine/integrations/registry.js +2 -0
  72. package/engine/planning/contracts.js +12 -10
  73. package/engine/planning/parser.js +4 -3
  74. package/engine/planning/state.js +2 -1
  75. package/package.json +5 -5
@@ -7,102 +7,11 @@ const { atomicWrite } = require('../core/files')
7
7
  // La misma lectura de secciones que usa el planning: repetirla acá sería una segunda copia del mismo
8
8
  // hecho, y una de las dos se pudre sin que nada falle (R11).
9
9
  const { section } = require('../planning/parser')
10
-
11
- const REQUIRED_SECTIONS = [
12
- 'Hallazgos',
13
- 'Evidencia',
14
- 'Cambio propuesto',
15
- 'Riesgos y regresiones',
16
- 'Evaluación',
17
- 'Aprobación humana',
18
- ]
19
-
20
- // Un cargo del sistema vive dentro del paquete: escribir ahí perdería el informe en el próximo
21
- // `npm ci`, y además duplicaría en cada empresa una investigación sobre la profesión que se hace
22
- // mejor una sola vez. Lo que sí es de esta empresa es su contexto, y ese tiene otro lugar.
23
- // Un recorrido no viene del paquete cuando lo mantiene este repositorio, así que la pregunta de
24
- // dónde se puede escribir es la misma y la respuesta se resuelve igual: dentro del proyecto, sí.
25
- function assertWritableTeam(root, slug) {
26
- const dir = path.dirname(require('../flows/registry').read(root, slug).file)
27
- const own = path.resolve(root, 'flows')
28
- if (path.resolve(dir).startsWith(`${own}${path.sep}`)) return dir
29
- throw new Error(
30
- `${slug} es un recorrido que trae Cauce y su aprendizaje se hace en el toolkit, no acá.\n` +
31
- ` Para tener una versión propia, copialo a flows/${slug}/ y mantenelo vos.`,
32
- )
33
- }
34
-
35
- function assertWritable(root, agent, kind = 'agent') {
36
- if (kind === 'flow') return assertWritableTeam(root, agent)
37
- const found = catalog.find(root, agent)
38
- // Lo que decide no es si el cargo es del sistema, sino si vive dentro de este repositorio. En el
39
- // toolkit los cargos del sistema son propios y se aprenden acá; en una instancia vienen del
40
- // paquete, y escribir ahí se pierde en el próximo `npm ci` o en el próximo upgrade.
41
- const own = path.resolve(catalog.projectCatalog(root))
42
- if (path.resolve(found.dir).startsWith(`${own}${path.sep}`)) return found.dir
43
- throw new Error(
44
- `${agent} es un cargo que trae Cauce y su aprendizaje se hace en el toolkit, no acá.\n` +
45
- ` Lo que este cargo debe saber de esta empresa va en organization/roles/${agent}.md.\n` +
46
- ` Para tener una versión propia del cargo, adoptalo: ops agents fork ${agent}.`,
47
- )
48
- }
49
-
50
- function isoDate(now = new Date()) { return now.toISOString().slice(0, 10) }
51
- function month(now = new Date()) { return now.toISOString().slice(0, 7) }
52
-
53
- // Una propuesta por período, y sus revisiones. La revisión existe porque aplicar no es el final del
54
- // ciclo: la evaluación posterior es la que dice si el cambio sirvió, y cuando dice que no, el sello
55
- // —que está para que nadie reaplique lo mismo y duplique cada viñeta— dejaba al cargo con un contrato
56
- // que se sabe mal calibrado y sin camino para corregirlo hasta el mes siguiente. La corrección es un
57
- // cambio distinto: documento propio, firma propia, y la aplicada queda sellada donde está.
58
- const PROPOSAL_NAME = /^(\d{4}-\d{2})(?:-r(\d+))?\.md$/
59
-
60
- // Mismo cuidado que con los registros de evaluación: `-` va antes que `.` en ASCII, así que ordenar
61
- // nombres pondría `2026-08-r2.md` delante de `2026-08.md` y la revisión se leería como la más vieja.
62
- function proposalOrder(name) {
63
- const [, period, revision] = name.match(PROPOSAL_NAME)
64
- return `${period}-${String(Number(revision || 1)).padStart(4, '0')}`
65
- }
66
-
67
- // El tope de la línea de índice. No es estético: son 47 líneas que se leen de un vistazo, y una que
68
- // se envuelve rompe la columna que hace posible el vistazo.
69
- const SUMMARY_MAX = 120
70
-
71
- function proposalFiles(dir) {
72
- try {
73
- return fs.readdirSync(dir)
74
- .filter((name) => PROPOSAL_NAME.test(name))
75
- .sort((one, other) => proposalOrder(one).localeCompare(proposalOrder(other)))
76
- } catch { return [] }
77
- }
78
-
79
- function frontmatterState(text, fallback) {
80
- return ((text.match(/^status:\s*(\S+)\s*$/m) || [])[1] || fallback).toLowerCase()
81
- }
82
-
83
- function proposalState(text) {
84
- return frontmatterState(text, 'proposed')
85
- }
86
-
87
- // El sufijo `-N` es la segunda corrida del mismo día, y es la que trae el veredicto más nuevo. Sin él
88
- // en el patrón, 51 de los 188 registros que hoy existen —44 de cargos, 7 de recorridos— quedaban fuera
89
- // del ciclo: no entraban a ninguna propuesta y nada lo delataba, que es el modo de fallo que el
90
- // comentario de `markConsolidated` ya describía para el informe atrasado.
91
- //
92
- // Ordenar por nombre no alcanza: `-` (0x2D) es menor que `.` (0x2E), así que `2026-08-24-2.md` cae
93
- // antes que `2026-08-24.md` y la corrida más nueva se leería primero.
94
- const REPORT_NAME = /^(\d{4}-\d{2}-\d{2})(?:-(\d+))?\.md$/
95
-
96
- function reportFiles(dir) {
97
- try {
98
- return fs.readdirSync(dir)
99
- .map((name) => [name, REPORT_NAME.exec(name)])
100
- .filter(([, hit]) => hit)
101
- .sort(([, a], [, b]) =>
102
- (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : Number(a[2] || 0) - Number(b[2] || 0)))
103
- .map(([name]) => name)
104
- } catch { return [] }
105
- }
10
+ const {
11
+ REQUIRED_SECTIONS, SUMMARY_MAX, PROPOSAL_NAME, REPORT_NAME, assertWritableTeam, assertWritable,
12
+ isoDate, month, proposalOrder, proposalFiles, frontmatterState, proposalState, reportFiles,
13
+ } = require('./learning-files')
14
+ const { SOURCE_TIERS, CADENCES, cadence, evaluate, evaluateTeam } = require('./learning-sources')
106
15
 
107
16
  // El informe que ya entró a una propuesta. Nace en `draft` y nada lo movía nunca, así que el que se
108
17
  // consolidó y el que se escribió tarde —después de que la propuesta del período ya existía, y por eso
@@ -230,10 +139,17 @@ propuesta consolidada. -->
230
139
  // propuesta que ésta corrige, y repetirlos haría que el mismo hallazgo entre dos veces al contrato. El
231
140
  // insumo de una revisión es otro —qué mostró la evaluación posterior a aplicar—, y por eso el molde
232
141
  // pregunta eso y no otra cosa.
233
- function reviseProposal(root, agent, dir, previous, period) {
142
+ //
143
+ // El molde de los apartados que una persona escribe queda; lo que deja de quedar es `## Hallazgos` en
144
+ // blanco. Una revisión que no puede decir qué la motiva no puede producir un cambio, y hasta acá se
145
+ // llega sólo con material: el llamador ya se negó a abrirla sin él.
146
+ function reviseProposal(root, agent, dir, previous, period, findings = [], consumed = []) {
234
147
  const parsed = previous.match(PROPOSAL_NAME)
235
148
  const revision = Number(parsed[2] || 1) + 1
236
149
  const file = path.join(dir, `${period}-r${revision}.md`)
150
+ const placeholder = `Qué mostró la evaluación posterior a aplicar \`${previous}\`. No repitas acá los hallazgos de esa
151
+ propuesta —ya entraron al contrato—: lo que va es lo que se supo después, con el registro de
152
+ evaluación que lo sostiene.`
237
153
  fs.writeFileSync(file, `---
238
154
  agent: ${agent}
239
155
  period: ${period}
@@ -250,9 +166,7 @@ la reabre: es un cambio distinto, con su propia firma.
250
166
 
251
167
  ## Hallazgos
252
168
 
253
- Qué mostró la evaluación posterior a aplicar \`${previous}\`. No repitas acá los hallazgos de esa propuesta
254
- —ya entraron al contrato—: lo que va es lo que se supo después, con el registro de evaluación que lo
255
- sostiene.
169
+ ${findings.join('\n\n') || placeholder}
256
170
 
257
171
  ## Evidencia
258
172
 
@@ -281,7 +195,8 @@ sólo que pase.
281
195
  - Responsable: por definir
282
196
  - Fecha: por definir
283
197
  `)
284
- return { file, created: true, reports: 0, corrects: previous }
198
+ for (const record of consumed) markConsolidated(record)
199
+ return { file, created: true, reports: 0, corrects: previous, findings: findings.length }
285
200
  }
286
201
 
287
202
  // La última propuesta del período, si la hay: es contra ella que se decide si abrir una revisión.
@@ -290,18 +205,9 @@ function lastOfPeriod(dir, period) {
290
205
  return names.length ? names[names.length - 1] : ''
291
206
  }
292
207
 
293
- // La propuesta se llama por el mes en que se abre, y `period` sólo existe para una corrida a mano
294
- // sobre otro mes. El ciclo automático no lo usa: nombrarle el mes que cerró abriría una **revisión**
295
- // —lo que se abre cuando ese mes ya tiene una propuesta aplicada—, y corregir es un acto humano.
296
- // Lo que hace que ningún informe se pierda no es el nombre sino el criterio de más abajo.
297
- // Qué aprende un recorrido, y de dónde. Un cargo aprende de su profesión —normas, versiones, fuentes
298
- // que cambian afuera— y por eso investiga. Un recorrido no tiene profesión: lo único que puede
299
- // enseñarle algo es cómo le fue, así que su insumo son los veredictos en contra de sus propias
300
- // corridas. Pedirle una investigación semanal sería pedirle que lea una literatura que no existe, y
301
- // devolvería informes vacíos.
302
- //
303
- // De cada registro sin sellar entran los casos que no pasaron, con su contraste y de qué corrida
304
- // salen. Si no hay ninguno, eso también es un resultado: el recorrido aguantó y no hay qué corregir.
208
+ // De qué aprende un recorrido: de cada registro sin sellar entran los casos que no pasaron, con su
209
+ // contraste y de qué corrida salen. Si no hay ninguno, eso también es un resultado el recorrido
210
+ // aguantó y no hay qué corregir. Por qué es esto y no una investigación, en `pendingRuns`.
305
211
  const VERDICT = /\n### ([^\n]+)\n\n- Veredicto: (pasa|no pasa)\n([\s\S]*?)(?=\n### |$)/g
306
212
 
307
213
  function verdicts(text) {
@@ -320,7 +226,11 @@ function verdicts(text) {
320
226
  //
321
227
  // Cuántas veces falló sí viaja, porque no es lo mismo un rojo suelto que uno que aguantó dos arreglos
322
228
  // distintos: lo primero puede ser varianza y lo segundo es una medición estable.
323
- function flowFindings(root, dir) {
229
+ //
230
+ // Sirve a los dos, y no por generalidad: el formato del registro es el mismo —`### caso` y
231
+ // `- Veredicto:`— porque lo escribe el mismo `evaluate --record`. Un cargo tenía este material desde
232
+ // siempre y nada lo leía, así que aprendía de su profesión y no de haber fallado su propia medición.
233
+ function verdictFindings(root, dir) {
324
234
  const results = path.join(dir, 'evaluations', 'results')
325
235
  const unsealed = reportFiles(results).filter((name) =>
326
236
  frontmatterState(fs.readFileSync(path.join(results, name), 'utf8'), 'draft') !== 'consolidated')
@@ -349,13 +259,26 @@ function pendingRuns(root, slug) {
349
259
  frontmatterState(fs.readFileSync(path.join(results, name), 'utf8'), 'draft') !== 'consolidated').length
350
260
  }
351
261
 
262
+ // El espejo del anterior para un cargo: qué informes esperan entrar a una propuesta. Toma todo lo que
263
+ // ya ocurrió y todavía no entró, no sólo lo del mes que se consolida. Filtrar por el prefijo del
264
+ // período dejaba huérfano al informe atrasado —el que se escribió después de que su mes se consolidó,
265
+ // o el que llegó tarde—: no entraba en ésa ni en ninguna posterior, porque la del mes siguiente sólo
266
+ // miraba su propio mes. Se perdía el hallazgo entero y en silencio.
267
+ //
268
+ // Repetirlo no es un riesgo: el sello dice cuál ya entró, y por eso este criterio existe recién ahora.
269
+ // Antes todos decían `draft` y no había cómo distinguirlos.
270
+ function pendingReports(target, sealing) {
271
+ const dir = path.join(target, 'learning', 'reports')
272
+ return reportFiles(dir).filter((name) => name.slice(0, 7) <= sealing
273
+ && frontmatterState(fs.readFileSync(path.join(dir, name), 'utf8'), 'draft') !== 'consolidated')
274
+ }
275
+
352
276
  // La propuesta de un recorrido. Mismo ciclo que la de un cargo —se abre, se firma, se aplica y se
353
277
  // sella— y distinto contenido: lo que se corrige es el recorrido mismo, y lo que lo justifica es un
354
278
  // veredicto en contra, no una fuente nueva.
355
- function proposeFromRuns(root, agent, target, file, period) {
356
- const { consumed, findings } = flowFindings(root, target)
357
- const empty = 'Ninguna corrida sin consolidar dejó un veredicto en contra. El recorrido '
358
- + 'aguantó lo que se le midió, y eso no pide cambio.'
279
+ // Recibe los hallazgos ya compuestos: el llamador los necesita antes para decidir si abre documento,
280
+ // y volver a leerlos acá sería leer dos veces lo mismo para responder la misma pregunta.
281
+ function proposeFromRuns(root, agent, target, file, period, { consumed, findings }) {
359
282
  fs.writeFileSync(file, `---
360
283
  flow: ${agent}
361
284
  period: ${period}
@@ -367,7 +290,7 @@ automatic_apply: false
367
290
 
368
291
  ## Hallazgos
369
292
 
370
- ${findings.join('\n\n') || empty}
293
+ ${findings.join('\n\n')}
371
294
 
372
295
  ## Evidencia
373
296
 
@@ -399,6 +322,10 @@ Qué caso tiene que cambiar de veredicto y por qué razón, no sólo que la corr
399
322
  return { file, created: true, reports: consumed.length, findings: findings.length }
400
323
  }
401
324
 
325
+ // La propuesta se llama por el mes en que se abre, y `period` sólo existe para una corrida a mano
326
+ // sobre otro mes. El ciclo automático no lo usa: nombrarle el mes que cerró abriría una **revisión**
327
+ // —lo que se abre cuando ese mes ya tiene una propuesta aplicada—, y corregir es un acto humano.
328
+ // Lo que hace que ningún informe se pierda no es el nombre sino el criterio de `pendingReports`.
402
329
  function prepareProposal(root, agent, now = new Date(), period = '', kind = 'agent') {
403
330
  if (period && !/^\d{4}-\d{2}$/.test(period)) throw new Error(`período inválido: ${period}`)
404
331
  const target = assertWritable(root, agent, kind)
@@ -409,40 +336,44 @@ function prepareProposal(root, agent, now = new Date(), period = '', kind = 'age
409
336
  // Una sola propuesta pendiente por período. Si la última todavía no se aplicó, abrir otra partiría
410
337
  // la firma en dos documentos que dicen cosas distintas sobre el mismo contrato.
411
338
  const previous = lastOfPeriod(proposalDir, sealing)
412
- if (previous) {
413
- const before = path.join(proposalDir, previous)
414
- if (proposalState(fs.readFileSync(before, 'utf8')) !== 'applied') {
415
- return { file: before, created: false, reports: 0 }
416
- }
417
- return reviseProposal(root, agent, proposalDir, previous, sealing)
339
+ const unapplied = previous && proposalState(fs.readFileSync(path.join(proposalDir, previous), 'utf8')) !== 'applied'
340
+ if (unapplied) return { file: path.join(proposalDir, previous), created: false, reports: 0 }
341
+
342
+ // La misma regla que abajo, y el mismo motivo: un documento que no puede decir qué corregir no
343
+ // produce un cambio de contrato, y cuesta igual la firma humana que uno que sí. Antes se abría uno
344
+ // por recorrido con corridas sin sellar aunque todas hubieran pasado, para decir que no había nada.
345
+ //
346
+ // Lo que se pierde es el sello de esas corridas, y es inocuo: los hallazgos se componen por caso con
347
+ // la última corrida ganando, así que una verde vieja no cambia ningún veredicto. Lo único que queda
348
+ // es que el recorrido siga contándose en `pending` y su job vuelva a no encontrar nada.
349
+ if (kind === 'flow') {
350
+ const red = verdictFindings(root, target)
351
+ if (!red.findings.length) return { file: '', created: false, reports: 0 }
352
+ if (previous) return reviseProposal(root, agent, proposalDir, previous, sealing, red.findings, red.consumed)
353
+ return proposeFromRuns(root, agent, target, path.join(proposalDir, `${sealing}.md`), sealing, red)
418
354
  }
419
355
 
420
- const file = path.join(proposalDir, `${sealing}.md`)
421
- if (kind === 'flow') return proposeFromRuns(root, agent, target, file, sealing)
422
- const reportDir = path.join(target, 'learning', 'reports')
423
- // Todo lo que ya ocurrió y todavía no entró, no sólo lo del mes que se consolida. Filtrar por el
424
- // prefijo del período dejaba huérfano al informe atrasado —el que se escribió después de que su mes
425
- // se consolidó, o el que llegó tarde—: no entraba en ésa ni en ninguna posterior, porque la del mes
426
- // siguiente sólo miraba su propio mes. Se perdía el hallazgo entero y en silencio.
356
+ // Los dos materiales de un cargo, y dicen cosas distintas: un informe sin consolidar dice que cambió
357
+ // la profesión, un caso en rojo dice que el contrato no se sostuvo. Cualquiera de los dos puede
358
+ // cambiarlo, así que cualquiera de los dos abre documento.
427
359
  //
428
- // Repetirlo no es un riesgo: el sello dice cuál ya entró, y por eso este criterio existe recién
429
- // ahora. Antes todos decían `draft` y no había cómo distinguirlos.
430
- const reports = reportFiles(reportDir).filter((name) => name.slice(0, 7) <= sealing
431
- && frontmatterState(fs.readFileSync(path.join(reportDir, name), 'utf8'), 'draft') !== 'consolidated')
432
- // El cron lo dice antes que nadie: consolidar sin informes produce un andamiaje que nadie puede
433
- // aprobar, y el job ve un archivo nuevo y abre el PR igual. Con una cadencia por cargo eso deja de
434
- // ser un borde: el que investiga cada trimestre pasaría dos meses de cada tres abriendo propuestas
435
- // para decir que no investigó.
436
- if (!reports.length) return { file: '', created: false, reports: 0 }
437
- const summaries = reports.map((name) => {
438
- const report = path.join(reportDir, name)
439
- const text = fs.readFileSync(report, 'utf8')
440
- // Sin `m`: con esa bandera el `$` casa fin de *línea*, así que la búsqueda no ávida cortaba en el
441
- // primer salto y la propuesta consolidaba una sola línea de una recomendación de diez.
442
- const match = text.match(/\n## Recomendación\s*\n([\s\S]*?)(?=\n## |$)/) || []
443
- const recommendation = (match[1] || 'Sin recomendación registrada.').trim()
444
- return `### ${name.slice(0, -3)}\n\nFuente interna: \`${path.relative(root, report)}\`\n\n${recommendation}`
445
- })
360
+ // Sin ninguno no se abre nada, y por eso la guarda vive acá arriba en vez de debajo de la
361
+ // bifurcación, que es donde cubría sólo la propuesta del período: la revisión se fabricaba igual
362
+ // para todo cargo cuya propuesta anterior estuviera aplicada. Que sea un andamio en blanco no la
363
+ // abarata —cuesta la misma firma humana— y encima llega indistinguible de una con hallazgos en la
364
+ // lista de PR. Componer el documento desde el material lo vuelve imposible en vez de prohibido.
365
+ const reportDir = path.join(target, 'learning', 'reports')
366
+ const reports = pendingReports(target, sealing)
367
+ const red = verdictFindings(root, target)
368
+ if (!reports.length && !red.findings.length) return { file: '', created: false, reports: 0 }
369
+
370
+ const reportPaths = reports.map((name) => path.join(reportDir, name))
371
+ const findings = [...reportPaths.map((file) => reportSummary(root, file)), ...red.findings]
372
+ const consumed = [...reportPaths, ...red.consumed]
373
+ if (previous) return reviseProposal(root, agent, proposalDir, previous, sealing, findings, consumed)
374
+
375
+ const file = path.join(proposalDir, `${sealing}.md`)
376
+ const summaries = findings
446
377
  fs.writeFileSync(file, `---
447
378
  agent: ${agent}
448
379
  period: ${sealing}
@@ -478,132 +409,22 @@ Pendiente.
478
409
  - Responsable: por definir
479
410
  - Fecha: por definir
480
411
  `)
481
- for (const name of reports) markConsolidated(path.join(reportDir, name))
482
- return { file, created: true, reports: reports.length }
483
- }
484
-
485
- function evaluateTeam(root, slug) {
486
- const dir = path.dirname(require('../flows/registry').read(root, slug).file)
487
- const errors = []
488
- const warnings = []
489
- if (!fs.existsSync(path.join(dir, 'learning', 'HISTORY.md'))) {
490
- warnings.push('sin learning/HISTORY.md: lo que se le cambie al recorrido no queda registrado')
491
- }
492
- const proposals = proposalFiles(path.join(dir, 'learning', 'proposals'))
493
- let pending = 0
494
- for (const name of proposals) {
495
- const text = fs.readFileSync(path.join(dir, 'learning', 'proposals', name), 'utf8')
496
- if (!/^automatic_apply:\s*false$/m.test(text)) errors.push(`${name}: automatic_apply debe ser false`)
497
- for (const section of REQUIRED_SECTIONS) {
498
- if (!text.includes(`## ${section}`)) errors.push(`${name}: falta sección ${section}`)
499
- }
500
- if (proposalState(text) !== 'applied') pending += 1
501
- }
502
- return { errors, warnings, proposals: proposals.length, pending, cases: 0 }
503
- }
504
-
505
- // Qué publica una fuente, que es lo único que decide cada cuánto vale la pena volver a mirarla. No
506
- // dice si es primaria —eso lo exige `rules.require_primary_source`— ni si sigue vigente: lo que se
507
- // aparta del default lo declara la fuente con `authority:` o `status:`, y por eso son dos campos y no
508
- // un nombre compuesto. Cuando eran uno solo el catálogo llegó a 51 etiquetas para estas seis.
509
- const SOURCE_TIERS = ['advisory', 'platform', 'project', 'regulation', 'standard', 'profession']
510
-
511
- // Cada cuánto vale la pena volver a mirar cada tipo. Un aviso publica todos los días y llegar un mes
512
- // tarde es llegar tarde; una norma se revisa por edición y mirarla cada lunes devuelve el mismo texto.
513
- // La cadencia de un cargo la fija su fuente más rápida: basta una que corra para que la semana traiga
514
- // algo, y ninguna otra pierde nada por mirarse antes.
515
- const TIER_CADENCE = {
516
- advisory: 'semanal', platform: 'semanal', project: 'semanal',
517
- regulation: 'mensual', standard: 'mensual', profession: 'trimestral',
518
- }
519
- const CADENCES = ['semanal', 'mensual', 'trimestral']
520
-
521
- // Sale del árbol y no de una lista escrita a mano, por la misma razón que la matriz del cron sale del
522
- // árbol: una lista paralela se pudre el día que un cargo cambia sus fuentes y nadie la toca.
523
- function cadence(root, agent) {
524
- const file = path.join(catalog.resolve(root, agent), 'learning', 'sources.yaml')
525
- if (!fs.existsSync(file)) return ''
526
- const tiers = sourceTiers(fs.readFileSync(file, 'utf8')).filter((one) => TIER_CADENCE[one])
527
- if (!tiers.length) return ''
528
- return CADENCES[Math.min(...tiers.map((one) => CADENCES.indexOf(TIER_CADENCE[one])))]
412
+ for (const record of consumed) markConsolidated(record)
413
+ return { file, created: true, reports: reports.length, findings: red.findings.length }
529
414
  }
530
415
 
531
- // Basta con las líneas `tier:`: el archivo es del catálogo, no de un tercero, y agregar un parser de
532
- // YAML por un campo rompería la regla de cero dependencias.
533
- function sourceTiers(text) {
534
- const body = text.includes('sources:') ? text.slice(text.indexOf('sources:')) : ''
535
- return [...body.matchAll(/tier:\s*([A-Za-z-]+)/g)].map((hit) => hit[1])
416
+ // La recomendación de un informe, que es lo único que la propuesta consolida de él.
417
+ function reportSummary(root, report) {
418
+ const name = path.basename(report)
419
+ const text = fs.readFileSync(report, 'utf8')
420
+ // Sin `m`: con esa bandera el `$` casa fin de *línea*, así que la búsqueda no ávida cortaba en el
421
+ // primer salto y la propuesta consolidaba una sola línea de una recomendación de diez. Comprobado en
422
+ // node v24.18.0: el mismo patrón con `m` devuelve la primera línea y sin `m` devuelve el bloque.
423
+ const match = text.match(/\n## Recomendación\s*\n([\s\S]*?)(?=\n## |$)/) || []
424
+ const recommendation = (match[1] || 'Sin recomendación registrada.').trim()
425
+ return `### ${name.slice(0, -3)}\n\nFuente interna: \`${path.relative(root, report)}\`\n\n${recommendation}`
536
426
  }
537
427
 
538
- function evaluate(root, agent) {
539
- const target = catalog.resolve(root, agent)
540
- const errors = []
541
- const warnings = []
542
- const requiredFiles = [
543
- 'learning/sources.yaml',
544
- 'learning/HISTORY.md',
545
- 'evaluations/expected-behaviors.yaml',
546
- ]
547
- // `AUTOMATION.md` documenta cómo corre la automatización de aprendizaje del toolkit. Exigírselo
548
- // a una empresa que escribe un cargo propio era pedirle contabilidad interna nuestra: su cargo debe
549
- // tener contrato, fuentes e historia, no nuestro andamiaje.
550
- if (catalog.find(root, agent).system) requiredFiles.push('learning/AUTOMATION.md')
551
- for (const relative of requiredFiles) {
552
- if (!fs.existsSync(path.join(target, relative))) errors.push(`falta ${relative}`)
553
- }
554
- const sourcesFile = path.join(target, 'learning', 'sources.yaml')
555
- if (fs.existsSync(sourcesFile)) {
556
- const tiers = sourceTiers(fs.readFileSync(sourcesFile, 'utf8'))
557
- // Sin fuentes el ciclo semanal no tiene literatura que leer y devuelve un informe vacío cada
558
- // semana. Avisa y no bloquea: un cargo que se está escribiendo todavía no las tiene.
559
- if (!tiers.length) warnings.push('sources.yaml sin fuentes: la investigación no tiene qué leer')
560
- for (const tier of tiers) {
561
- if (!SOURCE_TIERS.includes(tier)) {
562
- errors.push(`sources.yaml: tier "${tier}" fuera de ${SOURCE_TIERS.join(' | ')}`)
563
- }
564
- }
565
- }
566
- const skill = fs.readFileSync(path.join(target, 'SKILL.md'), 'utf8').toLowerCase()
567
- for (const phrase of ['no inventar', 'autorización', 'evidencia observable']) {
568
- if (!skill.includes(phrase)) errors.push(`SKILL.md no conserva el control: ${phrase}`)
569
- }
570
- // Sin su línea, el cargo existe pero no se encuentra: quien tiene una tarea tendría que abrir la
571
- // carpeta para saber si es éste. Se exige acá y no como advertencia porque es estático y de una línea.
572
- const summary = catalog.summary(target)
573
- if (!summary) errors.push('SKILL.md no declara summary: la línea con la que se elige este cargo')
574
- else if (summary.length > SUMMARY_MAX) {
575
- errors.push(`summary tiene ${summary.length} caracteres y el máximo es ${SUMMARY_MAX}: `
576
- + 'si no entra en una línea, no sirve para elegir de un vistazo')
577
- }
578
- const proposals = proposalFiles(path.join(target, 'learning', 'proposals'))
579
- let pending = 0
580
- for (const name of proposals) {
581
- const text = fs.readFileSync(path.join(target, 'learning', 'proposals', name), 'utf8')
582
- if (!/^automatic_apply:\s*false$/m.test(text)) errors.push(`${name}: automatic_apply debe ser false`)
583
- for (const section of REQUIRED_SECTIONS) {
584
- if (!text.includes(`## ${section}`)) errors.push(`${name}: falta sección ${section}`)
585
- }
586
- // Contar sólo las que esperan algo. Una propuesta aplicada contada como propuesta deja al cargo
587
- // reportando trabajo pendiente para siempre, y es la misma confusión que permitía reaplicarla.
588
- if (proposalState(text) !== 'applied') pending += 1
589
- }
590
- // Los hallazgos que todavía no llegaron al contrato. No es un error —la propuesta que los tome
591
- // puede no haberse abierto aún—, pero sin decirlo un informe escrito y olvidado se ve igual que uno
592
- // ya incorporado: los dos son un archivo en `reports/`.
593
- const reportDir = path.join(target, 'learning', 'reports')
594
- const unconsolidated = reportFiles(reportDir).filter((name) =>
595
- frontmatterState(fs.readFileSync(path.join(reportDir, name), 'utf8'), 'draft') !== 'consolidated')
596
- if (unconsolidated.length) {
597
- warnings.push(`${unconsolidated.length} informe(s) sin consolidar (${unconsolidated.join(', ')}): `
598
- + 'entran en la próxima propuesta')
599
- }
600
- let cases = 0
601
- try {
602
- cases = fs.readdirSync(path.join(target, 'evaluations', 'cases'))
603
- .filter((name) => name.endsWith('.md')).length
604
- } catch { /* vacío */ }
605
- return { errors, warnings, proposals: proposals.length, pending, cases }
606
- }
607
428
 
608
429
  module.exports = {
609
430
  SOURCE_TIERS,
@@ -0,0 +1,175 @@
1
+ 'use strict'
2
+
3
+ // Cómo se fusiona y se retira lo nuestro dentro de un archivo del usuario: la configuración del runner
4
+ // y el bloque enmarcado de un `AGENTS.md` compartido. Es una sola pregunta —qué entregamos y cómo se
5
+ // saca sin llevarse lo ajeno— y cambia cuando cambia el formato de esos archivos, no cuando cambia un
6
+ // comando.
7
+
8
+ const fs = require('node:fs')
9
+ const path = require('node:path')
10
+ const F = require('../core/files')
11
+ const { supersededGuards } = require('./hooks')
12
+
13
+ function mergeConfig(current, incoming) {
14
+ if (Array.isArray(incoming)) {
15
+ const values = [...(Array.isArray(current) ? current : []), ...incoming]
16
+ const seen = new Set()
17
+ return values.filter((value) => {
18
+ const key = JSON.stringify(value)
19
+ if (seen.has(key)) return false
20
+ seen.add(key)
21
+ return true
22
+ })
23
+ }
24
+ if (incoming && typeof incoming === 'object') {
25
+ const object = current && typeof current === 'object' && !Array.isArray(current)
26
+ const result = object ? { ...current } : {}
27
+ for (const [key, value] of Object.entries(incoming)) {
28
+ result[key] = mergeConfig(result[key], value)
29
+ }
30
+ return result
31
+ }
32
+ return incoming
33
+ }
34
+
35
+ // Una entrada de hook que puso Cauce se reconoce por el guard al que apunta: `automatization/hooks/`
36
+ // es nuestro y ninguna empresa escribe ahí. Se saca del archivo del usuario antes de fusionar para que
37
+ // el merge deje exactamente las de esta versión, ni una más.
38
+ const DELIVERED = /automatization\/hooks\/guard-[a-z-]+\.sh/
39
+
40
+ function withoutDeliveredHooks(config, live) {
41
+ const dropped = []
42
+ const walk = (node) => {
43
+ if (Array.isArray(node)) {
44
+ return node
45
+ .filter((item) => {
46
+ const command = item && typeof item === 'object' ? String(item.command || '') : ''
47
+ if (!DELIVERED.test(command)) return true
48
+ // Sólo se anuncia lo que ya no vuelve: una entrada que el merge repone quedó igual, y decir
49
+ // que se quitó y se puso la misma línea es ruido que esconde el caso que sí importa.
50
+ if (!live.has(command)) dropped.push(command)
51
+ return false
52
+ })
53
+ .map(walk)
54
+ .filter((item) => !(item && typeof item === 'object' && Array.isArray(item.hooks) && !item.hooks.length))
55
+ }
56
+ if (node && typeof node === 'object') {
57
+ return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, walk(value)]))
58
+ }
59
+ return node
60
+ }
61
+ return { config: walk(config), dropped }
62
+ }
63
+
64
+ // Por qué se fue cada entrada nuestra. Un guard suelto que ahora cubre un grupo no es lo mismo que una
65
+ // ruta que dejó de existir: el primero se ejecutaba dos veces por herramienta —con `verify`, la suite
66
+ // entera del proyecto dos veces por commit—, y el segundo no se ejecutaba nunca. Decirlo distinto es lo
67
+ // único que le permite a alguien darse cuenta de cuál de los dos tenía.
68
+ function reportRemoved(name, dropped, live, output) {
69
+ const wrappers = new Map()
70
+ const loose = []
71
+ for (const command of dropped) {
72
+ const hit = supersededGuards().find(
73
+ (entry) => command.endsWith(entry.file) && [...live].some((v) => v.endsWith(entry.wrapper)),
74
+ )
75
+ if (hit) wrappers.set(hit.wrapper, [...(wrappers.get(hit.wrapper) || []), hit.file])
76
+ else loose.push(command)
77
+ }
78
+ for (const [wrapper, files] of wrappers) {
79
+ output.log(`− ${name}: reemplazado ${[...new Set(files)].join(', ')} por ${wrapper}`)
80
+ }
81
+ for (const command of loose) output.log(`− ${name}: quitada una entrada obsoleta (${command})`)
82
+ }
83
+
84
+ function includesConfig(actual, expected) {
85
+ if (Array.isArray(expected)) {
86
+ return Array.isArray(actual) && expected.every((item) => {
87
+ return actual.some((value) => JSON.stringify(value) === JSON.stringify(item))
88
+ })
89
+ }
90
+ if (expected && typeof expected === 'object') {
91
+ return actual && typeof actual === 'object' && Object.entries(expected).every(([key, value]) => {
92
+ return includesConfig(actual[key], value)
93
+ })
94
+ }
95
+ return actual === expected
96
+ }
97
+
98
+ function hasHooks(config) {
99
+ if (config.hooks && Object.keys(config.hooks).length) return true
100
+ const events = [
101
+ 'PreToolUse',
102
+ 'PostToolUse',
103
+ 'PreInvocation',
104
+ 'PostInvocation',
105
+ 'Stop',
106
+ 'SessionEnd',
107
+ ]
108
+ return Object.values(config).some((entry) => {
109
+ return entry && typeof entry === 'object' && events.some((event) => event in entry)
110
+ })
111
+ }
112
+
113
+ // Quita de una estructura de configuración exactamente lo que este adaptador habría puesto, y nada más.
114
+ // Es el inverso de `mergeConfig`: una entrada del usuario nunca coincide literalmente con la nuestra, así
115
+ // que sobrevive; una que editó tampoco coincide, y por eso se conserva y se avisa en vez de borrarse.
116
+ function unmergeConfig(current, incoming) {
117
+ if (Array.isArray(incoming)) {
118
+ if (!Array.isArray(current)) return current
119
+ const ours = new Set(incoming.map((value) => JSON.stringify(value)))
120
+ return current.filter((value) => !ours.has(JSON.stringify(value)))
121
+ }
122
+ if (incoming && typeof incoming === 'object') {
123
+ if (!current || typeof current !== 'object' || Array.isArray(current)) return current
124
+ const result = { ...current }
125
+ for (const [key, value] of Object.entries(incoming)) {
126
+ if (!(key in result)) continue
127
+ const clean = unmergeConfig(result[key], value)
128
+ // Una clave que queda vacía por habernos ido no es del usuario: la creamos nosotros al instalar.
129
+ const empty = clean === undefined
130
+ || (Array.isArray(clean) && !clean.length)
131
+ || (clean && typeof clean === 'object' && !Array.isArray(clean) && !Object.keys(clean).length)
132
+ if (empty) delete result[key]
133
+ else result[key] = clean
134
+ }
135
+ return result
136
+ }
137
+ return JSON.stringify(current) === JSON.stringify(incoming) ? undefined : current
138
+ }
139
+
140
+ const blockStart = (name) => `<!-- cauce:${name} inicio — lo reescribe "automation install", no editar -->`
141
+ const blockEnd = (name) => `<!-- cauce:${name} fin -->`
142
+
143
+ function isSharedFile(root, target) {
144
+ return path.resolve(target) === path.resolve(root, 'AGENTS.md')
145
+ }
146
+
147
+ function withoutBlock(text, name) {
148
+ const sourceRoot = text.indexOf(blockStart(name))
149
+ if (sourceRoot === -1) return text
150
+ const until = text.indexOf(blockEnd(name), sourceRoot)
151
+ if (until === -1) return text
152
+ return `${text.slice(0, sourceRoot)}${text.slice(until + blockEnd(name).length)}`.trimEnd()
153
+ }
154
+
155
+ function mergeInstruction(file, name, content) {
156
+ const actual = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
157
+ const instructionBody = withoutBlock(actual, name).trimEnd()
158
+ const block = `${blockStart(name)}\n\n${content.trim()}\n\n${blockEnd(name)}\n`
159
+ F.atomicWrite(file, instructionBody ? `${instructionBody}\n\n${block}` : block)
160
+ }
161
+
162
+ function blockUpToDate(file, name, content) {
163
+ if (!fs.existsSync(file)) return false
164
+ const body = fs.readFileSync(file, 'utf8')
165
+ const sourceRoot = body.indexOf(blockStart(name))
166
+ const until = body.indexOf(blockEnd(name))
167
+ if (sourceRoot === -1 || until === -1) return false
168
+ return body.slice(sourceRoot + blockStart(name).length, until).trim() === content.trim()
169
+ }
170
+
171
+ module.exports = {
172
+ blockStart,
173
+ mergeConfig, withoutDeliveredHooks, reportRemoved, includesConfig, hasHooks,
174
+ unmergeConfig, isSharedFile, withoutBlock, mergeInstruction, blockUpToDate,
175
+ }