@ingeniomaps/cauce 0.95.0 → 0.97.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,156 @@ 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.97.0] - 2026-09-17
18
+
19
+ ### Agregado
20
+
21
+ - **La revisión de `autobuild` tiene dónde poner una decisión que no le toca tomar.** Tenía dos puertas:
22
+ marcar el hallazgo como bloqueante —y mandar a tocar código— o dejarlo en el INBOX como propuesta, con
23
+ tope de tres. Un contrato público que alguien tiene que definir no es ninguna de las dos, así que el
24
+ revisor elegía frenar, que de las dos es la correcta y costaba la corrida entera sobre trabajo que
25
+ estaba bien.
26
+
27
+ Ahora ese hallazgo va a `HUMAN_ACTIONS.md` con qué lo cierra y quién puede tomarlo, nombrando la épica o
28
+ el hito al que alcanza —nunca la tarea, para no bloquear a la que lo encontró—, y la corrida sigue. La
29
+ entrada de DONE cuenta cuántas quedaron registradas.
30
+
31
+ ### Corregido
32
+
33
+ - **Una parada que espera una decisión tuya suelta la tarea que había reservado.** Al registrar la fila
34
+ en `HUMAN_ACTIONS.md`, la tarea queda bloqueada; si además seguía reservada, el runner quedaba ocupado
35
+ por algo que nadie podía tomar y la corrida siguiente moría reclamando la que sigue —«este runner ya
36
+ tiene …»—. Pasaba en toda parada anterior a Build, que son las más frecuentes mientras una tarea
37
+ todavía se está definiendo, y costaba una corrida entera por vez.
38
+
39
+ Las paradas de después de construir **no** sueltan nada: ahí el WIP existe y es resumible, y la reserva
40
+ es lo único que dice de quién es ese trabajo.
41
+
42
+ - **Lo que la línea de una tarea ya decidió llega a las fases que deciden.** La descripción —dónde vive un
43
+ símbolo, qué queda fuera de alcance, con qué se produce la evidencia— se leía del BACKLOG y se
44
+ descartaba, así que Plan la volvía a decidir por su cuenta y decidía distinto. Critique **sí** abre el
45
+ BACKLOG, y bloqueaba el plan citando la línea palabra por palabra: una compuerta juzgando contra un
46
+ texto que la otra no recibió. Dos corridas medidas se pagaron enteras por esto, 1,10 M y 815 k tokens,
47
+ sin escribir una línea.
48
+
49
+ Ahora `ops context` la emite con la tarea y llega a Ready, Plan, Critique y Build, dicha como lo que es:
50
+ decisiones ya tomadas, que no se re-deciden. **No tenés que cambiar nada en tus líneas** — es el texto
51
+ que ya escribías antes de la aceptación.
52
+
53
+ - **`git commit --amend` se frena sobre historia publicada, y ya no sobre la que nadie vio.** Lo que R8
54
+ protege es la historia que otro leyó —lo mismo que ya decía del force-push—, y el guard lo bloqueaba
55
+ siempre «por política y no por daño». Eso no impedía el resultado: `git reset --soft HEAD~1` y volver a
56
+ commitear produce exactamente lo mismo, no lo frena nada y no deja constancia. Sólo impedía el comando
57
+ que lo nombra.
58
+
59
+ Ahora el guard mira si algún remoto alcanza al commit, con una lectura local que no habla con ningún
60
+ servidor. Publicado sigue cerrado y sin salida por chat, igual que el force-push. **R8 también cambió**:
61
+ pasa a prohibir reescribir historia que otro ya leyó, en vez de nombrar el comando.
62
+
63
+ - **Una etapa de un recorrido ya no muere por escribir de más.** Pasado cierto tamaño el modelo deja de
64
+ emitir los campos y devuelve el JSON envuelto como texto, que no valida; cinco reintentos después la
65
+ etapa se cae y la corrida entera se para. Cada campo de la respuesta lleva ahora su tope declarado
66
+ —`summary` 1000 caracteres, `missing` y `humanAction` 500, cada evidencia 200—, así que el rechazo
67
+ nombra el campo y el número en vez de decir que no se pudo parsear.
68
+
69
+ Lo que no entra no se pierde: va al archivo de análisis, que es lo que lee quien sintetiza al final.
70
+ Si escribís un recorrido propio, no tenés que hacer nada — el tope es del esquema, no del contrato.
71
+
72
+ - **`autobuild` ya no frena una tarea correcta porque el nombre del test venga anotado de dos formas.**
73
+ La puerta comprueba que un borde que el build arregló aparezca en algún rojo declarado, comparando los
74
+ dos nombres por contención. Eso cubría que uno fuera prefijo del otro, y dejaba afuera la forma que
75
+ aparece de verdad: los dos nombran el mismo test y cada uno le agrega **su propia** anotación entre
76
+ paréntesis —dónde está la línea de un lado, por qué se vio en rojo del otro—. Ahí ninguno contenía al
77
+ otro y la corrida paraba con `edge-unproven` sobre trabajo terminado y en verde.
78
+
79
+ Si te pasó, la tarea estaba bien: no hacía falta rehacerla. Y el motivo de la parada ya no se contradice
80
+ solo — dice qué comparó la puerta en vez de afirmar que faltaba la prueba.
81
+
82
+ - **Una corrección de Review se lleva también lo que ella misma dejó desactualizado.** «Corregí sólo estos
83
+ hallazgos» acota el alcance y dejaba un cabo suelto: el conteo que enumeraba lo que cambió, el
84
+ comentario que describía la forma vieja, la fila que la afirmaba. Como Review tiene una sola vuelta de
85
+ corrección, esa deriva aterrizaba en la re-revisión y la corrida frenaba sobre trabajo correcto.
86
+
87
+ - **El checkpoint de hito se destraba escribiendo, no borrando.** `AWAITING_REVIEW.md` frenaba a todos los
88
+ runners por el hecho de existir, así que resolverlo exigía acordarse de borrarlo — y mientras tanto
89
+ quien lo leía podía ver ahí escrito que ya estaba revisado. Es lo que R28 prohíbe, y el propio archivo
90
+ lo decía de sí mismo.
91
+
92
+ Ahora lleva `status: pendiente` en su frontmatter y se destraba cambiándolo a `resuelta`, con lo cual el
93
+ archivo **se queda** y se puede leer después qué se revisó. Borrarlo sigue funcionando.
94
+
95
+ **Tu archivo actual no cambia de conducta**: sin `status` sigue frenando, que es lo que corresponde
96
+ —abrirlo al actualizar sería destrabar una compuerta en silencio—. Agregale la línea cuando quieras
97
+ resolverlo sin perderlo; los que escriba `autobuild` de acá en más ya vienen con ella.
98
+
99
+ - **`check` rechaza una fila de `HUMAN_ACTIONS.md` que nombra a su tarea sin ser su slug.** El motor
100
+ bloquea por esa primera columna **exacta**, así que `| **alta-de-cliente: falta el token** | pendiente |`
101
+ se escribía sin error, salía en `ops context` bajo `HUMAN` como si estuviera registrada, y la tarea se
102
+ seguía ofreciendo. Es la forma que sale natural, porque esa tabla la lee una persona; en la instancia
103
+ donde se midió, **once filas pendientes bloqueaban cero tareas** y la corrida siguiente volvía a elegir
104
+ lo mismo y a pagar la misma fase.
105
+
106
+ Si te aparece, el mensaje dice qué fila y a qué tarea apuntaba: dejá el slug solo en esa celda y contá
107
+ el resto en la columna de la acción. Las otras dos formas no cambian — el slug exacto sigue bloqueando,
108
+ y nombrar la épica, el hito o el recorrido sigue siendo válido cuando lo que se frena no es una tarea.
109
+
110
+ - **Una decisión que `autobuild` deja abierta ya no bloquea la tarea que la encontró.** Esas filas se
111
+ registran para que la corrida siga —lo que de verdad frena tiene otro camino—, pero la fase que las
112
+ escribe podía poner en la primera columna el slug de la tarea en construcción y bloquearla. No se veía
113
+ mientras la corrida vivía, porque un WIP activo manda sobre la acción humana; aparecía al cerrar el WIP.
114
+
115
+ - **Una corrida de `autobuild` que no puede tomar nada lo dice, en vez de terminar como si la cola
116
+ estuviera vacía.** Eran dos estados distintos con la misma salida: la cola terminada, y la cola cuyas
117
+ tareas están todas reclamadas por otro runner o esperando una dependencia. La corrida informaba que no
118
+ había nada que hacer sobre una cola que sí tenía trabajo.
119
+
120
+ Se paga después de una parada que espera una decisión tuya: ahí el reclamo se queda puesto a propósito
121
+ —quien paró va a volver y su `claim` es idempotente—, así que cuando contestás, **otro** runner pregunta
122
+ y se va sin nada. Ahora para con `queue-unavailable` y dice cuántas hay; la línea `TAKEN` de
123
+ `ops context` nombra quién tiene cada una, y se sueltan con `ops release`.
124
+
125
+ ## [0.96.0] - 2026-09-16
126
+
127
+ ### Corregido
128
+
129
+ - **Tres errores que el CLI contestaba con exit 1 ahora contestan 2, que es lo que dice la convención.**
130
+ Son «no se llegó a la pregunta»: `integrations/config.json` ilegible, un proveedor que no está en ese
131
+ registro, y un `package.json` inválido al declarar el motor. Sus hermanos —un `ops.config.json`
132
+ ilegible, un proveedor que Cauce no trae— ya contestaban 2.
133
+
134
+ El corte, que hasta ahora estaba en la cabeza de quien escribía cada salida, está escrito y sale en
135
+ `ops --help`: **2** es que el comando no existe, falta un argumento, o la raíz que nombrás no es lo que
136
+ dice ser —lo arreglás cambiando la invocación—; **1** es que se llegó y la respuesta es que no —una
137
+ validación encontró problemas, el estado se niega, o una operación falló a mitad de camino—.
138
+
139
+ Si tenés un script que distingue los dos, revisá esos tres casos. Si sólo mira «distinto de cero», no
140
+ cambia nada.
141
+
142
+ - **`ops flow list|check|show` acepta la raíz de la instancia, como el resto.** Todos los comandos que leen
143
+ una instancia la toman como posicional; `flow` la descartaba y resolvía por el directorio actual, así que
144
+ `ops flow list <raíz>` contestaba sobre otra cosa — y una lista de recorridos se lee igual de bien venga
145
+ de donde venga. Ahora es `ops flow list [ops-root]`, y con recorrido va después de él.
146
+
147
+ - **Una lista de cargos o recorridos vacía dice si es porque no hay o porque no se pudo resolver el
148
+ paquete.** Eran dos hechos distintos con la misma respuesta, y las acciones son opuestas. El aviso sale
149
+ por `stderr` y sólo cuando la lista viene vacía; el código de salida y el `--json` no cambian, porque
150
+ ese JSON lo consume el cron del ciclo de aprendizaje.
151
+
152
+ - **`automation check` nombra un `ops.config.json` ilegible en vez de culpar al motor.** La resolución del
153
+ paquete cuelga de esa configuración, así que un JSON roto se propagaba como archivos del motor que faltan
154
+ y el aviso mandaba a correr `npm install` sobre un motor instalado: la acción sugerida no arreglaba nada.
155
+
156
+ - **El catálogo de cargos y recorridos se encuentra también cuando el paquete vive un nivel arriba.** Es el
157
+ layout que documenta el arranque —`npm install @ingeniomaps/cauce` en la carpeta de la empresa y la
158
+ instancia adentro—, y 0.92.0 lo arregló para el motor y dejó afuera el catálogo. En ese estado `check`
159
+ pasaba en verde y `agents list` contestaba **una lista vacía con exit 0**, indistinguible de «esta
160
+ instancia no tiene cargos»; `flow list` igual, y `evaluate` culpaba a un `SKILL.md` que falta, mandando
161
+ a mirar el cargo en vez del paquete.
162
+
163
+ Si te pasó, no hacía falta bajar una segunda copia: con esta versión los cargos aparecen donde ya estaban.
164
+ Verificado instalando el paquete publicado desde el registro y recorriendo la superficie del CLI: de 0
165
+ cargos y 0 recorridos a **53 y 7**, sobre la misma instancia.
166
+
17
167
  ## [0.95.0] - 2026-09-16
