@ingeniomaps/cauce 0.77.0 → 0.79.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,80 @@ 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.79.0] - 2026-09-10
18
+
19
+ ### Corregido
20
+
21
+ - **La fila que registra una parada se escribe antes de parar.** Cuando ningún plan sobrevive a la
22
+ crítica, el recorrido pide que la tarea quede en `HUMAN_ACTIONS.md` con su motivo —así `context` deja
23
+ de ofrecerla y alguien la ve—. Esa escritura se lanzaba y el recorrido volvía en la línea siguiente,
24
+ así que el agente que la escribía se quedaba a mitad de camino: en una corrida real el resumen contó
25
+ diez agentes y el registro nueve. La parada se informaba bien y el disco no la tenía.
26
+
27
+ Ahora se espera. Y en las tres paradas que dejan fila —plan rechazado, tarea que no está lista,
28
+ criterio que no dice qué aserciar— si el agente no contesta, el detalle de la parada lo dice: el
29
+ motivo sigue siendo el que la causó, y se agrega que la fila hay que escribirla a mano.
30
+
31
+ **Qué cambia para vos**: relanzar después de un plan rechazado deja de repetir la planificación
32
+ entera, porque la tarea queda registrada. Si venías viendo que `context` te ofrecía otra vez la tarea
33
+ que acababa de rechazarse, era esto.
34
+
35
+ - **El guard de migraciones frena por haber viajado, no por estar en disco.** Escribir una migración son
36
+ dos pasos —crearla y completarla— y el segundo se bloqueaba: el chequeo preguntaba si el archivo
37
+ existía, que no es la pregunta. Un stub de hace dos segundos y una migración publicada hace un año
38
+ daban la misma respuesta, y **ninguna herramienta lo esquivaba** —`Write`, `Edit` y `MultiEdit` se
39
+ bloqueaban igual—, así que lo único que quedaba a la vista era apagar el guard entero, incluida la
40
+ protección contra SQL destructivo.
41
+
42
+ Ahora se le pregunta a git si el archivo está en `HEAD`. En el índice no alcanza: un archivo apenas
43
+ agregado no viajó a ninguna parte. Y sin repositorio con el que contestar —un proyecto sin git— se
44
+ conserva la conducta anterior en vez de dejar pasar, que es el lado correcto para equivocarse cuando
45
+ no se puede saber.
46
+
47
+ **Qué cambia para vos**: completar una migración recién creada deja de frenarse. El mensaje del
48
+ bloqueo pasa a decir el hecho que lo sostiene —que está en el historial, o que existe y acá no hay con
49
+ qué saberlo— y a nombrar cómo aprobar esa ruta puntual en `planning/.ops-approval`, que es lo que el
50
+ bloqueo hermano ya hacía. Si tenías `OPS_MIGRATIONS_OVERRIDE=1` puesto para poder trabajar, ya no hace
51
+ falta y conviene sacarlo: apagaba también lo que sí te cuida.
52
+
53
+ ## [0.78.0] - 2026-09-10
54
+
55
+ ### Corregido
56
+
57
+ - **Las puertas que revisan un recorrido leen los literales de regex.** Tres pruebas analizan cada
58
+ workflow como texto —que su `meta` sea un literal puro, que no llame a nada inexistente y que no use un
59
+ nombre que no declaró— y ninguna sabía qué es un regex. La barra escapada que cierra uno deja un `//`
60
+ literal, que se leía como el arranque de un comentario: a partir de ahí se perdía el resto de la línea,
61
+ en silencio. Dos recorridos del catálogo tenían cuatro líneas así.
62
+
63
+ **Qué cambia para vos**: nada en lo que recibís, y sí en lo que se puede confiar. Un recorrido propio
64
+ con un regex adentro ahora se revisa entero en vez de a medias, y un `meta` con un regex o con una
65
+ interpolación —que hasta acá pasaba siempre, porque esa regla no podía dispararse— se rechaza.
66
+ - **El nombre de un banco de pruebas no trae nada que la prueba no haya pedido.** La suite aísla lo que
67
+ cada prueba mide filtrando la salida de `check`, y esa salida empieza con la ruta del banco: cuando el
68
+ sufijo aleatorio del directorio terminó en `adr`, un error ajeno entró a una prueba de plantillas de
69
+ ADR y la puso en rojo. Falla una vez cada muchas corridas y se lee como un hipo del entorno.
70
+
71
+ **Qué cambia para vos**: nada — es la suite del toolkit, no algo que recibas. Va acá porque una puerta
72
+ que falla al azar enseña a relanzar en vez de a mirar, y eso sí llega a quien la usa.
73
+
74
+ - **`autobuild` lee `blocked` por su valor y no por su verdad.** El campo del contexto es un vocabulario
75
+ de tres valores —vacío, `awaiting-review` y `blocked-on-human`—, y el recorrido lo probaba con un `if`
76
+ a secas. Dos consecuencias, y la que más costaba no era intermitente: una cola trabada por acciones
77
+ humanas frenaba con el motivo del checkpoint de hito y mandaba a mirar `AWAITING_REVIEW.md`, un archivo
78
+ que en ese escenario no existe. Ahora cada bloqueo para con su motivo y nombra su archivo:
79
+ `AWAITING_REVIEW.md` para el checkpoint, `HUMAN_ACTIONS.md` —con las tareas trabadas— para la cola.
80
+
81
+ La otra es la que se ve al azar: el agente que transcribe el contexto a veces entrega el vacío como la
82
+ cadena de dos comillas, y eso frenaba la corrida en la fase 1 sin que hubiera nada que resolver. Medido
83
+ sobre siete corridas de una instancia real: 3 de 24 lecturas llegaron así, y una de cada siete cayó en
84
+ la única lectura que consulta el campo. Las formas equivocadas del vacío se desenvuelven antes de leer.
85
+
86
+ Y lo que no está en el vocabulario ya no se adivina en ninguna de las dos direcciones: no se sigue como
87
+ si no hubiera bloqueo ni se inventa cuál es — para diciendo que el contexto llegó fuera del
88
+ vocabulario. El esquema declara los tres valores, igual que `lane`; eso documenta el contrato, y lo que
89
+ sostiene el arreglo es la lectura.
90
+
17
91
  ## [0.77.0] - 2026-09-10
