@ingeniomaps/cauce 0.73.0 → 0.75.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 CHANGED
@@ -14,6 +14,107 @@ desde este repositorio no va, porque el que lee no puede actuar sobre eso. Cuand
14
14
  unas pocas líneas casi siempre es porque cuenta cómo se descubrió el problema o por qué se eligió el
15
15
  diseño — eso vive en el commit y en el código.
16
16
 
17
+ ## [0.75.0] - 2026-09-10
18
+
19
+ ### Cambiado
20
+
21
+ - **R9 dice ahora que una quita casi nunca se ve como una quita, y que eso no admite excepción.** Se
22
+ escribe como un agregado —una bandera que se pone, una variable que se exporta— y lo que la delata es
23
+ la frase que la justifica: «lo agrego **para que** deje de …». Silenciar un aviso, saltear una rama o
24
+ desarmar una confirmación son quitas, y una quita no se entrega sin su aserción de ausencia vista en
25
+ rojo devolviendo lo quitado.
26
+
27
+ Con un renglón propio para el caso que más se disfraza: **una confirmación que estorba casi siempre
28
+ está cuidando algo**, y si no sabés qué, eso es el resultado de la medición y no un permiso para
29
+ seguir. Lo que corresponde es quitarle a la herramienta el motivo de preguntar, no la pregunta.
30
+
31
+ **Lo que te pide algo**: es una regla del sistema, así que baja a tu `planning/` en el próximo
32
+ `upgrade` y aplica a todo cambio, no sólo a los del toolkit.
33
+
34
+ - **Un punto y coma dentro de la prosa de `tests:` ya no parte la traza.** El `;` es el separador que R8
35
+ fija y a la vez el signo más común de la prosa española, y ese campo pide las dos cosas: el contrato
36
+ pide `CN → prueba` y R9 pide decir cómo se vio fallar esa prueba. Una sola traza con prosa se partía en
37
+ tres y `check` rechazaba el campo entero mandando a revisar la traza, que era lo único que estaba bien
38
+ — y la salida fácil era acortar la prosa hasta que pasara, o sea empobrecer justo la evidencia.
39
+
40
+ Ahora se corta sólo cuando detrás **empieza otra traza**, que es la misma decisión que `commit:` ya
41
+ tomaba para el sha. Las entradas con varias trazas siguen valiendo igual.
42
+
43
+ **Lo que te pide algo**: si tu prosa contiene literalmente `; A → algo`, se va a partir ahí — desde
44
+ afuera es indistinguible de dos trazas. Es la misma concesión que `commit:` aceptó.
45
+
46
+ - **Un caso no se cierra sin haberlo probado corriendo.** El `## Cierre` nombra qué se corrió y qué
47
+ devolvió —la salida, la mutación vista en rojo, el número medido—; «la suite pasa» no cuenta, porque
48
+ dice que nada de lo que ya había se rompió y no que esto funcione. Vale igual cuando se decide no
49
+ arreglar: ahí se prueba el dato que sostiene la decisión. La puerta lo comprueba desde esta versión y
50
+ no hacia atrás, por lo mismo que el contraste rige desde 0.65.0.
51
+
52
+ ### Corregido
53
+
54
+ - **`autobuild` deja de reintentar un reclamo que no puede salir bien.** Cuando la cola vuelve a ofrecer
55
+ la misma tarea después de un `claim` fallido, nadie la tomó: el reclamo falló por su cuenta y repetirlo
56
+ no cambia nada. Antes se repetía hasta agotar el cupo de tareas de la corrida — en una corrida real
57
+ **28 de 50 agentes** se fueron ahí, sin construir nada y sin que nada lo dijera. Ahora para con
58
+ `claim-stuck`, y el motivo lleva el slug que la cola ofreció y lo que contestó el reclamo. Perder la
59
+ carrera de verdad sigue sin frenar: ahí la cola pasa a ofrecer otra tarea, y ahora la corrida dice con
60
+ cuál sigue. Lo mismo en Decompose: si tras pedir la partición la cola sigue ofreciendo la tarea sin
61
+ partir, la escritura no ocurrió y para con `split-not-applied`.
62
+
63
+ - **El registro de una corrida dice qué hace cada agente.** Las llamadas sin nombre se veían como el
64
+ arranque del preámbulo compartido, que es igual en todas: la misma cadena repetida treinta veces, y un
65
+ bucle de veintiocho agentes indistinguible de trabajo. Las veintisiete llamadas del recorrido llevan
66
+ etiqueta propia.
67
+
68
+ - **Un gate ya no puede borrar el `node_modules` de tu proyecto, y se revierte el `CI=true` de 0.74.0.**
69
+ Esa variable resolvía el síntoma del caso anterior —pnpm dejaba de preguntar antes de purgar—
70
+ desarmando la confirmación en vez de quitarle el motivo. Y esa confirmación era lo único que protegía
71
+ al árbol enlazado: sin ella la reinstalación avanza y **borra por el enlace**. Medido: `require()`
72
+ dejaba de encontrar las dependencias del proyecto, y la instalación ni siquiera necesita completarse
73
+ para hacerlo — en la corrida medida abortó por `frozen-lockfile` y para entonces ya había borrado.
74
+
75
+ Ahora la copia lleva `verify-deps-before-run=false`, que le dice a pnpm que no sincronice nada antes de
76
+ correr el script. No hay purga que confirmar, así que no hay confirmación que desarmar.
77
+
78
+ **Lo que te pide algo**: si tenías un gate que se portaba distinto bajo `CI`, deja de verla. Vuelven el
79
+ color y los prompts de otras herramientas — y un prompt en un proceso sin terminal aborta, que es la
80
+ barrera que se quiere de vuelta.
81
+
82
+ ## [0.74.0] - 2026-09-10
83
+
84
+ ### Corregido
85
+
86
+ - **Un gate ya no pisa lo que vos construiste.** Al medir el índice, lo ignorado se enlazaba al árbol
87
+ real: el gate corría sobre lo staged y te dejaba la salida de build con **esa** versión, mientras tu
88
+ fuente en disco tenía otra y nada lo decía. Si corrías la app después de commitear, corrías algo que
89
+ no era lo que estabas mirando. Ahora lo que un gate puede fabricar —`dist`, `build`, `out`,
90
+ `coverage`, `.next`, `.nuxt`, `.svelte-kit`, `.turbo`, `.output`, `.parcel-cache`, `__pycache__`,
91
+ `.pytest_cache`— se construye adentro de la copia y se descarta con ella. Lo que no puede fabricar
92
+ —`node_modules`, un `.env`— se le sigue enlazando.
93
+
94
+ Arregla también algo del propio gate: construía sobre restos de tu corrida anterior, así que su
95
+ veredicto dependía de un estado que nadie declaró.
96
+
97
+ **Lo que te pide algo**: el build del gate deja de ser incremental, así que ese commit tarda más. Y si
98
+ tu proyecto genera en un directorio ignorado que no está en esa lista, seguí reportándolo: el nombre
99
+ se agrega.
100
+
101
+ - **Los gates corren sobre una copia que se declara no interactiva, y un gate que falla dice qué dijo.**
102
+ `verify` mide el índice en un temporal y enlaza ahí lo ignorado, `node_modules` incluido, apuntando al
103
+ original. En un proyecto pnpm eso no corre: el gestor ve que el árbol enlazado no fue instalado ahí y
104
+ su reacción es reinstalar, que empieza borrando el `node_modules` **del proyecto**. Lo único que lo
105
+ detenía es que el hijo no ve una terminal. Ahora la copia lleva `CI=true`, que es la variable que el
106
+ propio pnpm nombra — y sólo la copia: si árbol e índice coinciden, los gates corren en tu directorio y
107
+ ahí no se te cambia nada.
108
+
109
+ Y el bloqueo dejaba de decir por qué: `test (exit 1)` era el mismo texto para una suite en rojo y para
110
+ un gestor que se negó a arrancar el script, así que empujaba a aprobar el commit como «rojo conocido»
111
+ sin que nada se hubiera medido. Ahora llega con la duración y con la primera línea de error de la
112
+ herramienta, y cuando todos los gates fallan por debajo de dos segundos lo dice — sin afirmar que no
113
+ corrieron, que es algo que no se puede saber desde acá.
114
+
115
+ **Lo que te pide algo**: si tenías un gate que se comportaba distinto bajo `CI`, ahora lo va a hacer
116
+ al commitear con algo sin stagear o sin trackear.
117
+
17
118
  ## [0.73.0] - 2026-09-09