18
168
 
19
169
  ### Corregido
@@ -60,6 +60,9 @@ const CONTEXT = {
60
60
  hasTask: { type: 'boolean' }, wipActive: { type: 'boolean' },
61
61
  queued: { type: 'integer' }, slug: { type: 'string' }, hito: { type: 'string' },
62
62
  service: { type: 'string' }, acceptance: { type: 'string' }, epic: { type: 'string' },
63
+ // Sin declararlo acá no llega, igual que le pasó a `epicContext`: `additionalProperties: false` lo
64
+ // descarta y las fases lo reciben vacío para siempre.
65
+ description: { type: 'string' },
63
66
  // Sin declararlo acá no llega: `additionalProperties: false` lo descartaría, y el aplanado de la
64
67
  // épica a su número —dos líneas arriba— hace fácil creer que ya viene. La fase Plan lo pedía en su
65
68
  // prompt y planificaba contra el título; `readEpics` cuenta de dónde sale.
@@ -153,8 +156,29 @@ const DECISION = {
153
156
  }
154
157
  // Review nombra contra qué reglas revisó (caso 105): recibir las rutas no garantiza abrirlas, y esto es lo único
155
158
  // que deja rastro de que se hizo. Critique no lo lleva porque no recibe la lista.
159
+ //
160
+ // Y `decision` es el tercer destino, el que R6 le da a lo que aparece y no le toca a este cargo: queda
161
+ // registrado con qué lo cierra y quién puede tomarlo. Las otras fases lo tienen —`ready-human`,
162
+ // `plan-human`, `verify-human`, y `open-decisions` en Build— y Review no, así que un revisor que
163
+ // encontraba una decisión elegía entre frenar la corrida o degradarla a propuesta con tope de tres.
164
+ // Elegía frenar, que de las dos es la correcta, y costaba la corrida entera sobre trabajo que estaba
165
+ // bien. Es la misma separación que `uncovered` hace en Verify, en el otro extremo del recorrido.
166
+ //
167
+ // Medido sobre un banco desechable el 2026-09-17, corridas `wf_99130468-2c4` y `wf_5dcc3828-a9f`: las dos
168
+ // terminaron en `review-failed` con la suite del producto en verde y los tres casos de la aceptación
169
+ // dando lo pedido, y las dos por un hallazgo que el propio revisor describió como una decisión que no le
170
+ // tocaba.
156
171
  const REVIEWED = { ...DECISION, required: [...DECISION.required, 'rules'],
157
- properties: { ...DECISION.properties, rules: { type: 'array', items: { type: 'string' } } } }
172
+ properties: {
173
+ ...DECISION.properties,
174
+ rules: { type: 'array', items: { type: 'string' } },
175
+ // Extiende el concern de `DECISION` en vez de reescribirlo: copiado entero, un campo nuevo allá no
176
+ // llegaría acá y ninguna prueba lo notaría.
177
+ concerns: { ...DECISION.properties.concerns,
178
+ items: { ...DECISION.properties.concerns.items,
179
+ properties: { ...DECISION.properties.concerns.items.properties, decision: { type: 'boolean' } },
180
+ } },
181
+ } }
158
182
  // Un exit code dice que el test corrió, no que pruebe lo que la tarea prometió: un test que asercia de