18
92
 
19
93
  ### Corregido
@@ -38,6 +38,11 @@ const GATE = `${P}/AWAITING_REVIEW.md`
38
38
  const ROADMAP = `${P}/roadmap`
39
39
 
40
40
  // Estado de planning tal como lo emite `ops context --json`; ningún modelo parsea BACKLOG ni WIP.
41
+ // De a pares, y sin regex: una comilla dentro de un literal de regex desincroniza a las dos puertas que
42
+ // leen este archivo sin parsearlo —caso 084—, y de a pares es además lo correcto, porque una comilla
43
+ // suelta no es un envoltorio. Por eso también la escapada en vez de alternar el estilo de comillas.
44
+ const QUOTES = ['\'', '"']
45
+
41
46
  const CONTEXT = {
42
47
  type: 'object', additionalProperties: false,
43
48
  required: ['blocked', 'hasTask', 'wipActive', 'queued', 'cast', 'readOk'],
@@ -46,7 +51,13 @@ const CONTEXT = {
46
51
  // exactamente lo que un modelo completa cuando no tiene qué poner. Sin este campo esa invención se
47
52
  // lee igual que una cola terminada, y Pick la toma como permiso para promover.
48
53
  readOk: { type: 'boolean' },
49
- blocked: { type: 'string' }, hasTask: { type: 'boolean' }, wipActive: { type: 'boolean' },
54
+ // Vocabulario cerrado, igual que `lane` acá abajo, y por la misma razón: el motor emite tres valores
55
+ // y nada más —`ops context` los decide con un `existsSync` y un conteo—, así que dejarlo como texto
56
+ // libre le pedía a quien lo transcribe que acertara una convención invisible. Un modelo que rellena
57
+ // «el valor vacío» puede escribir la cadena vacía o **escribir las comillas**, y las dos satisfacían
58
+ // el esquema: medido en una instancia real, 3 de 24 lecturas llegaron como `"\"\""` (caso 083).
59
+ blocked: { type: 'string', enum: ['', 'awaiting-review', 'blocked-on-human'] },
60
+ hasTask: { type: 'boolean' }, wipActive: { type: 'boolean' },
50
61
  queued: { type: 'integer' }, slug: { type: 'string' }, hito: { type: 'string' },
51
62
  service: { type: 'string' }, acceptance: { type: 'string' }, epic: { type: 'string' },
52
63
  // Sin declararlo acá no llega: `additionalProperties: false` lo descartaría, y el aplanado de la
@@ -344,6 +355,14 @@ const read = (prompt, options = {}) => agent(`${BASE}\n\n${prompt}`, options)
344
355
  const run = (prompt, options = {}) => agent(`${SCOPE}\n\n${prompt}`, options)
345
356
  const write = (prompt, options = {}) => agent(`${LEDGER}\n\n${prompt}`, options)
346
357
 
358
+ // Las tres paradas que dejan una fila en HUMAN_ACTIONS delegan esa escritura a un agente, y esa fila es
359
+ // el único rastro de la parada: sin ella el recorrido informa un estado que el disco no tiene. Por eso
360
+ // se espera —lanzarla y volver en la línea siguiente la abandona— y por eso se mira si contestó.
361
+ // Devuelve lo que hay que agregarle al detalle, vacío cuando la fila quedó pedida. Caso 087.
362
+ const registerHuman = async (prompt, label) => (await write(prompt, { label })
363
+ ? ''
364
+ : ` — la fila en ${HUMAN} no se pudo registrar: escribila a mano`)
365
+
347
366
  // Gate, mutex de WIP y selección de tarea salen de un comando determinista: AWAITING_REVIEW, BACKLOG,
348
367
  // WIP y HUMAN_ACTIONS nunca entran al contexto de un modelo, y su tamaño deja de costar tokens.
349
368
  const readContext = () => read(
@@ -364,7 +383,28 @@ if (!planning) return stop('context-unavailable', `no se pudo leer el estado de
364
383
  // Que el agente conteste no significa que haya leído: el schema se completa igual con ceros. Parar acá
365
384
  // cuesta una corrida; seguir sobre una lectura fallida escribe en el BACKLOG, y eso no se revierte solo.
366
385
  if (!planning.readOk) return stop('context-unavailable', `${P} no se pudo leer; revisá la ruta y el cwd`)
367
- if (planning.blocked) return stop('awaiting-human-review', `${GATE} tiene un checkpoint humano sin resolver`)
386
+ // `blocked` se lee por su valor y no por su verdad. Como verdad, **cualquier** cadena no vacía frenaba la
387
+ // corrida con el mismo motivo, y eso mentía dos veces: con `blocked-on-human` —la cola trabada por
388
+ // acciones humanas, que es otra cosa— mandaba a mirar un gate que no existe, y eso no era intermitente;
389
+ // y con la cadena `""` que a veces llega del transcriptor, frenaba sin que hubiera nada que resolver.
390
+ //
391
+ // El vacío se normaliza antes porque sus formas equivocadas no son ambiguas: dos comillas o unos espacios
392
+ // no son ningún bloqueo legítimo. Lo que no se reconoce **no se adivina**: para diciendo que el contexto
393
+ // llegó fuera del vocabulario, que es lo que esta familia de casos —056, 074, 075— pide para lo que no se
394
+ // pudo determinar.
395
+ let blocker = String(planning.blocked || '').trim()
396
+ while (blocker.length > 1 && QUOTES.includes(blocker[0]) && blocker[blocker.length - 1] === blocker[0]) {
397
+ blocker = blocker.slice(1, -1).trim()
398
+ }
399
+ if (blocker === 'awaiting-review') {
400
+ return stop('awaiting-human-review', `${GATE} tiene un checkpoint humano sin resolver`)
401
+ }
402
+ if (blocker === 'blocked-on-human') {
403
+ return stop('blocked-on-human', `toda la cola espera una acción humana. Está en ${HUMAN}`
404
+ + `${(planning.blockedTasks || []).length ? `, sobre ${planning.blockedTasks.join(', ')}` : ''}`)
405
+ }
406
+ if (blocker) return stop('context-unavailable', `${P} contestó blocked=${JSON.stringify(planning.blocked)}, `
407
+ + 'que no es del vocabulario. No se sabe si hay bloqueo, así que no se sigue como si no lo hubiera.')
368
408
 
369
409
  let currentMilestone = planning.wipActive ? planning.hito : ''
370
410
  const completed = []
@@ -520,12 +560,13 @@ while (rounds++ < MAX_TASKS) {
520
560
  //
521
561
  // Lo que la fila le pide a una persona lo dice R17: dos rechazos sobre lo mismo son el disparador
522
562
  // posterior de división. No se parte acá porque partir es una decisión, y ésa no le toca al recorrido.
523
- const planRejected = (reason, unit, found) => {
563
+ const planRejected = async (reason, unit, found) => {
524
564
  const detail = found.join('; ') || 'sin condiciones nombradas'
525
- write(`Registrá ${unit.id} en ${HUMAN}: nadie pudo escribir un plan que sobreviva a la crítica. `
565
+ const nota = await registerHuman(
566
+ `Registrá ${unit.id} en ${HUMAN}: nadie pudo escribir un plan que sobreviva a la crítica. `
526
567
  + `Motivo: ${detail}. La acción humana es revisar si la unidad son dos resultados con vidas `
527
- + `distintas y partirla —R17—, o dejarla entera con la razón escrita.`, { label: 'plan-human' })
528
- return stop(reason, detail)
568
+ + `distintas y partirla —R17—, o dejarla entera con la razón escrita.`, 'plan-human')
569
+ return stop(reason, `${detail}${nota}`)
529
570
  }
530
571
 
531
572
  if (!planning.wipActive) {
@@ -539,9 +580,10 @@ while (rounds++ < MAX_TASKS) {
539
580
  )
540
581
  if (!ready) return stop('agent-unavailable', 'Ready no devolvió resultado')
541
582
  if (!ready.ready) {
542
- await write(`Registrá ${task.id} en ${HUMAN} con el motivo y una acción humana exacta: ${ready.reason}.`,
543
- { label: 'ready-human' })
544
- return stop('not-ready', ready.reason)
583
+ const nota = await registerHuman(
584
+ `Registrá ${task.id} en ${HUMAN} con el motivo y una acción humana exacta: ${ready.reason}.`,
585
+ 'ready-human')
586
+ return stop('not-ready', `${ready.reason}${nota}`)
545
587
  }
546
588
  if (ready.refinedAcceptance) task.acceptance = ready.refinedAcceptance
547
589
  }
@@ -761,9 +803,10 @@ while (rounds++ < MAX_TASKS) {
761
803
  // hacer parar a una persona por eso le cobra una interrupción por algo que se resolvía solo.
762
804
  const ambiguous = verified.uncovered.find((entry) => entry.cause === 'ambiguous')
763
805
  if (ambiguous) {
764
- await write(`Registrá ${task.id} en ${HUMAN}: el criterio "${ambiguous.criterion}" no dice qué habría ` +
765
- `que aserciar, y hace falta la decisión que lo fija.`, { label: 'verify-human' })
766
- return stop('acceptance-ambiguous', ambiguous.criterion)
806
+ const nota = await registerHuman(
807
+ `Registrá ${task.id} en ${HUMAN}: el criterio "${ambiguous.criterion}" no dice qué habría ` +
808
+ `que aserciar, y hace falta la decisión que lo fija.`, 'verify-human')
809
+ return stop('acceptance-ambiguous', `${ambiguous.criterion}${nota}`)
767
810
  }
768
811
  if (verified.uncovered.length) {
769
812
  await run(`${asRole(cast.build)}Escribí sólo las pruebas que faltan en ${task.id}, con el mismo rojo ` +
@@ -6,6 +6,7 @@
6
6
 
7
7
  const fs = require('node:fs')
8
8
  const path = require('node:path')
9
+ const { spawnSync } = require('node:child_process')
9
10
  const {
10
11
  patchOf, filesOf, contentOf, cwdOf, block, configOf, findOpsRoot,
11
12
  writableRoots, outsideRoots, DECLARE_IT,
@@ -26,6 +27,29 @@ function opsRoot(input) {
26
27
  // el guard hasta que cierre la sesión.
27
28
  const approved = (input, file) => !AP.pending(opsRoot(input), [file]).length
28
29
 
30
+ // Si la migración ya viajó a otra copia, que es lo que el bloqueo de abajo quiere saber y `existsSync`
31
+ // no contesta. Devuelve el motivo del bloqueo o cadena vacía.
32
+ //
33
+ // **`HEAD` y no el índice**: un archivo apenas `git add`eado no viajó a ninguna parte, y `git ls-files`
34
+ // lo daría por historial. Y **resolver la raíz es una pregunta aparte** de si el archivo está en `HEAD`:
35
+ // las dos fallan con 128 y confundirlas repite el error que este caso arregla —decidir por la respuesta
36
+ // equivocada—. Sin raíz resoluble se degrada a la conducta de antes, que bloquea de más, porque cuando
37
+ // no se puede saber ése es el lado correcto para equivocarse. Es la degradación que `check` ya declara
38
+ // cuando no puede resolver el repositorio de un servicio. Caso 086.
39
+ function alreadyShipped(file) {
40
+ if (!fs.existsSync(file)) return ''
41
+ const cwd = path.dirname(file)
42
+ const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' })
43
+ if (top.status !== 0) {
44
+ return 'existe, y acá no hay repositorio con el que saber si ya viajó a otra copia'
45
+ }
46
+ const rel = path.relative(top.stdout.trim(), file).split(path.sep).join('/')
47
+ return spawnSync('git', ['cat-file', '-e', `HEAD:${rel}`], { cwd }).status === 0
48
+ ? 'ya está en el historial del repositorio'
49
+ : ''
50
+ }
51
+
52
+
29
53
  function secrets(input) {
30
54
  for (const file of filesOf(input)) {
31
55
  const base = path.basename(file)
@@ -232,9 +256,15 @@ function migrations(input) {
232
256
  if (destructiveSql.test(contentOf(input))) {
233
257
  block(`${raw} contiene SQL destructivo.\n${AP.HOW('OPS_MIGRATIONS_OVERRIDE')}`)
234
258
  }
259
+ // El mensaje nombra el hecho que sostiene el bloqueo y no su interpretación: «historial» era una
260
+ // lectura que `existsSync` no podía dar, y se la daba igual sobre stubs de la misma sesión. Y lleva
261
+ // la salida angosta, que hasta 0.79.0 sólo tenía el bloqueo hermano: éste es el que aparece en el
262
+ // flujo normal de escribir una migración, así que era justo el que no podía quedarse sin decirla.
235
263
  const file = path.resolve(cwdOf(input), raw)
236
- if (fs.existsSync(file)) {
237
- block(`${raw} es una migración existente. Crea una nueva en vez de reescribir historial.`)
264
+ const shipped = alreadyShipped(file)
265
+ if (shipped) {
266
+ block(`${raw} ${shipped}. Crea una nueva en vez de reescribirla.\n`
267
+ + AP.HOW('OPS_MIGRATIONS_OVERRIDE'))
238
268
  }
239
269
  }
240
270
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.77.0",
3
+ "version": "0.79.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",