18
119
 
19
120
  ### Corregido
@@ -332,6 +332,14 @@ const LEDGER = `${SCOPE}\n\nContratos de planning, textuales de ${P}/PROTOCOL.md
332
332
  // con un TypeError en la fase que sea, y lo que quedó a medias es una tarea con WIP escrito y código
333
333
  // sin revisar. Cada llamada corta con su etapa puesta: «no contestó» no es lo mismo que «dijo que no»,
334
334
  // y sólo la segunda significa que alguien juzgó algo.
335
+ //
336
+ // Y cada llamada lleva su `label`. Sin él el runtime muestra el arranque del prompt, que acá es el
337
+ // preámbulo compartido: en cinco corridas reales el journal repitió treinta veces la misma cadena
338
+ // —«Nunca inventes credenciales ni decisiones; registrá »— y un bucle de veintiocho agentes pidiendo
339
+ // la misma tarea se vio igual que trabajo. Al revés que `phase`, esto no se puede envolver: el nombre
340
+ // de la fase no alcanza —Critique planifica y critica, Review revisa y manda a corregir— y esa es
341
+ // justo la distinción que hace falta. Lo que evita el olvido es el arnés, que rechaza la llamada sin
342
+ // etiqueta en las cuatro suites del recorrido.
335
343
  const read = (prompt, options = {}) => agent(`${BASE}\n\n${prompt}`, options)
336
344
  const run = (prompt, options = {}) => agent(`${SCOPE}\n\n${prompt}`, options)
337
345
  const write = (prompt, options = {}) => agent(`${LEDGER}\n\n${prompt}`, options)
@@ -365,6 +373,9 @@ const completed = []
365
373
  // para siempre. Se corta con motivo porque agotarlo en silencio se lee igual que haber terminado.
366
374
  const MAX_TASKS = 50
367
375
  let rounds = 0
376
+ // Lo dicen las dos vueltas que cambian de tarea a mitad de corrida —la carrera perdida y la partición—,
377
+ // que no son errores y por eso no salen por `stop`.
378
+ const nextUp = (state) => (state.hasTask ? state.slug : '(nada más en cola)')
368
379
  // Tareas que ya pasaron por el clasificador en esta corrida. Sin esto, una que vuelve sin lane
369
380
  // —porque la escritura falló o el modelo la salteó— se reclasifica en cada vuelta del bucle.
370
381
  const classified = new Set()
@@ -400,6 +411,28 @@ while (rounds++ < MAX_TASKS) {
400
411
  if (!reserva || !reserva.claimed) {
401
412
  planning = await readContext()
402
413
  if (!planning) return stop('context-unavailable', `no se pudo releer el estado de ${P}`)
414
+ // Perder la carrera es legítimo y se ve en que la cola pasa a ofrecer **otra** tarea: quien la
415
+ // tomó ya la reclamó, así que `context` la saltea. Que vuelva a ofrecer la misma significa lo
416
+ // contrario — que nadie la tiene y el reclamo falló por su cuenta—, y eso no mejora repitiendo.
417
+ //
418
+ // Reintentar igual costó 28 de los 50 agentes de una corrida real, trece sobre un slug y quince
419
+ // sobre otro, sin construir nada y sin que nada lo dijera: el único tope es `MAX_TASKS`, así que
420
+ // el presupuesto de reintentos **es** el de tareas y una tarea irreclamable se lleva la corrida
421
+ // (caso 071).
422
+ //
423
+ // La parada nombra las dos cosas que hacen falta para saber cuál de los dos defectos fue: el slug
424
+ // que `context` entregó y lo que el reclamo contestó. Si el mensaje dice que ese slug no está en
425
+ // BACKLOG, los dos comandos discrepan sobre la misma cola; si dice otra cosa, el comando se compuso
426
+ // distinto del que se pidió.
427
+ if (planning.hasTask && planning.slug === task.id && !planning.claimed) {
428
+ return stop('claim-stuck', `${task.id} sigue siendo la próxima tarea y no se pudo reclamar. `
429
+ + `context la ofrece y claim la rechaza, así que repetir no cambia nada. `
430
+ + `El reclamo contestó: ${(reserva && reserva.details) || '(sin detalle)'}`)
431
+ }
432
+ // Con qué sigue, que es lo que cambia respecto de lo que esperaba quien autorizó la corrida: se
433
+ // pidió un hito y se va a construir otra tarea de ese hito. Sin decirlo, el cambio sólo aparece al
434
+ // final, en un cierre que nombra algo que nadie mandó a hacer.
435
+ log(`${task.id} la tomó otro: la corrida sigue con ${nextUp(planning)}`)
403
436
  continue
404
437
  }
405
438
  }
@@ -424,7 +457,7 @@ while (rounds++ < MAX_TASKS) {
424
457
  `corchetes después del slug y el reparto al final de la línea, con la forma ` +
425
458
  `"(cast: quien-entrega → quien-revisa, otro)". No toques nada más de la línea, ni el orden del hito, ` +
426
459
  `ni las tareas que ya declaran las dos cosas. Reportá lo que escribiste.`,
427
- { schema: CLASSIFICATION },
460
+ { schema: CLASSIFICATION, label: 'classify' },
428
461
  )
429
462
  if (classification && classification.classified.length) {
430
463
  log(`Clasificadas: ${classification.classified
@@ -479,11 +512,12 @@ while (rounds++ < MAX_TASKS) {
479
512
  `${asRole(OWNERS.ready)}Revisá que ${task.id} tenga aceptación concreta, dependencias resueltas y ` +
480
513
  `ninguna decisión pendiente: ${task.acceptance}. Aclará la redacción y nada más; nunca amplíes el ` +
481
514
  `alcance.`,
482
- { schema: READY },
515
+ { schema: READY, label: 'ready' },
483
516
  )
484
517
  if (!ready) return stop('agent-unavailable', 'Ready no devolvió resultado')
485
518
  if (!ready.ready) {
486
- await write(`Registrá ${task.id} en ${HUMAN} con el motivo y una acción humana exacta: ${ready.reason}.`)
519
+ await write(`Registrá ${task.id} en ${HUMAN} con el motivo y una acción humana exacta: ${ready.reason}.`,
520
+ { label: 'ready-human' })
487
521
  return stop('not-ready', ready.reason)
488
522
  }
489
523
  if (ready.refinedAcceptance) task.acceptance = ready.refinedAcceptance
@@ -493,14 +527,22 @@ while (rounds++ < MAX_TASKS) {
493
527
  phase('Decompose')
494
528
  const estimate = await run(
495
529
  `Inspeccioná ${task.service} y estimá ${task.id}. Partila sólo si supera ${contract.maxTaskHours} horas.`,
496
- { schema: ESTIMATE },
530
+ { schema: ESTIMATE, label: 'estimate' },
497
531
  )
498
532
  if (!estimate) return stop('agent-unavailable', 'Decompose no devolvió resultado')
499
533
  if (estimate.needsSplit) {
500
534
  await write(`Reemplazá sólo ${task.id} en ${BACKLOG} por subtareas ordenadas y verificables de forma ` +
501
- `independiente: ${JSON.stringify(estimate.subtasks)}.`)
535
+ `independiente: ${JSON.stringify(estimate.subtasks)}.`, { label: 'split' })
502
536
  planning = await readContext()
503
537
  if (!planning) return stop('context-unavailable', `no se pudo releer el estado de ${P}`)
538
+ // Misma forma que en Claim: si la cola sigue ofreciendo lo mismo, el estado no cambió y repetir
539
+ // no lo va a cambiar. Acá lo que no cambió es una escritura que se le pidió a un agente, y darla
540
+ // por hecha manda al bucle a partir la misma tarea otra vez.
541
+ if (planning.hasTask && planning.slug === task.id) {
542
+ return stop('split-not-applied', `se pidió reemplazar ${task.id} en ${BACKLOG} por sus `
543
+ + 'subtareas y la cola sigue ofreciéndola: la escritura no ocurrió como se pidió.')
544
+ }
545
+ log(`${task.id} quedó partida: la corrida sigue con ${nextUp(planning)}`)
504
546
  continue
505
547
  }
506
548
  }