159
183
  // menos —o que ni existe— sale verde igual, y el guard de verify tampoco lo ve porque también mira exit
160
184
  // codes. Por eso `uncovered` se contrasta contra la aceptación leyendo el fuente, no la salida (R9).
@@ -301,9 +325,19 @@ const VERDICT = ' Cerrá con verdict=aprobado si no queda nada por corregir ante
301
325
  'alcance—. Marcá blocking=true sólo en el hallazgo que impide entregar: el resto queda registrado y no ' +
302
326
  'manda a tocar código.'
303
327
  // Acompaña a todo prompt con schema REVIEWED.
304
- const RULED = ' En rules nombrá, por su ruta, cada una de las reglas que rigen contra la que revisaste el diff.'
328
+ const RULED = ' En rules nombrá, por su ruta, cada una de las reglas que rigen contra la que revisaste'
329
+ + ' el diff. Y marcá decision=true en el hallazgo que no te toca resolver a vos —una definición de'
330
+ + ' producto, un contrato público, una autoridad que el cargo no tiene—: ése se registra para una'
331
+ + ' persona y no manda a tocar código.'
305
332
  // Lo que hay que corregir antes de entregar. El resto de los hallazgos no desaparece: se registra.
306
- const blockers = (verdict) => verdict.concerns.filter((one) => one.blocking).map((one) => one.detail)
333
+ // Una decisión no cuenta como bloqueante aunque venga marcada: su destino es la fila, no la corrección.
334
+ // Critique no la emite —no está en su esquema— así que para él `one.decision` es siempre `undefined`.
335
+ const blockers = (verdict) => verdict.concerns
336
+ .filter((one) => one.blocking && !one.decision).map((one) => one.detail)
337
+ // Lo que una parada tiene que nombrar es todo lo que el revisor señaló, no sólo lo que manda a corregir:
338
+ // con `blockers` solo, un veredicto cuyo único hallazgo es una decisión paraba diciendo que nadie nombró
339
+ // ninguna condición. El motivo es lo único que queda para leer cuando la corrida terminó.
340
+ const named = (verdict) => verdict.concerns.filter((one) => one.blocking).map((one) => one.detail)
307
341
  // El resultado de Build cuando no hubo nada que construir en esta corrida. Devuelve lo que de verdad
308
342
  // pasó y nada más: `redFirst` y `discovered` van **vacíos** porque acá no hubo ningún rojo nuevo que
309
343
  // mostrar ni ningún borde nuevo que fijar, y rellenarlos para que se parezca a una construcción sería