@@ -519,7 +561,7 @@ while (rounds++ < MAX_TASKS) {
519
561
  `sólo el cambio dentro de ${task.service}: correr los gates del repositorio, hacer QA, commitear y ` +
520
562
  `cerrar la tarea son fases posteriores de este recorrido, cada una con su dueño, así que no van como ` +
521
563
  `pasos.`,
522
- { schema: PLAN },
564
+ { schema: PLAN, label: 'plan' },
523
565
  )
524
566
  if (!plan) return stop('agent-unavailable', 'Plan no devolvió resultado')
525
567
  if (!lite) {
@@ -527,7 +569,7 @@ while (rounds++ < MAX_TASKS) {
527
569
  let critique = await read(
528
570
  `Atacá este plan por correctitud, alcance, seguridad, pruebas y conflictos con el código ` +
529
571
  `existente.${MANIFEST}${VERDICT} Plan: ${JSON.stringify(plan)}`,
530
- { schema: DECISION },
572
+ { schema: DECISION, label: 'critique' },
531
573
  )
532
574
  if (!critique) return stop('agent-unavailable', 'Critique no devolvió resultado')
533
575
  // Un plan bloqueado no se corrige: lo que lo bloquea está fuera de lo que una segunda pasada puede
@@ -538,12 +580,12 @@ while (rounds++ < MAX_TASKS) {
538
580
  if (blockers(critique).length) {
539
581
  plan = await read(
540
582
  `Corregí el plan una vez por: ${blockers(critique).join('; ')}. Plan: ${JSON.stringify(plan)}`,
541
- { schema: PLAN },
583
+ { schema: PLAN, label: 'replan' },
542
584
  )
543
585
  critique = await read(
544
586
  `Volvé a criticar el plan corregido contra ${task.acceptance}.${MANIFEST}${VERDICT} ` +
545
587
  `Plan: ${JSON.stringify(plan)}`,
546
- { schema: DECISION },
588
+ { schema: DECISION, label: 'critique' },
547
589
  )
548
590
  if (!plan || !critique) return stop('agent-unavailable', 'la revisión del plan no devolvió resultado')
549
591
  if (critique.verdict === 'bloqueado' || blockers(critique).length) {
@@ -568,7 +610,7 @@ while (rounds++ < MAX_TASKS) {
568
610
  `acceptance=${JSON.stringify(task.acceptance)}, pasos sin tildar=${JSON.stringify(plan.steps)}. ` +
569
611
  `Registrá el reparto de cargos ${JSON.stringify(cast)} en las decisiones del WIP, para que después se ` +
570
612
  `pueda auditar quién revisó qué. Seguí el contrato de WIP exactamente y reportá con qué status quedó.`,
571
- { schema: {
613
+ { label: 'wip', schema: {
572
614
  type: 'object', additionalProperties: false, required: ['wipActive'],
573
615
  properties: { wipActive: { type: 'boolean' }, note: { type: 'string' } },
574
616
  } },
@@ -593,7 +635,7 @@ while (rounds++ < MAX_TASKS) {
593
635
  `registra para que lo decida quien corresponde y el recorrido sigue. Si de verdad no podés entregar sin ` +
594
636
  `esa decisión, eso no va en discovered: es completed=false con su blocker. ` +
595
637
  `Aceptación: ${task.acceptance}.`,
596
- { schema: BUILD },
638
+ { schema: BUILD, label: 'build' },
597
639
  )
598
640
  if (!build) return stop('agent-unavailable', 'Build no devolvió resultado')
599
641
  if (!build.completed) return stop('build-blocked', (build.blockers || []).join('; ') || build.summary)
@@ -615,7 +657,7 @@ while (rounds++ < MAX_TASKS) {
615
657
  if (openDecisions.length) {
616
658
  await write(`Registrá en ${HUMAN} una fila por cada decisión que ${task.id} dejó abierta, con qué la ` +
617
659
  `cierra y quién puede tomarla. No inventes responsables ni fechas: ` +
618
- `${JSON.stringify(openDecisions.map((entry) => entry.detail))}`)
660
+ `${JSON.stringify(openDecisions.map((entry) => entry.detail))}`, { label: 'open-decisions' })
619
661
  }
620
662
  // Y un caso que sí se fijó acá entra con su prueba o no entró: sin ella el comportamiento nuevo queda
621
663
  // sin nada que lo sostenga, y nadie sabe después que debía existir.
@@ -639,16 +681,17 @@ while (rounds++ < MAX_TASKS) {
639
681
  `${asRole(cast.review)}Revisá el diff real por aceptación, regresiones, seguridad, arquitectura, código ` +
640
682
  `generado, migraciones y alcance accidental. Cada cargo revisa su dominio, no el ajeno.${MANIFEST}` +
641
683
  `${VERDICT}`,
642
- { schema: DECISION },
684
+ { schema: DECISION, label: 'review' },
643
685
  )
644
686
  if (!review) return stop('agent-unavailable', 'Review no devolvió resultado')
645
687
  if (review.verdict === 'bloqueado') {
646
688
  return stop('review-blocked', blockers(review).join('; ') || 'sin condiciones nombradas')
647
689
  }
648
690
  if (blockers(review).length) {
649
- await write(`Corregí sólo estos hallazgos con evidencia y actualizá el WIP: ${blockers(review).join('; ')}`)
691
+ await write(`Corregí sólo estos hallazgos con evidencia y actualizá el WIP: ${blockers(review).join('; ')}`,
692
+ { label: 'review-fix' })
650
693
  review = await run(`Volvé a revisar el diff corregido de ${task.id}.${MANIFEST}${VERDICT}`,
651
- { schema: DECISION })
694
+ { schema: DECISION, label: 'review' })
652
695
  if (!review) return stop('agent-unavailable', 'la re-revisión no devolvió resultado')
653
696
  if (review.verdict === 'bloqueado' || blockers(review).length) {
654
697
  return stop('review-failed', blockers(review).join('; ') || 'sin condiciones nombradas')
@@ -664,7 +707,7 @@ while (rounds++ < MAX_TASKS) {
664
707
  const noted = review.concerns.filter((one) => !one.blocking).map((one) => one.detail)
665
708
  if (noted.length) {
666
709
  await write(`Registrá en la sección Propuestas de ${P}/INBOX.md lo que la revisión de ${task.id} dejó ` +
667
- `anotado sin frenar la entrega, sin promover ninguna: ${JSON.stringify(noted)}`)
710
+ `anotado sin frenar la entrega, sin promover ninguna: ${JSON.stringify(noted)}`, { label: 'review-noted' })
668
711
  }
669
712
  }
670
713
 
@@ -687,7 +730,7 @@ while (rounds++ < MAX_TASKS) {
687
730
  `passed=true exige comandos corridos y ninguna regresión causada por la tarea. Marcá ranTests en el ` +
688
731
  `comando que haya corrido las pruebas, sea cual sea su nombre. ` +
689
732
  `Aceptación: ${task.acceptance}.`
690
- let verified = await run(VERIFY_ASK, { schema: VERIFY })
733
+ let verified = await run(VERIFY_ASK, { schema: VERIFY, label: 'verify' })
691
734
  if (!verified) return stop('agent-unavailable', 'Verify no devolvió resultado')
692
735
  // Un criterio que nadie sabe cómo aserciar no es trabajo que falta sino una definición que falta, y
693
736
  // definirla acá sería inventarla. Escribir la prueba que falta, en cambio, es trabajo del recorrido:
@@ -695,13 +738,14 @@ while (rounds++ < MAX_TASKS) {
695
738
  const ambiguous = verified.uncovered.find((entry) => entry.cause === 'ambiguous')
696
739
  if (ambiguous) {
697
740
  await write(`Registrá ${task.id} en ${HUMAN}: el criterio "${ambiguous.criterion}" no dice qué habría ` +
698
- `que aserciar, y hace falta la decisión que lo fija.`)
741
+ `que aserciar, y hace falta la decisión que lo fija.`, { label: 'verify-human' })
699
742
  return stop('acceptance-ambiguous', ambiguous.criterion)
700
743
  }
701
744
  if (verified.uncovered.length) {
702
745
  await run(`${asRole(cast.build)}Escribí sólo las pruebas que faltan en ${task.id}, con el mismo rojo ` +
703
- `previo, y no toques el código de producción: ${verified.uncovered.map((e) => e.criterion).join('; ')}`)
704
- verified = await run(VERIFY_ASK, { schema: VERIFY })
746
+ `previo, y no toques el código de producción: ${verified.uncovered.map((e) => e.criterion).join('; ')}`,
747
+ { label: 'missing-tests' })
748
+ verified = await run(VERIFY_ASK, { schema: VERIFY, label: 'verify' })
705
749
  if (!verified) return stop('agent-unavailable', 'la segunda pasada de Verify no devolvió resultado')
706
750
  }
707
751
  if (!verified.passed || !verified.commands.length) return stop('verify-failed', verified.details)
@@ -726,7 +770,7 @@ while (rounds++ < MAX_TASKS) {
726
770
  : 'Ejercitá el comportamiento real que ve quien lo usa'} para ` +
727
771
  `${task.id}. Las pruebas unitarias solas no son QA. Levantá el mínimo runtime necesario y bajalo ` +
728
772
  `después. Aceptación: ${task.acceptance}.`,
729
- { schema: QA },
773
+ { schema: QA, label: 'qa' },
730
774
  )
731
775
  if (!qa) return stop('agent-unavailable', 'QA no devolvió resultado')
732
776
  if (!qa.passed) return stop('qa-failed', qa.evidence)
@@ -738,7 +782,7 @@ while (rounds++ < MAX_TASKS) {
738
782
  `stageá por nombre los archivos de la tarea, creá un solo Conventional Commit con el footer ` +
739
783
  `"Task: ${task.id}" y después verificá log y status. Nunca amend ni push; reportá lo que quedó suelto ` +
740
784
  `y no era de la tarea.`,
741
- { schema: COMMIT },
785
+ { schema: COMMIT, label: 'commit' },
742
786
  ) : { committed: true, reason: 'runner.commitPerTask está apagado' }
743
787
  if (!commit) return stop('agent-unavailable', 'Commit no devolvió resultado')
744
788
  if (!commit.committed) return stop('commit-failed', commit.reason)
@@ -753,6 +797,7 @@ while (rounds++ < MAX_TASKS) {
753
797
  `que no figure en estos hechos. Hechos: lane=${planning.lane || 'sin clasificar'}; ` +
754
798
  `review=${reviewFact}; fases=${ran.join(' → ')}; build=${build.summary}; ` +
755
799
  `verify=${JSON.stringify(verified.commands)}; qa=${qa.evidence}; commit=${commit.hash || commit.reason}.`,
800
+ { label: 'done' },
756
801
  )
757
802
  completed.push(task.id)
758
803
  planning = await readContext()
@@ -767,6 +812,7 @@ phase('Closing')
767
812
  const closing = await write(
768
813
  `Corré "node tools/ops.js check ${P}" desde ${ROOT}. Si sale en rojo, reparás sólo estado derivado ` +
769
814
  `determinista; nunca reescribas aceptación ni decisiones para forzar el verde.`, {
815
+ label: 'closing',
770
816
  schema: {
771
817
  type: 'object', required: ['passed', 'details'],
772
818
  properties: { passed: { type: 'boolean' }, details: { type: 'string' } },
@@ -778,5 +824,6 @@ if (!closing.passed) return stop('planning-check-failed', closing.details)
778
824
  if (completed.length && contract.humanCheckpoint) await write(
779
825
  `Creá ${GATE} con el hito terminado, las tareas ${completed.join(', ')}, la evidencia, las acciones humanas ` +
780
826
  `pendientes y las instrucciones exactas para continuar. Nunca hagas push ni deploy.`,
827
+ { label: 'human-checkpoint' },
781
828
  )
782
829
  return finish({ done: completed, count: completed.length, hito: currentMilestone, phases: ran })
@@ -66,6 +66,45 @@ function agents(action, dir, extra, cli) {
66
66
  }
67
67
  }
68
68
 
69
+ // Qué decir cuando el banco sobrevivió a su propio borrado, que es lo único que va a permitir
70
+ // establecer la causa. Devuelve el mensaje en vez de escribirlo donde ocurre, y eso es lo que lo hace
71
+ // medible sin provocar el fallo; por qué eso importa acá lo dice su prueba.
72
+ //
73
+ // Tres cosas que el listado anterior no traía, y cada una separa dos diagnósticos distintos:
74
+ //
75
+ // - **Cuánto**, y no una muestra. Cortaba en cinco, así que «borró casi todo y quedaron cuatro objetos»
76
+ // y «no borró nada» se leían idénticos, y son problemas opuestos.
77
+ // - **Si lo que quedó es anterior al borrado o se escribió durante.** Posterior significa que alguien
78
+ // reescribió mientras borrábamos; anterior, que el borrado no lo tocó. Es la pregunta central del
79
+ // caso y la contesta la fecha de modificación.
80
+ // - **Qué hace un segundo borrado.** No lo rodea: quien lo llama corta igual.
81
+ // Distingue lo transitorio de lo permanente, que se arreglan distinto.
82
+ function benchSurvived(dir, since) {
83
+ let files = 0
84
+ let dirs = 0
85
+ const sample = []
86
+ const walk = (base, relative = '') => {
87
+ for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
88
+ const next = relative ? `${relative}/${entry.name}` : entry.name
89
+ if (entry.isDirectory()) { dirs += 1; walk(path.join(base, entry.name), next); continue }
90
+ files += 1
91
+ if (sample.length >= 5) continue
92
+ const stat = fs.statSync(path.join(base, entry.name), { throwIfNoEntry: false })
93
+ sample.push(`${next} (${!stat ? 'ya no está'
94
+ : stat.mtimeMs >= since ? 'escrito durante el borrado' : 'anterior al borrado'})`)
95
+ }
96
+ }
97
+ try { walk(dir) } catch { /* el listado es la explicación, no la comprobación */ }
98
+ let again = 'no se pudo reintentar'
99
+ try {
100
+ fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
101
+ again = fs.existsSync(dir) ? 'un segundo borrado tampoco lo sacó' : 'un segundo borrado sí lo sacó'
102
+ } catch (error) { again = `un segundo borrado lanzó ${error.code || error.message}` }
103
+ return `${dir} no se pudo borrar entero y el banco tiene que ser nuevo. Sobrevivieron ${files} `
104
+ + `archivo(s) en ${dirs} directorio(s), con Node ${process.version}: `
105
+ + `${sample.join(', ') || '(sólo directorios)'}. ${again}. Borralo a mano y volvé a correr.`
106
+ }
107
+
69
108
  // Un banco de trabajo desechable donde un cargo del catálogo puede realmente trabajar.
70
109
  //
71
110
  // Hace falta porque el toolkit no es una raíz ops: el único `planning/` que vive acá es
@@ -96,43 +135,35 @@ function evaluationBench(root, agent, caso, force, kind) {
96
135
  fail(`${dir} tiene trabajo sin recoger. Guardá el registro de esa corrida antes de rehacerlo, `
97
136
  + 'o usá --force si ya lo tenés.', 2)
98
137
  }
99
- // Con reintentos: el banco es un árbol grande y versionado —hay un `git status` dos líneas arriba— y
100
- // borrarlo entero falla a veces con ENOTEMPTY, que es transitorio. Pasó en CI rehaciendo un banco que
101
- // se acababa de crear: `ENOTEMPTY, Directory not empty: .cauce-eval/product-manager/11-otro`. Sin los
102
- // reintentos, rehacer un banco es una operación que falla de vez en cuando y deja la corrida sin
103
- // empezar.
138
+ // El instante de arranque, para poder fechar lo que sobreviva: es lo único que separa un archivo que
139
+ // el borrado no tocó de uno que alguien reescribió mientras borrábamos.
140
+ const since = Date.now()
141
+ // Con reintentos. Los puso el `ENOTEMPTY` que aparecía al rehacer un banco recién creado, y hoy se
142
+ // sabe que eso era el mantenimiento de git escribiendo por detrás —la causa está apagada quince líneas
143
+ // más abajo, en la creación—. Se quedan porque cubren a cualquier otro escritor transitorio, no porque
144
+ // sigan tapando éste; sacarlos es una decisión aparte y lo que la activaría es que nunca más disparen.
104
145
  fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
105
- // Y se comprueba que haya borrado. `rmSync` puede volver sin lanzar y dejar cosas —pasó en CI y no
106
- // está establecido por qué—, y hasta acá cada síntoma se rodeaba por separado: `force` en el
107
- // andamiaje, un `rm` antes del enlace. Rodearlo deja la corrida siguiendo sobre un banco que no es
108
- // nuevo, y lo que falla después no dice nada del borrado: el test que lo destapó reportaba
109
- // `true !== false` sobre un archivo de la corrida anterior, sin nombrar de dónde salía.
146
+ // Y se comprueba que haya borrado. `rmSync` puede volver sin lanzar y dejar cosas, y hasta acá cada
147
+ // síntoma se rodeaba por separado: `force` en el andamiaje, un `rm` antes del enlace. Rodearlo deja la
148
+ // corrida siguiendo sobre un banco que no es nuevo, y lo que falla después no dice nada del borrado: el
149
+ // test que lo destapó reportaba `true !== false` sobre un archivo de la corrida anterior, sin nombrar
150
+ // de dónde salía.
151
+ //
152
+ // Esta guarda es la que estableció la causa: fue su primer disparo instrumentado el que nombró al
153
+ // escritor. Se queda igual —lo que cubre ahora es que aparezca otro—.
110
154
  //
111
155
  // Falla en vez de seguir, porque un banco a medio borrar contamina la medición que viene, que es lo
112
- // que la recreación existe para evitar. Y nombra lo que sobrevivió: es lo único que va a permitir
113
- // establecer la causa la próxima vez que ocurra.
114
- if (fs.existsSync(dir)) {
115
- const sobreviven = []
116
- const recorrer = (base, relative = '') => {
117
- for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
118
- if (sobreviven.length >= 5) return
119
- const next = relative ? `${relative}/${entry.name}` : entry.name
120
- if (entry.isDirectory()) recorrer(path.join(base, entry.name), next)
121
- else sobreviven.push(next)
122
- }
123
- }
124
- try { recorrer(dir) } catch { /* el listado es la explicación, no la comprobación */ }
125
- fail(`${dir} no se pudo borrar entero y el banco tiene que ser nuevo. Sobrevivieron al borrado: `
126
- + `${sobreviven.join(', ') || '(sólo directorios)'}. Borralo a mano y volvé a correr.`, 2)
127
- }
156
+ // que la recreación existe para evitar. Qué trae el mensaje y por qué, en `benchSurvived`.
157
+ if (fs.existsSync(dir)) fail(benchSurvived(dir, since), 2)
128
158
  // Con `force`: el banco es desechable y se acaba de borrar, así que lo que sobreviva al `rmSync` se
129
159
  // pisa en vez de cortar la corrida. Sin esto, `copyTemplate` se niega ante cualquier archivo que
130
160
  // quede —«El destino contiene …/AGENTS.md»— y el mismo test falló así tres veces en un día, en las
131
- // dos patas de la matriz. Por qué algo sobrevive a un borrado que no lanzó no está establecido.
161
+ // dos patas de la matriz.
132
162
  //
133
163
  // No ablanda ninguna protección: la pregunta «¿acá alguien trabajó?» la contesta el `git status` de
134
164
  // arriba, que exige `--force` explícito para seguir. Esta segunda puerta no la eligió nadie y sólo
135
- // se cerraba a veces, que es la clase de freno que enseña a re-correr sin leer.
165
+ // se cerraba a veces, que es la clase de freno que enseña a re-correr sin leer. Ese «a veces» era el
166
+ // mismo escritor de fondo; con la causa apagada, esto cubre el residuo.
136
167
  IN.scaffold(dir, { name: 'Banco de evaluación', mode: 'sidecar', quiet: true, force: true })
137
168
  // El motor por symlink: la misma resolución que en una instancia real —`node_modules/@ingeniomaps`—
138
169
  // sin pagar un `npm install` por corrida. El cargo llega a un banco donde el CLI funciona.
@@ -172,6 +203,20 @@ function evaluationBench(root, agent, caso, force, kind) {
172
203
  git('init', '-q')
173
204
  git('config', 'user.email', 'banco@cauce.local')
174
205
  git('config', 'user.name', 'banco de evaluación')
206
+ // Y se le apaga el mantenimiento automático, que es el escritor de fondo que rompía el borrado del
207
+ // banco siguiente. `git commit` lanza `git maintenance run --auto`, que se detacha y sigue escribiendo
208
+ // en `.git/objects` después de que el comando ya volvió; el banco se rehace milisegundos más tarde y
209
+ // el `rmSync` corre contra alguien que está escribiendo ahí.
210
+ //
211
+ // Es lo que produjo los tres síntomas que se venían rodeando por separado —`ENOTEMPTY`, `EEXIST`, y el
212
+ // borrado que vuelve sin lanzar y deja archivos—. La guarda lo nombró el 2026-09-10:
213
+ // `maintenance.lock` entre los sobrevivientes, y `info/refs` y `objects/info/packs` fechados **durante**
214
+ // el borrado, en un árbol que ninguna otra prueba toca (caso 073).
215
+ //
216
+ // `maintenance.auto=false` y no `gc.auto=0`: medido con `GIT_TRACE=1`, el segundo deja que el commit
217
+ // lance el mantenimiento igual —sólo hace que su tarea de `gc` no encuentre trabajo— y el proceso
218
+ // toma su lock y escribe lo mismo. Se le quita el motivo de lanzarlo, no lo que hace una vez lanzado.
219
+ git('config', 'maintenance.auto', 'false')
175
220
  git('add', '-A')
176
221
  git('commit', '-q', '-m', 'banco limpio')
177
222
  return dir
@@ -353,4 +398,4 @@ function flow(action, slug, cli) {
353
398
  } catch (error) { fail(error.message, 2) }
354
399
  }
355
400
 
356
- module.exports = { agents, learn, evaluate, flow }
401
+ module.exports = { agents, learn, evaluate, flow, benchSurvived }
@@ -31,12 +31,16 @@ function logPath(root) {
31
31
 
32
32
  // Una línea por gate corrido. Nunca lanza: es un efecto de borde de un guard, y un registro que no se
33
33
  // puede escribir no puede impedir el commit que estaba juzgando.
34
- function record(root, gate, status) {
34
+ // `ms` es cuánto tardó el gate, y se guarda porque es lo que separa una suite que falló de una que
35
+ // nunca arrancó. Antes había que restar los `at` de dos líneas seguidas para estimarlo, y esa resta
36
+ // incluye lo que pasó entre gate y gate; el número propio no. Fue lo que costó diagnosticar el caso 068:
37
+ // tres gates «en rojo» a un segundo uno de otro, cuando la corrida real de ese proyecto tarda trece.
38
+ function record(root, gate, status, ms) {
35
39
  if (!root) return
36
40
  try {
37
41
  const file = logPath(root)
38
42
  const previous = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').filter(Boolean) : []
39
- const entry = JSON.stringify({ at: new Date().toISOString(), gate, status })
43
+ const entry = JSON.stringify({ at: new Date().toISOString(), gate, status, ...(ms >= 0 ? { ms } : {}) })
40
44
  fs.mkdirSync(path.dirname(file), { recursive: true })
41
45
  fs.writeFileSync(file, `${[...previous, entry].slice(-MAX_RUNS).join('\n')}\n`)
42
46
  } catch { /* el registro es evidencia, no una puerta */ }
@@ -355,14 +355,22 @@ function governance(input) {
355
355
  function run(program, args, cwd, extra = {}) {
356
356
  const env = { ...process.env, ...extra }
357
357
  delete env.NODE_TEST_CONTEXT
358
+ const started = Date.now()
358
359
  const result = spawnSync(program, args, { cwd, encoding: 'utf8', stdio: 'pipe', env })
359
360
  return {
360
361
  ok: result.status === 0,
361
362
  status: result.status,
363
+ ms: Date.now() - started,
362
364
  output: `${result.stdout || ''}${result.stderr || ''}`.trim(),
363
365
  }
364
366
  }
365
367
 
368
+ // Salidas de build y cachés que cualquier gate rehace solo. Se comparan contra el nombre entero de la
369
+ // entrada para que valga también anidado —`packages/app/dist`—, y con el separador de `git status`, que
370
+ // siempre usa `/`.
371
+ const RECREABLE = new RegExp('(^|/)(?:dist|build|out|coverage|__pycache__'
372
+ + '|\\.next|\\.nuxt|\\.svelte-kit|\\.turbo|\\.output|\\.parcel-cache|\\.pytest_cache)$')
373
+
366
374
  // Dónde tiene que correr un gate: sobre lo que el commit va a grabar, que es el índice y no el árbol.
367
375
  // El árbol se le parece casi siempre y por eso el error no se veía — puede tener encima otra versión de
368
376
  // un archivo staged, y puede tener uno sin trackear que el commit no lleva, que es el olvido de
@@ -399,6 +407,19 @@ function commitTree(dir) {
399
407
  for (const line of lines) {
400
408
  if (!line.startsWith('!! ')) continue
401
409
  const name = line.slice(3).trim().replace(/\/$/, '')
410
+ // Lo que el gate puede fabricar no se le enlaza: lo construye adentro de la copia y se descarta con
411
+ // ella. Enlazarlo hacía dos daños a la vez. Uno es del usuario: el gate corre sobre el índice, así
412
+ // que le dejaba la salida de build con la versión **staged** mientras su fuente en disco tenía otra,
413
+ // y nada lo decía —medido con un `dist/` que pasó de «lo-que-estoy-editando» a «staged» (caso 069)—.
414
+ // El otro es del propio gate: construía sobre restos de la corrida anterior del usuario, así que su
415
+ // veredicto dependía de un estado que nadie declaró.
416
+ //
417
+ // La lista envejece y eso pesa menos de lo que parece, porque sólo se aplica a rutas que git ya
418
+ // marcó como ignoradas: un `dist/` ignorado es generado por definición. Errarle por defecto —que
419
+ // falte un nombre— deja el comportamiento de antes; errarle por exceso hace que un gate reconstruya,
420
+ // que es más lento y no incorrecto. Lo que **sí** se enlaza es lo que un gate no puede fabricar:
421
+ // `node_modules`, un `.env`, las credenciales de una herramienta.
422
+ if (RECREABLE.test(name)) continue
402
423
  const link = path.join(temp, name)
403
424
  if (fs.existsSync(link)) continue
404
425
  fs.mkdirSync(path.dirname(link), { recursive: true })
@@ -421,7 +442,27 @@ function commitTree(dir) {
421
442
  // quien commitea; un proyecto con un gate así tiene que sacar esa escritura del gate.
422
443
  const started = run('git', ['init', '--quiet'], temp)
423
444
  if (started.ok) run('git', ['add', '--all'], temp)
424
- return { root: temp, temp, env: {} }
445
+ // Un gate no sólo lee su entorno: escribe en él. Lo ignorado se enlaza al original —eso es a propósito
446
+ // y está arriba—, así que lo que el gate escriba cae en el árbol de quien commitea. Un gestor que se
447
+ // sincroniza antes de correr un script lo lleva al extremo: ve que el árbol enlazado no coincide con
448
+ // el lockfile de la copia y reinstala, lo que **empieza borrando** el `node_modules` del proyecto.
449
+ //
450
+ // Lo que se apaga es esa comprobación previa, que es el motivo por el que quiere tocar nada.
451
+ // `verify-deps-before-run` la gobierna y tiene tres valores: `install` reinstala solo —el que trae
452
+ // pnpm 11 y el que hace el daño—, `error` se niega y frena el gate cuando el lockfile de la copia
453
+ // difiere de lo instalado, que es justo lo que pasa al commitear un cambio de lockfile por partes, y
454
+ // `false` corre el script sin mirar. La copia no tiene que sincronizar nada: tiene que medir el
455
+ // código.
456
+ //
457
+ // Acá estuvo `CI: 'true'` y fue una regresión (caso 070). Resolvía el síntoma del 068 —pnpm dejaba de
458
+ // preguntar antes de purgar— desarmando la confirmación en vez de quitarle el motivo, y esa
459
+ // confirmación era lo único que protegía al `node_modules` del proyecto: sin ella la reinstalación
460
+ // avanza y borra por el enlace. Además encendía `frozen-lockfile`, que el propio pnpm anuncia al
461
+ // fallar, así que la variable armaba y desarmaba guardas distintas a la vez.
462
+ //
463
+ // La regla que queda: no se desarma la confirmación de una herramienta, se le quita el motivo de
464
+ // preguntar. Una confirmación que estorba casi siempre está cuidando algo.
465
+ return { root: temp, temp, env: { npm_config_verify_deps_before_run: 'false' } }
425
466
  }
426
467
 
427
468
  function verify(input) {
@@ -462,6 +503,41 @@ function verify(input) {
462
503
  // Cada gate deja su rastro en `ops`; para qué sirve ese registro lo dice `core/evidence.js`. Lo que se
463
504
  // decide acá es que el rojo se anota igual que el verde: un gate que falló y se commiteó con
464
505
  // aprobación es exactamente lo que alguien va a querer ver después.
506
+ // Lo que se sabe de un gate que falló, en la forma en que se va a leer. El mensaje decía sólo
507
+ // `test (exit 1)` y tiraba la salida de la herramienta: cualquier causa —una suite en rojo, un gestor
508
+ // que se negó a arrancar el script, un binario que no está— llegaba con el mismo texto. Es la misma
509
+ // forma de fallar que el caso 066 encontró en una prueba, acá en el mensaje que lee una persona.
510
+ //
511
+ // Se muestra **una** línea y acotada: la salida de un gate puede traer cualquier cosa del entorno, y lo
512
+ // que hace falta para diagnosticar es la primera línea de error, no el volcado.
513
+ const ERROR_LINE = /error|err[_!]|fail|abort|not found|cannot|no such/i
514
+ const MAX_LINE = 160
515
+ function fallo(gate, result) {
516
+ // La línea que empieza con `>` es el eco del script que npm y pnpm imprimen antes de correrlo, así
517
+ // que lleva el comando entero y no dice nada de qué falló. Descartarla es lo que hace que la primera
518
+ // coincidencia sea el error y no el comando — con el eco adentro, un script que **menciona** una
519
+ // palabra de error gana siempre.
520
+ const lines = (result.output || '').split('\n').map((one) => one.trim())
521
+ .filter((one) => one && !one.startsWith('>'))
522
+ const line = lines.find((one) => ERROR_LINE.test(one)) || lines[0] || ''
523
+ return { gate, status: result.status, ms: result.ms, line: line.slice(0, MAX_LINE) }
524
+ }
525
+
526
+ // Un gate que vuelve en menos de esto no corrió una suite. No se afirma que **no** haya corrido —un
527
+ // lint puede fallar rápido y de verdad— y por eso lo que se agrega es el número, no un veredicto: los
528
+ // tres gates del caso 068 volvieron a un segundo uno de otro contra los trece de la corrida real.
529
+ const DEMASIADO_RAPIDO = 2000
530
+ function comoSeLee(failures) {
531
+ const texto = failures
532
+ .map((one) => `${one.gate} (exit ${one.status}, ${(one.ms / 1000).toFixed(1)} s)`
533
+ + `${one.line ? `: ${one.line}` : ''}`)
534
+ .join('; ')
535
+ if (!failures.every((one) => one.ms < DEMASIADO_RAPIDO)) return texto
536
+ const cuantos = failures.length === 1 ? 'Volvió' : `Los ${failures.length} volvieron`
537
+ return `${texto}\n${cuantos} en menos de ${DEMASIADO_RAPIDO / 1000} s: eso no alcanza para correr `
538
+ + 'una suite, así que mirá si llegaron a ejecutarse antes de aprobar esto como un rojo conocido.'
539
+ }
540
+
465
541
  function verifyGates(root, dir, aprobado, env, ops) {
466
542
  const failures = []
467
543
  if (fs.existsSync(path.join(root, 'package.json'))) {
@@ -472,28 +548,28 @@ function verifyGates(root, dir, aprobado, env, ops) {
472
548
  for (const script of ['test', 'lint', 'typecheck', 'build']) {
473
549
  if (!pkg.scripts || !pkg.scripts[script]) continue
474
550
  const result = run(pm, ['run', script], root, env)
475
- EV.record(ops, script, result.status)
476
- if (!result.ok) failures.push(`${script} (exit ${result.status})`)
551
+ EV.record(ops, script, result.status, result.ms)
552
+ if (!result.ok) failures.push(fallo(script, result))
477
553
  }
478
554
  } else if (fs.existsSync(path.join(root, 'go.mod'))) {
479
555
  const makefile = path.join(root, 'Makefile')
480
556
  if (fs.existsSync(makefile) && /^ci:/m.test(fs.readFileSync(makefile, 'utf8'))) {
481
557
  const result = run('make', ['ci'], root, env)
482
- EV.record(ops, 'make ci', result.status)
483
- if (!result.ok) failures.push(`make ci (exit ${result.status})`)
558
+ EV.record(ops, 'make ci', result.status, result.ms)
559
+ if (!result.ok) failures.push(fallo('make ci', result))
484
560
  } else {
485
561
  for (const args of [['test', './...'], ['build', './...']]) {
486
562
  const result = run('go', args, root, env)
487
- EV.record(ops, `go ${args[0]}`, result.status)
488
- if (!result.ok) failures.push(`go ${args[0]} (exit ${result.status})`)
563
+ EV.record(ops, `go ${args[0]}`, result.status, result.ms)
564
+ if (!result.ok) failures.push(fallo(`go ${args[0]}`, result))
489
565
  }
490
566
  }
491
567
  } else if (fs.existsSync(path.join(root, 'pyproject.toml')) || fs.existsSync(path.join(root, 'requirements.txt'))) {
492
568
  const makefile = path.join(root, 'Makefile')
493
569
  if (fs.existsSync(makefile) && /^test:/m.test(fs.readFileSync(makefile, 'utf8'))) {
494
570
  const result = run('make', ['test'], root, env)
495
- EV.record(ops, 'make test', result.status)
496
- if (!result.ok) failures.push(`make test (exit ${result.status})`)
571
+ EV.record(ops, 'make test', result.status, result.ms)
572
+ if (!result.ok) failures.push(fallo('make test', result))
497
573
  }
498
574
  }
499
575
  if (!failures.length || aprobado) return
@@ -501,7 +577,7 @@ function verifyGates(root, dir, aprobado, env, ops) {
501
577
  // comando a mano se lee como que el guard miente, y lo que pasó es que midió lo que se va a grabar.
502
578
  const donde = root === dir ? '' : '\nCorrió sobre el índice, que es lo que el commit graba: si en tu '
503
579
  + 'directorio pasa, es que en disco tenés algo que no está staged.'
504
- block(`Verify falló en ${path.basename(dir)}: ${failures.join(', ')}. No se commitea en rojo.${donde}\n`
580
+ block(`Verify falló en ${path.basename(dir)}: ${comoSeLee(failures)}\nNo se commitea en rojo.${donde}\n`
505
581
  + AP.HOW('OPS_SKIP_VERIFY'))
506
582
  }
507
583
 
@@ -9,9 +9,26 @@ const TEST_TRACE = /^(?:n\/a\s*[—-]\s*.+|(?:A|C\d+)\s*(?:→|->)\s*\S.+)$/i
9
9
  const DECISION_TRACE = /\[(?:fuente|supuesto):\s*[^\]]+\]/i
10
10
  const COMMIT_TRACE = /^(?:n\/a\s*[—-]\s*.+|[0-9a-f]{7,40}\s+\S.*)$/i
11
11
 
12
+ // Se corta en `;` sólo cuando detrás **empieza otra traza**. Es la misma decisión que `validCommitTrace`
13
+ // toma unas líneas más abajo para el sha, y por el mismo motivo: el `;` es el separador que R8 fija y a
14
+ // la vez el signo más común de la prosa española, y este campo pide las dos cosas — el contrato pide
15
+ // `CN → prueba` y R9 pide decir cómo se vio fallar esa prueba.
16
+ //
17
+ // Con el corte a secas, una sola traza con prosa se partía en tres y el campo se rechazaba entero: el
18
+ // mensaje mandaba a revisar la traza, que era lo único que estaba bien, y la salida fácil era acortar la
19
+ // prosa hasta que pasara — o sea empobrecer justo la evidencia que R9 exige. Pasó dos veces en dos días
20
+ // en una instancia real (caso 072).
21
+ //
22
+ // Lo comparten `validTestTrace` y `testedCriteria` porque «dónde termina una traza» es una sola decisión
23
+ // y no dos. Con los datos de hoy las dos formas de partir dan el mismo resultado —un `;` que no abre
24
+ // traza no produce un fragmento que empiece por `Cn →`, así que el rastreo no cambia—, y por eso ninguna
25
+ // prueba lo distingue: comprobado con una mutación que hace partir distinto a cada uno y sobrevive. Se
26
+ // comparte igual, para que no se separen el día que una de las dos cambie.
27
+ const TRACE_SPLIT = /\s*;(?=\s*(?:(?:A|C\d+)\s*(?:→|->)|n\/a\s*[—-]))\s*/i
28
+ const splitTraces = (value) => String(value || '').split(TRACE_SPLIT).map((one) => one.trim()).filter(Boolean)
29
+
12
30
  function validTestTrace(value) {
13
- return String(value || '').split(/\s*;\s*/).filter(Boolean)
14
- .every((item) => TEST_TRACE.test(item))
31
+ return splitTraces(value).every((item) => TEST_TRACE.test(item))
15
32
  }
16
33
 
17
34
  function validDecisionTrace(value) {
@@ -34,7 +51,7 @@ function validCommitTrace(value) {
34
51
  // Los criterios que la evidencia realmente rastrea. `n/a — razón` no rastrea ninguno a propósito: es
35
52
  // la salida explícita, y como lleva su razón escrita se lee en el propio DONE sin que nadie la cruce.
36
53
  function testedCriteria(value) {
37
- return String(value || '').split(/\s*;\s*/).filter(Boolean)
54
+ return splitTraces(value)
38
55
  .map((item) => ((item.match(/^(C\d+)\s*(?:→|->)/i) || [])[1] || '').toUpperCase())
39
56
  .filter(Boolean)
40
57
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.73.0",
3
+ "version": "0.75.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",
@@ -36,6 +36,24 @@ nuevo no comprueba que desapareció lo viejo: los dos pueden convivir, y ahí el
36
36
  del cambio ocurrió. La aserción que hace falta es de ausencia —que la salida vieja ya no esté, que la
37
37
  rama vieja ya no corra—, y es la que no se escribe sola porque nadie la extraña.
38
38
 
39
+ **Y lo más caro es que una quita casi nunca se ve como una quita.** Se escribe como un agregado: una
40
+ bandera que se pone, una variable que se exporta, una condición que se añade. Lo que la delata es la
41
+ frase que la justifica — «lo agrego **para que** deje de …». Ahí el sujeto es lo que entra y el objeto es
42
+ lo que desaparece, y lo que desaparece es lo que hay que probar. Silenciar un aviso, saltear una rama,
43
+ desarmar una confirmación: los tres se escriben sumando y los tres son quitas.
44
+
45
+ Esto no admite excepción y por eso se dice acá y no en una guía: **una quita no se entrega sin su
46
+ aserción de ausencia, y esa aserción se vio en rojo devolviendo lo quitado.** Sin ese rojo no está
47
+ probado que la aserción mire lo que dice mirar — es el mismo rojo previo del párrafo de arriba, aplicado
48
+ al revés.
49
+
50
+ Y hay un caso particular que merece su renglón porque es el que más se disfraza: **una confirmación que
51
+ estorba casi siempre está cuidando algo.** Antes de callarla se establece qué protege. Si la respuesta es
52
+ «no sé», eso **es** el resultado de la medición y no un permiso para seguir: lo que corresponde es
53
+ quitarle a la herramienta el motivo de preguntar, no la pregunta. Poner `CI=true` para que un gestor de
54
+ paquetes dejara de confirmar antes de purgar borró el árbol de dependencias de un proyecto real, y el
55
+ cambio se había probado —el gate arrancaba— midiendo sólo lo que aparecía.
56
+
39
57
  Lo que se quita, además, tiene dependientes, y no se anuncian. Una invariante que deja de valer se lleva
40
58
  puesto a quien la daba por cierta: el mensaje que la afirmaba, la condición que la deducía, el comentario
41
59
  que la explicaba. Suelen vivir en otro archivo, que es donde una premisa vieja se pudre sin que nada