@@ -423,7 +457,8 @@ const readContext = () => read(
423
457
  `omitilo entero si wip es null, sin inventar ceros—, y lane ` +
424
458
  `de task.tier; copiá slug, ` +
425
459
  `hito, service, acceptance, ` +
426
- `epic y cast de task, epicContext de epic.context —vacío si no hay épica— e inbox tal cual. El comando es ` +
460
+ `epic, cast y description de task, epicContext de epic.context —vacío si no hay épica— e inbox tal `
461
+ + `cual. El comando es ` +
427
462
  `la fuente de ` +
428
463
  `verdad: no abras archivos de planning para completarlo. Poné readOk en true sólo si el comando salió ` +
429
464
  `con código 0 y devolvió JSON; si falló, readOk en false y el resto en sus valores vacíos, sin ` +
@@ -485,10 +520,31 @@ while (rounds++ < MAX_TASKS) {
485
520
  //
486
521
  // `context` nombra la que sigue, igual que nombra una recurrencia vencida y por el mismo motivo: la
487
522
  // máquina calcula y la persona encola.
523
+ // Una cola con trabajo y sin tarea disponible no es una cola terminada. Romper el bucle igual deja la
524
+ // corrida informando que no había nada que hacer sobre una cola que sí tiene tareas —reclamadas por otro
525
+ // runner, o esperando una dependencia—, que es la forma del caso 075: una respuesta vacía que se lee
526
+ // como un hecho del dominio. El dato ya venía: `queued` cuenta la cola entera, y `context` además la
527
+ // nombra con su dueño en la línea TAKEN.
528
+ //
529
+ // Se paga sobre todo después de una parada que espera a una persona: ahí el reclamo se queda puesto a
530
+ // propósito —quien paró va a volver, y `claim` le es idempotente—, así que cuando la persona contesta es
531
+ // un runner distinto el que pregunta y el que se va sin nada.
532
+ if (!planning.hasTask && planning.queued > 0) {
533
+ return stop('queue-unavailable', `la cola tiene ${planning.queued} tarea(s) y ninguna disponible para `
534
+ + 'este runner. Mirá la línea TAKEN de "ops context": lo reclamado por otro se suelta con '
535
+ + '"ops release" o se retoma desde el runner que lo tiene; lo que espera una dependencia, no.')
536
+ }
488
537
  if (!planning.hasTask || (currentMilestone && planning.hito !== currentMilestone)) break
538
+ // Las decisiones que la línea ya tomó, dichas como lo que son. Sin ese rótulo se leen como contexto
539
+ // opinable y el que planifica las re-decide igual, que es el defecto entero: la crítica abre el
540
+ // BACKLOG por su cuenta y bloquea el plan citando la línea palabra por palabra (caso 177).
541
+ const DECIDED = () => (task.description
542
+ ? ` Lo que la línea de la tarea ya decidió, y no se re-decide acá: ${task.description}`
543
+ : '')
489
544
  const task = {
490
545
  id: planning.slug, hito: planning.hito, service: planning.service,
491
546
  acceptance: planning.acceptance, epic: planning.epic, epicContext: planning.epicContext || '',
547
+ description: planning.description || '',
492
548
  }
493
549
  // Reservar antes de construir, y antes de fijar el hito de la corrida. Sin esto dos corridas en
494
550
  // paralelo trabajan la misma tarea: `context` sólo puede saltear lo que alguien ya reclamó, y el
@@ -614,12 +670,32 @@ while (rounds++ < MAX_TASKS) {
614
670
  //
615
671
  // Lo que la fila le pide a una persona lo dice R17: dos rechazos sobre lo mismo son el disparador
616
672
  // posterior de división. No se parte acá porque partir es una decisión, y ésa no le toca al recorrido.
673
+ // Una parada que registra la fila **bloquea** la tarea: la fila pendiente la saca de la cola. Si además
674
+ // el reclamo se queda puesto, el runner queda ocupado por algo que nadie puede tomar, y la corrida
675
+ // siguiente —que hace bien en no reintentarla— muere reclamando la que sigue: `claim` le contesta «este
676
+ // runner ya tiene …». Es la forma del 163 por la otra puerta, y la diferencia es que el estado lo creó
677
+ // esta corrida, así que soltarlo también le toca (caso 176).
678
+ //
679
+ // Sólo las paradas de **antes** de construir. Las de después —`verify-regression`, `qa-failed`,
680
+ // `commit-failed`— dejan el WIP en disco y es resumible: ahí el reclamo es lo único que dice de quién es
681
+ // ese trabajo, y soltarlo lo abandonaría.
682
+ //
683
+ // Cuesta un agente, porque el recorrido no tiene con qué correr un comando. Un agente contra una
684
+ // corrida entera.
685
+ const releaseBlocked = async () => write(
686
+ `Corré "node tools/ops.js release ${P} ${task.id}" desde ${ROOT}: quedó bloqueada por la fila que `
687
+ + 'acabás de registrar y todavía no hay nada construido, así que su reserva no reserva trabajo. No '
688
+ + 'escribas ningún archivo vos: lo escribe el comando.',
689
+ { label: `release:${task.id}` },
690
+ )
691
+
617
692
  const planRejected = async (reason, unit, found) => {
618
693
  const detail = found.join('; ') || 'sin condiciones nombradas'
619
694
  const nota = await registerHuman(
620
695
  `Registrá ${unit.id} en ${HUMAN}: nadie pudo escribir un plan que sobreviva a la crítica. `
621
696
  + `Motivo: ${detail}. La acción humana es revisar si la unidad son dos resultados con vidas `
622
697
  + `distintas y partirla, o dejarla entera con la razón escrita.`, 'plan-human')
698
+ await releaseBlocked()
623
699
  return stop(reason, `${detail}${nota}`)
624
700
  }
625
701
 
@@ -628,7 +704,8 @@ while (rounds++ < MAX_TASKS) {
628
704
  phase('Ready')
629
705
  const ready = await read(
630
706
  `${asRole(OWNERS.ready)}Revisá que ${task.id} tenga aceptación concreta, dependencias resueltas y ` +
631
- `ninguna decisión pendiente: ${task.acceptance}. Aclará la redacción y nada más; nunca amplíes el ` +
707
+ `ninguna decisión pendiente: ${task.acceptance}.${DECIDED()} Aclará la redacción y nada más; ` +
708
+ `nunca amplíes el ` +
632
709
  `alcance.`,
633
710
  { schema: READY, label: 'ready' },
634
711
  )
@@ -637,6 +714,7 @@ while (rounds++ < MAX_TASKS) {
637
714
  const nota = await registerHuman(
638
715
  `Registrá ${task.id} en ${HUMAN} con el motivo y una acción humana exacta: ${ready.reason}.`,
639
716
  'ready-human')
717
+ await releaseBlocked()
640
718
  return stop('not-ready', `${ready.reason}${nota}`)
641
719
  }
642
720
  if (ready.refinedAcceptance) task.acceptance = ready.refinedAcceptance
@@ -703,7 +781,8 @@ while (rounds++ < MAX_TASKS) {
703
781
  `vecinas y el git status de ${task.id}.` +
704
782
  `${task.epicContext ? ` Contexto de la épica: ${task.epicContext}` : ''}` +
705
783
  ` Producí el plan más chico que satisfaga ` +
706
- `${task.acceptance}. Un archivo de planning no puede ser un archivo de implementación. El plan cubre ` +
784
+ `${task.acceptance}.${DECIDED()} Un archivo de planning no puede ser un archivo de implementación. ` +
785
+ `El plan cubre ` +
707
786
  `sólo el cambio dentro de ${task.service}: correr los gates del repositorio, hacer QA, commitear y ` +
708
787
  `cerrar la tarea son fases posteriores de este recorrido, cada una con su dueño, así que no van como ` +
709
788
  `pasos.`,
@@ -714,7 +793,7 @@ while (rounds++ < MAX_TASKS) {
714
793
  phase('Critique')
715
794
  let critique = await read(
716
795
  `Atacá este plan por correctitud, alcance, seguridad, pruebas y conflictos con el código ` +
717
- `existente.${MANIFEST}${VERDICT} Plan: ${JSON.stringify(plan)}`,
796
+ `existente.${DECIDED()}${MANIFEST}${VERDICT} Plan: ${JSON.stringify(plan)}`,
718
797
  { schema: DECISION, label: 'critique' },
719
798
  )
720
799
  if (!critique) return stop('agent-unavailable', 'Critique no devolvió resultado')
@@ -729,7 +808,7 @@ while (rounds++ < MAX_TASKS) {
729
808
  { schema: PLAN, label: 'replan' },
730
809
  )
731
810
  critique = await read(
732
- `Volvé a criticar el plan corregido contra ${task.acceptance}.${MANIFEST}${VERDICT} ` +
811
+ `Volvé a criticar el plan corregido contra ${task.acceptance}.${DECIDED()}${MANIFEST}${VERDICT} ` +
733
812
  `Plan: ${JSON.stringify(plan)}`,
734
813
  { schema: DECISION, label: 'critique' },
735
814
  )
@@ -798,7 +877,7 @@ while (rounds++ < MAX_TASKS) {
798
877
  `nombrás en test y anotás en redFirst—, kind=open si lo notaste y no impide entregar la aceptación: se ` +
799
878
  `registra para que lo decida quien corresponde y el recorrido sigue. Si de verdad no podés entregar sin ` +
800
879
  `esa decisión, eso no va en discovered: es completed=false con su blocker. ` +
801
- `Aceptación: ${task.acceptance}.`,
880
+ `Aceptación: ${task.acceptance}.${DECIDED()}`,
802
881
  { schema: BUILD, label: 'build' },
803
882
  )
804
883
  if (!build) return stop('agent-unavailable', 'Build no devolvió resultado')
@@ -819,8 +898,16 @@ while (rounds++ < MAX_TASKS) {
819
898
  // tiene camino —`completed: false` con su blocker—; esto se registra y sigue.
820
899
  const openDecisions = build.discovered.filter((entry) => entry.kind === 'open')
821
900
  if (openDecisions.length) {
901
+ // Y la fila no puede nombrar a la tarea que la produjo. El motor bloquea por esa primera celda
902
+ // exacta, así que escribirla ahí registra «esto no impide entregar» y produce el bloqueo igual —lo
903
+ // contrario de lo que este registro decidió dos líneas arriba—. No se nota mientras la corrida vive,
904
+ // porque el WIP activo manda sobre la acción humana; aparece cuando el WIP cierra, y entonces la
905
+ // tarea queda frenada por una pregunta que ya se había resuelto seguir sin contestar. En la corrida
906
+ // que lo mostró la atrapó Review, tres fases después de escribirla.
822
907
  await write(`Registrá en ${HUMAN} una fila por cada decisión que ${task.id} dejó abierta, con qué la ` +
823
- `cierra y quién puede tomarla. No inventes responsables ni fechas: ` +
908
+ `cierra y quién puede tomarla. La primera columna nunca es ${task.id}: el motor bloquea por esa ` +
909
+ `celda exacta y estas decisiones no impiden entregarla. Va la épica, el hito o el recorrido al que ` +
910
+ `alcanza la decisión. No inventes responsables ni fechas: ` +
824
911
  `${JSON.stringify(openDecisions.map((entry) => entry.detail))}`, { label: 'open-decisions' })
825
912
  }
826
913
  // Y un caso que sí se fijó acá entra con su prueba o no entró: sin ella el comportamiento nuevo queda
@@ -830,11 +917,25 @@ while (rounds++ < MAX_TASKS) {
830
917
  // cadena exacta frena una tarea correcta por haber nombrado el test de dos formas —`TestAlta` acá y
831
918
  // `users_test.go::TestAlta` allá—. Alcanza con que uno nombre al otro; lo que sigue frenando, que es de
832
919
  // lo que se trata, es el caso que no aparece en ningún rojo.
833
- const namesTest = (red, item) => Boolean(item.test)
834
- && (red.test.includes(item.test) || item.test.includes(red.test))
920
+ //
921
+ // Y la contención sola no alcanza, porque el caso que aparece no es que uno esté contenido en el otro:
922
+ // los dos nombran el mismo test y cada uno le agrega **su propia** anotación entre paréntesis —dónde
923
+ // está la línea de un lado, por qué se vio en rojo del otro—. Ahí ninguno contiene al otro y la puerta
924
+ // frenaba una entrega correcta, que es lo que R26 dice que termina apagándola. Se compara sin esa
925
+ // anotación final; una que esté en el medio se conserva, porque ahí sí es parte del nombre.
926
+ const core = (name) => String(name || '').replace(/\s*\([^)]*\)\s*$/, '').trim()
927
+ const namesTest = (red, item) => Boolean(core(item.test))
928
+ && (core(red.test).includes(core(item.test)) || core(item.test).includes(core(red.test)))
835
929
  const loose = build.discovered.find((entry) => entry.kind === 'edge'
836
930
  && !build.redFirst.some((red) => namesTest(red, entry)))
837
- if (loose) return stop('edge-unproven', `${loose.detail} entró sin la prueba que lo fija`)
931
+ // El motivo dice qué comprobó la puerta y no una conclusión sobre el trabajo: pegarle al detalle del
932
+ // build un «entró sin la prueba que lo fija» producía una parada que se contradecía sola cuando el
933
+ // detalle contaba que la prueba sí estaba —la frase del agente y la de la puerta hablaban de cosas
934
+ // distintas y se leían como una—.
935
+ if (loose) {
936
+ return stop('edge-unproven', `${loose.detail} — su campo "test" (${loose.test || 'vacío'}) no nombra `
937
+ + `ninguno de los rojos declarados: ${build.redFirst.map((red) => red.test).join(' | ') || '(ninguno)'}`)
938
+ }
838
939
 
839
940
  // Qué revisión hubo, para que el cierre no pueda inventar una. Nace diciendo que no hubo porque
840
941
  // `express` no convoca a nadie, y ése es el caso que se escribió como si un cargo hubiera aprobado.
@@ -848,17 +949,35 @@ while (rounds++ < MAX_TASKS) {
848
949
  { schema: REVIEWED, label: 'review' },
849
950
  )
850
951
  if (!review) return stop('agent-unavailable', 'Review no devolvió resultado')
952
+ // Se junta apenas cada pasada contesta, y no al final: las paradas de abajo salen antes de llegar al
953
+ // registro, y sin esto lo que el revisor señaló se iba con la corrida.
954
+ const reviewDecisions = review.concerns.filter((one) => one.decision)
955
+ let decidedNote = ''
851
956
  if (review.verdict === 'bloqueado') {
852
- return stop('review-blocked', blockers(review).join('; ') || 'sin condiciones nombradas')
957
+ return stop('review-blocked', named(review).join('; ') || 'sin condiciones nombradas')
853
958
  }
854
959
  if (blockers(review).length) {
855
- await write(`Corregí sólo estos hallazgos con evidencia y actualizá el WIP: ${blockers(review).join('; ')}`,
960
+ // «Sólo estos hallazgos» acota el alcance (R6) y por sí solo deja un cabo suelto: una corrección
961
+ // tiene dependientes y no se anuncian —el conteo que enumeraba lo que cambió, el comentario que
962
+ // describía la forma vieja, la fila que la afirmaba—. Es R9 leído al derecho: lo que deja de valer
963
+ // se lleva puesto a quien lo daba por cierto, y eso vive casi siempre en otro archivo.
964
+ //
965
+ // Sin pedirlo, la vuelta siguiente rechaza por la deriva que la corrección acabó de crear, y como
966
+ // la vuelta es una sola eso termina en `review-failed` sobre trabajo correcto. Medido: una
967
+ // corrección agregó una mutación y un caso de prueba, y la re-revisión frenó porque la fila de
968
+ // acciones humanas seguía diciendo el número viejo y dos comentarios seguían contando los casos
969
+ // anteriores —corrida `wf_99130468-2c4`, 2026-09-17, sobre un banco desechable—. Traerlos no
970
+ // amplía el alcance: es terminar la corrección.
971
+ await write(`Corregí sólo estos hallazgos con evidencia y actualizá el WIP: ${blockers(review).join('; ')}. `
972
+ + 'Traé también lo que tu propia corrección deje desactualizado —un conteo, un comentario que '
973
+ + 'describa la forma vieja, una fila que la enumere— y nada más que eso.',
856
974
  { label: 'review-fix' })
857
975
  review = await run(`Volvé a revisar el diff corregido de ${task.id}.${MANIFEST}${VERDICT}${RULED}`,
858
976
  { schema: REVIEWED, label: 'review' })
859
977
  if (!review) return stop('agent-unavailable', 'la re-revisión no devolvió resultado')
978
+ reviewDecisions.push(...review.concerns.filter((one) => one.decision))
860
979
  if (review.verdict === 'bloqueado' || blockers(review).length) {
861
- return stop('review-failed', blockers(review).join('; ') || 'sin condiciones nombradas')
980
+ return stop('review-failed', named(review).join('; ') || 'sin condiciones nombradas')
862
981
  }
863
982
  }
864
983
  // Con reglas que rigen, aprobar sin nombrar contra cuáles es la misma falla que la de abajo en otro eje.
@@ -871,7 +990,30 @@ while (rounds++ < MAX_TASKS) {
871
990
  // Contra qué reglas se revisó va también a la entrada de DONE, y no sólo al journal: el journal muere
872
991
  // con la corrida y la entrada queda, así que sin esto no había cómo reconstruir contra cuáles se
873
992
  // revisó cuando alguien audita la entrega meses después (caso 122).
993
+ // Lo que la revisión encontró y no le toca resolver va a la fila que una persona lee. No al INBOX, que
994
+ // es para propuestas: quien lo lea ahí puede promoverlo, y esto no se promueve, se decide.
995
+ //
996
+ // Se acumula desde la primera pasada y no se lee sólo de la última: al corrector se le pasa nada más
997
+ // lo que manda a corregir, así que nunca se entera de la decisión y no la puede cerrar. Leyendo sólo
998
+ // la re-revisión, una decisión que el agente no repitiera desaparecía sin dejar rastro, y el mismo
999
+ // camino la perdía entera cuando la revisión paraba antes de llegar acá.
1000
+ //
1001
+ // El tope es el mismo del INBOX y por la misma razón: una revisión que deja treinta filas en una tabla
1002
+ // que lee una persona no está priorizando (caso 101). Lo que no entra queda contado en el hecho.
1003
+ const decided = [...new Set(reviewDecisions.map((one) => one.detail))]
1004
+ const filed = decided.slice(0, INBOX_CAP)
1005
+ if (filed.length) {
1006
+ // La nota que devuelve viaja al hecho: sin ella la entrega afirma una fila que el disco no tiene,
1007
+ // que es el caso 087 entrando por otra puerta.
1008
+ const nota = await registerHuman(`Registrá en ${HUMAN} una fila por cada decisión que la revisión de `
1009
+ + `${task.id} dejó abierta, con qué la cierra y quién puede tomarla. La primera columna nunca es `
1010
+ + `${task.id} —el porqué es el mismo que en Build—: va la épica, el hito o el recorrido al que `
1011
+ + `alcanza. No inventes responsables ni fechas: ${JSON.stringify(filed)}`, 'review-human')
1012
+ decidedNote = `${nota}`
1013
+ }
874
1014
  reviewFact = `${review.verdict} por ${cast.review}, sobre ${review.consulted.join(', ')}`
1015
+ + (filed.length ? ` · ${filed.length} decisión(es) registrada(s)${decidedNote}` : '')
1016
+ + (decided.length > filed.length ? ` · ${decided.length - filed.length} decisión(es) sin volcar` : '')
875
1017
  + ((review.rules || []).length ? ` · reglas: ${review.rules.join(', ')}` : '')
876
1018
  // Lo que no impide entregar no manda a tocar código, y tampoco desaparece: la mejora opinable que se
877
1019
  // corrige a las apuradas cuesta una vuelta y un riesgo que nadie pidió. Va a Propuestas y no a
@@ -881,7 +1023,10 @@ while (rounds++ < MAX_TASKS) {
881
1023
  // De qué vía salió cada una: este recorrido, la tarea que la dejó anotada y la fecha del motor. La
882
1024
  // arma el recorrido, que es el único de los dos que sabe las tres cosas (caso 115).
883
1025
  const origin = inboxOrigin('autobuild', task.id, planning.today)
884
- const noted = review.concerns.filter((one) => !one.blocking).map((one) => withOrigin(one.detail, origin))
1026
+ // Lo marcado como decisión ya tiene destino y no se duplica acá: escrito en los dos lados, además de
1027
+ // aparecer dos veces, le come una ranura del tope a una propuesta que sí lo era.
1028
+ const noted = review.concerns.filter((one) => !one.blocking && !one.decision)
1029
+ .map((one) => withOrigin(one.detail, origin))
885
1030
  const kept = noted.slice(0, INBOX_CAP)
886
1031
  // Lo que pasa del tope no se escribe y tampoco desaparece: queda contado en el hecho de revisión, que
887
1032
  // viaja a `done/`. Una revisión que anota treinta y seis cosas no está priorizando, y el INBOX no las
@@ -1009,9 +1154,14 @@ const closing = await write(
1009
1154
  )
1010
1155
  if (!closing) return stop('agent-unavailable', 'Closing no devolvió resultado')
1011
1156
  if (!closing.passed) return stop('planning-check-failed', closing.details)
1157
+ // El archivo nace con su estado escrito porque la compuerta lo lee de ahí —R28, y el porqué vive junto a
1158
+ // esa lectura—. Sin decirlo acá la fase escribe prosa sin `status`, y la instancia queda con una compuerta
1159
+ // que sólo se destraba borrando: la forma que la regla prohíbe, escrita por el propio recorrido.
1012
1160
  if (completed.length && contract.humanCheckpoint) await write(
1013
1161
  `Creá ${GATE} con el hito terminado, las tareas ${completed.join(', ')}, la evidencia, las acciones humanas ` +
1014
- `pendientes y las instrucciones exactas para continuar. Nunca hagas push ni deploy.`,
1162
+ `pendientes y las instrucciones exactas para continuar. Arrancá el archivo con un frontmatter ` +
1163
+ `"status: pendiente", y decí que se destraba cambiándolo a "resuelta" —no borrando el archivo, que es ` +
1164
+ `lo que deja leer después qué se revisó—. Nunca hagas push ni deploy.`,
1015
1165
  { label: 'human-checkpoint' },
1016
1166
  )
1017
1167
  return finish({ done: completed, count: completed.length, hito: currentMilestone, phases: ran })
@@ -111,16 +111,30 @@ const STAGE = {
111
111
  properties: {
112
112
  gate: { type: 'string', enum: ['cumplido', 'con-condiciones', 'no-cumplido'] },
113
113
  // La ruta del análisis, no el análisis. Ver el comentario de arriba: mientras el campo pudo
114
- // contener el texto entero, lo contuvo, y la respuesta no llegaba.
115
- analysis: { type: 'string' }, summary: { type: 'string' },
116
- evidence: { type: 'array', items: { type: 'string' } },
117
- assumptions: { type: 'array', items: { type: 'string' } },
118
- openQuestions: { type: 'array', items: { type: 'object', additionalProperties: false,
114
+ // contener el texto entero, lo contuvo, y la respuesta no llegaba. Es la única excepción al techo:
115
+ // una ruta se acota sola.
116
+ analysis: { type: 'string' },
117
+ // Y el resto lleva su techo por la misma razón, que el arreglo de `analysis` no alcanzó a cubrir: la
118
+ // etapa que **frena** escribe largo en `missing` y `humanAction` —lo dice el párrafo de arriba y lo
119
+ // volvió a mostrar una corrida sobre un banco, con los cinco reintentos envueltos—. Sin tope, cada
120
+ // campo nuevo nace sin él y lo que revienta es la etapa que menos se ejercita.
121
+ //
122
+ // Los números salen de lo que el prompt ya pedía —`summary` en 150 palabras o menos, que es del orden
123
+ // de mil caracteres— y de R16: entre etapas viaja lo que la siguiente necesita para decidir, no todo
124
+ // lo que la anterior produjo. Lo que no entra no se pierde: vive en el archivo de análisis.
125
+ //
126
+ // Un techo por debajo de lo que el prompt pide no acota: contradice. Medido en una corrida real con
127
+ // `summary` en 400, el agente achicó en cada reintento —2766, 2078, 2064, 1934, 1854— y murió
128
+ // convergiendo hacia un número siete veces menor que el que la misma instrucción le pedía escribir.
129
+ summary: { type: 'string', maxLength: 1000 },
130
+ evidence: { type: 'array', maxItems: 5, items: { type: 'string', maxLength: 200 } },
131
+ assumptions: { type: 'array', maxItems: 4, items: { type: 'string', maxLength: 160 } },
132
+ openQuestions: { type: 'array', maxItems: 4, items: { type: 'object', additionalProperties: false,
119
133
  required: ['detail', 'blocking'],
120
- properties: { detail: { type: 'string' }, blocking: { type: 'boolean' } },
134
+ properties: { detail: { type: 'string', maxLength: 200 }, blocking: { type: 'boolean' } },
121
135
  } },
122
- missing: { type: 'string' },
123
- humanAction: { type: 'string' },
136
+ missing: { type: 'string', maxLength: 500 },
137
+ humanAction: { type: 'string', maxLength: 500 },
124
138
  },
125
139
  }
126
140
  // Tres salidas porque el contrato del equipo enumera tres —«hacer, no hacer o investigar»— y con un
@@ -281,14 +295,16 @@ const runStage = (stage, index) => {
281
295
  `condiciona la decisión siguiente. ` +
282
296
  `Escribí primero tu análisis completo en ${REPORTS} como ${stage.id}-analisis.md —ahí no hay ` +
283
297
  `límite de extensión y es lo que lee quien sintetiza al final— y devolvé esa ruta en analysis.\n` +
284
- `Lo que devolvés en el esquema es corto, todo junto por debajo de 2000 caracteres: si algo no ` +
285
- `entra, va al archivo y en el campo queda lo esencial. Vale también para missing y humanAction ` +
286
- `cuando el gate no se cumple, que es cuando más se escribe. ` +
298
+ `Lo que devolvés en el esquema es corto y cada campo tiene su tope, que el esquema rechaza si lo `
299
+ + `pasás: summary 1000 caracteres, missing y humanAction 500, cada evidencia 200. Si algo no entra, `
300
+ + `va al archivo y en el campo queda lo esencial. El tope rige sobre todo cuando el gate no se `
301
+ + `cumple, que es cuando más se escribe. ` +
287
302
  `Devolvé los campos directamente: nunca envuelvas la respuesta en {"raw": ..., "len": ...} ni ` +
288
303
  `mandes el JSON como string adentro de un campo, porque eso no valida. ` +
289
- `En summary va, en 150 ` +
290
- `palabras o menos, lo que la etapa siguiente necesita para decidir —no un resumen de tu análisis, ` +
291
- `sino lo que le cambia el trabajo—, porque eso se le reenvía a cada etapa posterior.`,
304
+ `En summary va, en los 1000 caracteres que el esquema admite, lo que la etapa siguiente necesita ` +
305
+ `para decidir —no un resumen de tu análisis, sino lo que le cambia el trabajo—, porque eso se le ` +
306
+ `reenvía a cada etapa posterior. El límite va en caracteres y no en palabras porque es lo que el ` +
307
+ `esquema mide.`,
292
308
  { schema: STAGE, label: `stage:${stage.id}` })
293
309
  }
294
310
 
@@ -22,6 +22,12 @@ const { hasHooks } = require('./config')
22
22
 
23
23
  function check(root) {
24
24
  const errors = []
25
+ // La configuración se lee primero y se corta ahí. La resolución del paquete cuelga de ella —`declaredRoot`
26
+ // la parsea— así que un JSON roto se propagaba como archivos del motor que faltan, y el aviso mandaba a
27
+ // correr `npm install` sobre un motor que está instalado: la acción sugerida no arreglaba nada. Nombrar
28
+ // la causa y no seguir es lo que separa este aviso del ruido — enumerar ausencias que salen de una causa
29
+ // ya nombrada manda a arreglar lo que no está roto (caso 174).
30
+ try { O.mode(root) } catch (error) { return [error.message] }
25
31
  const hookDir = path.join(root, 'automatization', 'hooks')
26
32
  if (!fs.existsSync(path.join(root, 'automatization', 'AGENTS.md'))) {
27
33
  errors.push('falta automatization/AGENTS.md')
@@ -11,7 +11,7 @@ const P = require('../planning/parser')
11
11
  const PC = require('../planning/contracts')
12
12
  const AD = require('../planning/adoption')
13
13
  const F = require('../core/files')
14
- const { fail, planningRoot, TODAY } = require('./io')
14
+ const { fail, planningRoot, TODAY, USAGE, REFUSED } = require('./io')
15
15
 
16
16
  // El historial de acciones humanas se acumula en un solo archivo y no por épica: una fila no pertenece
17
17
  // a ninguna, y esperar el cierre de una épica dejaría sin archivar las de un planning que todavía no
@@ -30,7 +30,7 @@ function adopt(dir) {
30
30
  const existing = fs.readFileSync(target, 'utf8')
31
31
  if (!AD.sealWarnings(root).some((one) => /sin huella/.test(one))) {
32
32
  fail(`${AD.BASELINE} ya existe: se genera una vez. Para retirar un renglón, ponele \`#~\` `
33
- + 'delante; `check` marca los que ya cumplen.')
33
+ + 'delante; `check` marca los que ya cumplen.', REFUSED)
34
34
  }
35
35
  const slugs = AD.declared(existing)
36
36
  F.atomicWrite(target, existing.replace(/\n?$/, `\n# huella: ${slugs.length} entradas · `
@@ -78,7 +78,7 @@ function archiveHumanActions(root) {
78
78
  function archive(dir, rawNum) {
79
79
  if (String(rawNum || '') === 'human-actions') return archiveHumanActions(planningRoot(dir))
80
80
  return fail('Sólo se archiva `human-actions`. La evidencia de una tarea ya vive en su propio archivo '
81
- + 'de `done/`, así que archivar una épica dejó de tener sentido.', 2)
81
+ + 'de `done/`, así que archivar una épica dejó de tener sentido.', USAGE)
82
82
  }
83
83
 
84
84
  module.exports = { archive, adopt }