@ingeniomaps/cauce 0.93.0 → 0.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +311 -0
  2. package/automatization/workflows/autobuild.js +28 -1
  3. package/engine/agents/evaluations.js +20 -5
  4. package/engine/agents/fork.js +21 -8
  5. package/engine/agents/learning-sources.js +7 -7
  6. package/engine/agents/learning.js +15 -6
  7. package/engine/automation/check.js +2 -2
  8. package/engine/automation/config.js +9 -9
  9. package/engine/automation/index.js +19 -8
  10. package/engine/automation/rules.js +27 -9
  11. package/engine/cli/archive.js +1 -4
  12. package/engine/cli/args.js +16 -2
  13. package/engine/cli/bench.js +3 -3
  14. package/engine/cli/catalog.js +3 -3
  15. package/engine/cli/claims.js +21 -23
  16. package/engine/cli/contract.js +24 -7
  17. package/engine/cli/instance.js +19 -19
  18. package/engine/cli/ops.js +7 -3
  19. package/engine/cli/planning.js +4 -4
  20. package/engine/cli/upgrade-report.js +10 -10
  21. package/engine/cli/validate.js +7 -7
  22. package/engine/core/onboarding.js +4 -1
  23. package/engine/core/ownership.js +2 -2
  24. package/engine/core/repos.js +18 -12
  25. package/engine/hooks/approval.js +9 -1
  26. package/engine/hooks/chat.js +27 -5
  27. package/engine/hooks/files.js +6 -6
  28. package/engine/hooks/input.js +5 -2
  29. package/engine/hooks/run.js +3 -3
  30. package/engine/hooks/shell.js +20 -6
  31. package/engine/hooks/verify.js +7 -7
  32. package/engine/integrations/proposals.js +5 -1
  33. package/engine/integrations/registry.js +7 -0
  34. package/engine/integrations/state.js +6 -3
  35. package/engine/planning/adoption.js +2 -2
  36. package/engine/planning/claims.js +8 -8
  37. package/engine/planning/parser.js +8 -8
  38. package/engine/planning/state.js +2 -2
  39. package/engine/planning/structure.js +7 -7
  40. package/package.json +1 -1
  41. package/template/planning/rules/README.md +8 -5
  42. package/template/planning/rules/system/code-shape.md +19 -0
  43. package/template/planning/rules/system/commits.md +35 -0
  44. package/template/planning/rules/system/conduct.md +23 -0
  45. package/template/planning/rules/system/process.md +48 -118
  46. package/template/planning/rules/system/runs.md +153 -0
package/CHANGELOG.md CHANGED
@@ -14,6 +14,317 @@ 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.95.0] - 2026-09-16
18
+
19
+ ### Corregido
20
+
21
+ - **El bloqueo dice hasta dónde llega el «dale».** Ofrece dos salidas y sólo la del pegado decía su
22
+ alcance —«valen para ese conjunto y dejan de valer en cuanto cambie»—; la del chat, que es la que se
23
+ ofrece primero, decía sólo «reintentá el mismo cambio y pasa».
24
+
25
+ Faltaban **dos** cosas y no son la misma. Que el «dale» cubre lo que se frenó y nada más: por eso en una
26
+ carpeta donde se itera cada archivo nuevo vuelve a frenar, que desde afuera se lee como si el guard se
27
+ hubiera olvidado de que ya la aprobaste. Y que lo concedido **sigue valiendo en los mensajes
28
+ siguientes** hasta que lo niegues — la mitad permisiva, la que no se nota porque lo que no ocurre es un
29
+ bloqueo. `check` la muestra al final de la corrida; ahora también se dice al concederla.
30
+
31
+ No cambia ningún permiso: el diff es texto.
32
+
33
+ - **Partir una tarea de una épica actualiza también sus historias, así que `check` no queda en rojo.** Una
34
+ tarea que viene de una épica es además una historia suya, y la partición reemplazaba sólo su línea del
35
+ BACKLOG. La lista de historias quedaba nombrando un slug que ya no existe en ninguna parte, y eso rompe
36
+ por los dos lados según qué escriba la partición: con las subtareas declarando la épica, `check` falla en
37
+ el acto con «BACKLOG \<sub\>: no existe en epic-NNN»; sin declararla pasa en verde y **la épica no puede
38
+ cerrar nunca**, porque `closed` exige evidencia de cada historia y la original jamás la va a tener.
39
+
40
+ Ahí es donde la unidad partida se cierra diciendo en qué se partió, que es lo que pide R25. **No va una
41
+ entrada en `done/`**: ese directorio es la evidencia de lo que una tarea entregó, y una partida no
42
+ entregó nada. Una tarea sin épica no necesita ningún cierre — deja de existir limpiamente y no hay
43
+ cruce que se rompa.
44
+
45
+ Si tenés una épica con una historia que ninguna tarea de la cola nombra y que nunca vas a poder cerrar,
46
+ viene de esto: reemplazá esa historia por las de las subtareas que la partición dejó en el BACKLOG.
47
+
48
+ - **Un límite escrito en dos líneas ya viaja entero a los agentes.** Una viñeta bajo `### Límites` se
49
+ recorría línea por línea, así que de un límite que no entraba en el ancho del archivo —que es como se
50
+ escribe cualquiera de verdad— llegaba **sólo la primera línea**, cortada a mitad de frase. Y lo que se
51
+ pierde ahí suele ser lo que el límite decide: «…crear, editar o borrar productos, pedidos o» sin el «no
52
+ las hace un runner» que venía abajo.
53
+
54
+ La continuación, además, volvía por el otro lado: no coincidía con ninguna viñeta declarada y `check` la
55
+ reportaba como un párrafo «que no llega a los agentes», citando media frase tuya. O sea que el proyecto
56
+ hacía lo que el aviso pedía, el aviso seguía sonando, y el límite que sí viajaba era el mutilado.
57
+
58
+ Si escribiste tus límites en una sola línea para que el aviso bajara a cero, ya podés volver a partirlos
59
+ donde corresponda. Una línea en blanco cierra la viñeta, así que la prosa que venga después sigue sin
60
+ contar como límite — y sigue avisándose.
61
+
62
+ - **Una notificación de tarea de fondo ya no borra del chat a la persona que está mirando.** Cuando un
63
+ guard frena algo, el bloqueo ofrece dos salidas: contestar «dale» —corto, y es lo que la persona ya está
64
+ haciendo— o pegar líneas a mano en `planning/.ops-approval`. La primera sólo se ofrecía si el registro
65
+ de la sesión decía que había alguien.
66
+
67
+ Una notificación de una tarea de fondo entra por el mismo hook que un mensaje, así que el registro
68
+ pasaba a decir que el último que habló no era una persona — y en el turno que esa notificación despierta
69
+ **la persona sigue ahí**. El bloqueo le ofrecía la salida cara y terminaba copiando y pegando un comando
70
+ que no hacía falta. Se disparaba solo en el patrón más común de una sesión larga: lanzar trabajo de
71
+ fondo y retomar cuando vuelve.
72
+
73
+ Que haya alguien a quien preguntarle pasa a ser de la sesión y no del mensaje. **No cambia ninguna
74
+ autorización**: preguntar no es conceder, y lo que decide si un «dale» viejo cubre algo nuevo quedó
75
+ intacto — un mensaje de hace tres turnos sigue sin autorizar lo que se frenó ahora. Y un recorrido de
76
+ Cauce sigue sin ofrecer el «dale», porque ahí no hay nadie leyendo.
77
+
78
+ - **Una tarea que `autobuild` parte ya suelta su reserva, así que la corrida sigue.** El recorrido reclama
79
+ la tarea antes de estimarla, y cuando Decompose la partía el reclamo quedaba puesto sobre un slug que ya
80
+ no estaba ni en la cola ni en lo hecho. Con eso el runner quedaba ocupado por una tarea que no existe: el
81
+ Claim de la primera subtarea se negaba —«este runner ya tiene …»— y la corrida entera terminaba en
82
+ `claim-stuck` **sin construir nada**, dejando además `check` en rojo con un reclamo huérfano que soltaba
83
+ una persona a mano.
84
+
85
+ No dependía de una carrera ni de un id de runner compartido, que es a lo que el mensaje mandaba a mirar:
86
+ pasaba siempre que Decompose partiera, que es una fase que el propio protocolo manda correr. Si venís de
87
+ una versión anterior y tenés un reclamo huérfano de esto, se suelta con
88
+ `node tools/ops.js release planning <tarea>`.
89
+
90
+ La reserva se suelta **después** de comprobar que el reemplazo en el BACKLOG ocurrió: si no ocurrió, la
91
+ tarea sigue viva y su reserva tiene que seguir puesta.
92
+
93
+ - **Un veredicto se lee de una sola forma, así que las dos cuentas de un registro coinciden.** El registro
94
+ de una evaluación transcribe la respuesta literal del cargo —es la evidencia de la corrida—, así que su
95
+ cuerpo puede traer cualquier cosa, incluida una línea con forma de veredicto. La cuenta de la última
96
+ corrida las tomaba todas y la cuenta compuesta sólo las que cuelgan de un `### <caso>`: dos números
97
+ distintos sobre el mismo archivo y ninguna forma de saber cuál valía.
98
+
99
+ Con un registro de dos casos cuya respuesta citaba un veredicto ajeno, la última corrida decía «2 de 3»
100
+ sobre una corrida que midió dos y pasó las dos. Ahora un veredicto es un caso con su línea —sin
101
+ encabezado no hay a qué atribuirlo— y las dos lecturas salen del mismo lugar.
102
+
103
+ - **Un `agents fork` que se corta a la mitad se retira en vez de quedarse puesto.** Si el copiado fallaba
104
+ —un archivo ilegible, disco lleno— quedaba medio cargo en `agents/`, y lo caro no es perder la copia: es
105
+ dejarla. El catálogo pasa a resolver el slug contra esa copia, así que el intento siguiente ya no dice
106
+ que se cortó sino **«ya lo mantiene esta empresa»** — y lo que la empresa mantiene es un contrato
107
+ incompleto que ningún manifiesto registra, o sea que el aviso de deriva tampoco lo mira nunca.
108
+
109
+ Verificado provocando el corte con un archivo ilegible: antes el segundo intento contestaba «ya lo
110
+ mantiene esta empresa» con `.cauce` vacío; ahora no queda nada en `agents/` y arreglando lo que lo
111
+ cortó el fork sale entero. Sólo se borra lo que esa llamada creó, y la ruta se comprueba contra la raíz
112
+ de la instancia antes de tocarla.
113
+
114
+ - **`automation uninstall` no borra nada si no puede leer la configuración de tu runner.** Borraba
115
+ primero y leía el `settings.json` al final, con un `JSON.parse` sin proteger. Con un archivo que alguien
116
+ dejó a medio fusionar, el comando moría con «Expected property name or '}' in JSON at position 13»
117
+ —que no nombra ni un archivo— **después** de haber borrado los workflows y los cargos: la instancia
118
+ quedaba a medio desinstalar, con la configuración registrando guards que ya no existen.
119
+
120
+ Verificado sobre un banco: el comando se para antes de tocar el disco, dice qué archivo no pudo leer, y
121
+ arreglando ese archivo vuelve a correr.
122
+
123
+ - **Un adaptador propio que no trae `components` ya no rompe el borrador.** El README declara el contrato
124
+ de un adaptador por sus tres funciones y no dice qué campos trae un item, así que un adaptador de la
125
+ empresa —que es lo que ese README invita a escribir— puede no traerlo. Con `serviceFrom: "component"`,
126
+ que es lo que trae el molde, eso era un `TypeError` sobre `undefined` sin nada que lo atribuyera.
127
+
128
+ Sin el campo la respuesta es la que el borrador ya sabía dar: `service: ""` y «Debe definirse un
129
+ servicio único para la promoción». Lo que se toleró es la ausencia y no el caso de uso: con un
130
+ componente sigue resolviendo el servicio y con dos sigue sin resolverlo.
131
+
132
+ - **`promotionEpic` se valida también cuando la promoción es una épica.** El campo se exigía `NNN` sólo
133
+ para una historia. Para una épica es opcional —sin él el número lo elige el motor—, y por eso nadie lo
134
+ miraba; pero cuando está se usa tal cual para armar el nombre del archivo. O sea que el único caso sin
135
+ validar era el único que escribe una ruta con lo que diga el campo.
136
+
137
+ `promotionEpic: 7` producía `epic-7-…`, que el roadmap no reconoce como épica: la promoción se anunciaba
138
+ bien y lo escrito quedaba invisible para la cola. Y con un `..` la épica salía de `roadmap/` entera.
139
+ Verificado sobre una instancia desechable: `integration check` daba exit 0 y `integration promote`
140
+ devolvía «✓ jira:DEMO-42 promovido como epic» dejando el archivo fuera del roadmap. Ahora `check` lo
141
+ rechaza con «promotionEpic debe ser NNN» y `promote`, que valida antes de escribir, no llega a tocar el
142
+ disco.
143
+
144
+ - **El aviso de commits sin entrada de DONE vuelve a decir lo que ve.** El filtro por fecha leía la
145
+ columna del `git log` en una posición fija, y `%h` no mide siempre lo mismo: git sube el largo del hash
146
+ abreviado solo cuando el repositorio crece. En cuanto pasa de siete, la posición fija lee un espacio en
147
+ vez de la fecha y **ningún commit pasa el filtro**, así que el aviso quedaba mudo sin decir por qué —la
148
+ peor forma de que una puerta falle, porque se lee igual que «no hay nada que avisar»—. Ahora se parte
149
+ por espacios y se lee el campo.
150
+
151
+ Verificado en este repositorio, donde el abreviado mide ocho: el aviso pasó de cero a «66 commit(s)
152
+ desde 2026-09-15 que ninguna entrada de DONE nombra». La prueba nueva lo fija en 7, 8 y 12.
153
+
154
+ - **El sello de un registro de evaluación se escribe en el frontmatter y no en el cuerpo.**
155
+ `markConsolidated` buscaba `status:` en el documento entero. Un registro nace sin `status` en su
156
+ frontmatter y su cuerpo transcribe la respuesta literal del cargo: si esa respuesta traía una línea
157
+ `status: <algo>` en la columna cero, el sello aterrizaba ahí y el frontmatter quedaba sin sellar.
158
+
159
+ Un registro sin sellar vuelve a entrar en la propuesta siguiente, o sea que el mismo hallazgo llega dos
160
+ veces — que es exactamente lo que sellar existe para evitar.
161
+
162
+ - **`ops -h` pide la ayuda, y un nombre heredado de `Object` ya no pasa por comando.** Dos huecos de la
163
+ puerta de entrada del CLI, los dos de la misma forma: leer algo que no era ni un comando ni una bandera.
164
+
165
+ `-h` se anunciaba en el uso y no existía: el parser sólo reconoce lo que empieza con `--`, así que caía
166
+ de argumento posicional y `ops check -h` contestaba «no existe el planning en …/-h» **saliendo con 0**.
167
+ Pedir ayuda y recibir un error sobre un directorio inventado es la peor forma de contestar, porque
168
+ parece que el comando corrió.
169
+
170
+ Y la tabla de comandos se consultaba con `FLAGS[comando]`, que resuelve contra `Object.prototype`:
171
+ `ops constructor` salía con 0 sin hacer nada y `ops toString --json` moría con
172
+ «FLAGS[command].includes is not a function». No es un nombre exótico — es lo que sale de pasarle al CLI
173
+ una variable que vino vacía o mal leída desde un script.
174
+
175
+ - **El tope de horas de una propuesta deja de desaparecer cuando la configuración no lo declara.** La
176
+ validación de propuestas lee `runner.maxTaskHours` de `ops.config.json` y tenía un default de cuatro
177
+ horas para cuando no se puede leer. Ese default sólo cubría el archivo ilegible: un `runner` sin el
178
+ campo no lanza nada, así que el tope quedaba en `undefined` y la comparación pasaba a ser falsa
179
+ siempre. O sea que el límite no se aflojaba: se iba entero, y en silencio.
180
+
181
+ Se nota poco a propósito de lo que es — lo que deja de pasar es un **rechazo**—, así que una propuesta
182
+ de cuarenta horas viajaba a Jira sin que nada la mirara. Verificado sobre una configuración con
183
+ `runner: {}`: antes no salía ni un error, ahora sale «supera 4h; debe dividirse o justificarlo».
184
+
185
+ Si tu `ops.config.json` declara `maxTaskHours`, nada cambia — `check` ya lo exigía mayor que cero.
186
+
187
+ - **`rm -r` sobre la raíz o el home se frena también entrecomillado.** La regla reconocía el destino sólo
188
+ desnudo: `rm -rf /` bloqueaba y `rm -rf "/"`, `rm -rf "$HOME"`, `rm -rf ${HOME}`, `rm -rf $HOME/` y
189
+ `rm -rf -- /` pasaban. La comilla es lo que más importa — citar una variable es la forma *correcta* de
190
+ escribirlo en bash, así que el hueco premiaba al que tiene el hábito bueno.
191
+
192
+ Los borrados con destino concreto siguen pasando, también entrecomillados: se comprobó con
193
+ `rm -rf "$HOME/proyecto/dist"` entre otros cinco.
194
+
195
+ - **Un commit escrito en dos líneas vuelve a pasar por los guards.** `isCommit` reconocía el commit
196
+ después de `;`, `&` o `|` y no después de un salto de línea ni dentro de un subshell — o sea que
197
+ `git add x` ⏎ `git commit -m x` en un solo Bash, que es como se escriben dos pasos, dejaba sin correr
198
+ `governance`, `dependencies` y `verify`, y también el bloqueo que impide stagear y commitear a la vez.
199
+
200
+ `bash -c "git commit …"` sigue sin reconocerse, a propósito: hacerlo exige desentrecomillar, y eso
201
+ choca con que el mensaje de un commit sea dato y no código.
202
+
203
+ - **Redirigir con `>|` ya no evade el guard que mira a dónde escribe un comando.** `>|` es el override de
204
+ `noclobber` y escribe igual que `>`, pero el guard no veía su destino: el comando se partía en segmentos
205
+ por `|` antes de buscar destinos, así que la redirección quedaba separada de su ruta. Verificado sobre
206
+ un banco: `echo x > <fuera>` y `echo x >> <fuera>` bloqueaban y `echo x >| <fuera>` pasaba.
207
+
208
+ Importa porque una de las rutas que se escriben por ese hueco es `planning/.ops-approval` — la
209
+ aprobación que después habilita el push a la rama viva—, que es lo que cerraron el 098 y el 119.
210
+
211
+ Las tuberías corrientes siguen sin producir destino: se comprobó con `ls | wc -l` y `a || b`.
212
+
213
+ ## [0.94.0] - 2026-09-16
214
+
215
+ ### Agregado
216
+
217
+ - **Cinco reglas nuevas, traídas de una empresa que las pagó.** Salieron de revisar las reglas propias de
218
+ una instancia real: no son ideas, cada una tiene adentro la corrida que costó.
219
+
220
+ - **R24 — una premisa sobre el propio código se abre antes de usarla.** R14 ya exigía registro para lo
221
+ que se afirma de una herramienta o una norma, y dejaba afuera tu propio repositorio, que es donde
222
+ nadie te va a discutir. Cuatro corridas perdidas en un día, todas frenadas en la puerta y ninguna por
223
+ el código: la aceptación pedía algo que el sistema no hace. Y el ancla `archivo:línea` se abre, no se
224
+ copia — una función se movió de la 555 a la 733 en la misma sesión.
225
+ - **R25 — el identificador de una unidad de trabajo no cambia mientras está viva.** Renombrar un slug a
226
+ mitad de camino rompe el cruce entre la cola y lo hecho **sin que nada falle**: cada lado se lee
227
+ coherente por separado. Una tarea partida en cuatro y cerrada con otros nombres costó 594k tokens de
228
+ la corrida siguiente para descubrir que ya estaba construida. Si el nombre tiene que cambiar, va
229
+ `slug-nuevo (antes: slug-viejo)` hasta cerrar.
230
+ - **R26 — una puerta acota su propio costo y no escribe en el árbol que juzga.** Dos revisores lanzando
231
+ la misma suite fueron cuatro corridas en cuatro minutos: el sistema operativo mató la sesión entera
232
+ con un pico de 24,2 GB. Y un formateador con `--fix` o un build que limpia su salida editan el trabajo
233
+ de quien está commiteando. Una puerta que estorba se saltea, y desde ahí no protege de nada.
234
+ - **R27 — una defensa se aplica por defecto y cada excepción se declara sola.** Con lista de lo que
235
+ protege, todo lo que se agregue después nace afuera y nada lo compara. Incluye el caso que más se
236
+ disfraza: «esta comprobación no corre en desarrollo» es una quita escrita como agregado, y garantiza
237
+ que el camino de producción sea el único que nunca se ejercitó.
238
+ - **R28 — un estado lo dice el contenido de un archivo, nunca su presencia.** Un centinela cuya única
239
+ información es existir obliga a que borrarlo sea parte de la resolución, y eso alguien lo olvida: la
240
+ corrida arranca, lee todo el estado y recién ahí muere. Pasó dos veces el mismo día, a 42k tokens por
241
+ vez. Es lo que el WIP de Cauce ya hace bien con `status: IDLE`.
242
+
243
+ - **R9 dice cuándo se puede quitar lo que está en uso.** Exigía probar la ausencia de lo quitado y nunca
244
+ decía cuándo se puede quitar. Ahora: lo que está en uso no se corta, se depreca, y la marca dice las
245
+ dos cosas que la vuelven una salida y no una etiqueta — qué lo reemplaza, y qué condición permite
246
+ borrarlo. Sin la primera, quien lo usa no sabe a dónde ir; sin la segunda, el deprecado es código
247
+ muerto con un cartel puesto y se queda para siempre.
248
+
249
+ Y lo que no llama nadie es otra cosa: se borra. Cortar de golpe rompe a un consumidor que nadie miró;
250
+ deprecar lo que nadie usa cuesta mantener dos caminos para nadie.
251
+
252
+ - **R10 dice a dónde va lo que se publica, no sólo quién lo autoriza.** La autorización decía si se
253
+ publica y nunca dónde. Ahora: lo que se publica va al repositorio en el que estás trabajando, y si ese
254
+ remoto es un fork, va al fork — con la rama cortada de la suya, porque una rama cortada del principal
255
+ es la antesala de mandarle el PR.
256
+
257
+ No se deduce del contexto: que la herramienta resuelva sola el repositorio de origen no es una
258
+ autorización, ni lo son que el cambio «obviamente tenga que llegar ahí» ni que un PR anterior haya ido
259
+ a parar allá. Saltar al principal se pide con todas las letras y para ese caso concreto.
260
+
261
+ Es de las pocas sin vuelta atrás: un PR mal apuntado es trabajo publicado en el repositorio de otro
262
+ equipo — lo vieron, les llegó la notificación, y cerrarlo no deshace nada de eso.
263
+
264
+ - **R8 dice que la prohibición de firmas de IA cubre todo lo que se publica**, no sólo el mensaje del
265
+ commit: el título y el cuerpo del pull request, y los comentarios que se dejen ahí. Y casi nunca es
266
+ algo que alguien tipea — lo agrega la herramienta sola, al final del texto que escribiste—, así que
267
+ cumplirla es revisar la salida antes de publicarla, no acordarse de no escribirla.
268
+
269
+ - **R17 dice qué cuenta como una condición, que es lo que volvía incontable su umbral.** La barra son
270
+ cinco condiciones de aceptación y nunca decía qué es una. Ahora: una condición es un resultado que se
271
+ puede mirar por separado, no una viñeta. Cinco viñetas que describen el mismo invariante desde cinco
272
+ ángulos son **una**, y contarlas como cinco parte por la mitad lo que era una sola cosa.
273
+
274
+ La otra dirección es la cara y la que nadie mira: una frase que promete dos resultados con vidas
275
+ distintas —«valida el pago y manda el email»— son **dos**, y escrita como una el umbral no se entera
276
+ nunca. Contar de menos no dispara nada, y se lee igual que una unidad chica.
277
+
278
+ La prueba no pide criterio: si al tachar una condición las otras siguen valiendo, son distintas; si
279
+ tachar una deja a las demás sin sentido, era una sola dicha en partes.
280
+
281
+ - **R9 ahora pide que la mutación quede escrita, no sólo que se corra.** R9 ya exigía romper, con el
282
+ código puesto, exactamente lo que el caso dice cuidar, y verlo ponerse rojo. Lo que faltaba es que eso
283
+ quedara en la aceptación: una línea con qué se rompe y qué prueba tiene que ponerse roja. Sin ella,
284
+ quien revisa no puede distinguir la mutación que se corrió de la que se pensó, y lo único que le queda
285
+ es volver a correrla — o sea rehacer el trabajo que delegarlo evitaba.
286
+
287
+ Y escribirla antes cambia lo que se escribe: una aceptación que tiene que nombrar qué romper deja de
288
+ poder pedir algo que ninguna mutación puede tocar. Si no hay nada que romper, no había propiedad que
289
+ cuidar, y eso se ve al redactarla en vez de al final de la vuelta.
290
+
291
+ **Lo que cuesta:** el bloque de reglas que cada agente carga al arrancar pasa de **39,1 a 49,1 KB**.
292
+ Está medido, no estimado, y el umbral del aviso de `check` **no se movió**: sigue en 64 KB, porque lo
293
+ que mide es cuánto agregaste vos, y subirlo para hacerle lugar al piso apagaría justamente eso. Quedan
294
+ ~18 KB de margen antes de que el aviso hable.
295
+
296
+ ### Corregido
297
+
298
+ - **El aviso de peso mide lo que agregaste vos, no el total.** Comparaba el bloque entero contra el
299
+ umbral, así que el piso del toolkit y tus reglas salían del mismo bolsillo: decía «tu bloque pesa»
300
+ cuando la mitad la habíamos puesto nosotros, y cada regla que Cauce agregaba te achicaba el margen sin
301
+ que nadie lo decidiera. Ahora el umbral se compara contra tus reglas, y la línea dice las dos cosas —
302
+ `114.8 KB en cada agente (93.8 KB propias)`—: el total es lo que paga el agente y lo propio es lo único
303
+ sobre lo que podés hacer algo.
304
+
305
+ **Una instancia recién creada ya no puede cruzarlo**, por más que el piso crezca. Antes era una cuenta
306
+ que había que rehacer cada vez que agregábamos una regla.
307
+
308
+ El número **no se movió**: sigue en 64 KB, a propósito, porque cambiar qué se mide y cuánto a la vez
309
+ deja sin saber cuál de los dos movió el resultado. Lo que sí quedó medido es que 64 está por debajo de
310
+ lo que una empresa real usa — una instancia medida tiene 93,8 KB de reglas propias — así que elegirlo
311
+ con esa evidencia es lo que sigue.
312
+
313
+
314
+ - **Escribir tu propio `process.md` ya no te deja sin las reglas que no ibas a reemplazar.** El override
315
+ es por nombre de archivo, así que reemplazar «pensar antes de editar» por tu versión se llevaba puesto
316
+ el archivo entero: R16, R17, R20, R21 y R22 dejaban de llegarle a todo agente. `check` te lo decía —lo
317
+ hace desde 0.57.0— y no había nada que hacer al respecto, porque conservarlas exigía copiar su texto y
318
+ una copia deja de recibir las mejoras del `upgrade`.
319
+
320
+ Ahora **R16, R20, R21 y R22 viven en `system/runs.md`** —lo que cuesta una corrida, cuándo una medición
321
+ vale, cómo se retoma lo interrumpido y qué no se toca mientras se mide—, un archivo que reemplazar tu
322
+ proceso no toca. `system/process.md` se queda con R1..R4 y R17.
323
+
324
+ **No tenés que hacer nada**: el archivo nuevo llega en tu próximo `upgrade`. Si ya sobrescribiste
325
+ `process.md`, esas cuatro reglas vuelven a regir solas, y el aviso de `check` se acorta a lo que de
326
+ verdad reemplazaste. El bloque de reglas pasa de cuatro archivos a cinco y pesa lo mismo: 39,1 KB.
327
+
17
328
  ## [0.93.0] - 2026-09-16
18
329
 
19
330
  ### Corregido
@@ -650,8 +650,23 @@ while (rounds++ < MAX_TASKS) {
650
650
  )
651
651
  if (!estimate) return stop('agent-unavailable', 'Decompose no devolvió resultado')
652
652
  if (estimate.needsSplit) {
653
+ // Una tarea que viene de una épica es además una historia suya, y la lista de historias no se
654
+ // actualizaba sola: quedaba nombrando un slug que ya no existe en ninguna parte. Medido sobre un
655
+ // banco, eso rompe por los dos lados según qué escriba el agente — con las subtareas declarando
656
+ // la épica, `check` se pone en rojo en el acto («BACKLOG <sub>: no existe en epic-NNN»); sin
657
+ // declararla pasa en verde y la épica **no puede cerrar nunca**, porque `closed` exige evidencia
658
+ // de cada historia y la original jamás la va a tener.
659
+ //
660
+ // Es lo que R25 pide al decir que la unidad partida se cierra diciendo en qué se partió, y el
661
+ // lugar donde eso se dice es la épica: ahí es donde la unidad vivía. En `done/` no va —su README
662
+ // declara que es la evidencia de lo que una tarea **entregó**, y una partida no entregó nada—
663
+ // (caso 169).
664
+ const historias = task.epic
665
+ ? ` ${task.id} es una historia de la épica ${task.epic}: reemplazá también ahí su historia por `
666
+ + 'las de las subtareas, con el mismo criterio y el mismo service que traía.'
667
+ : ''
653
668
  await write(`Reemplazá sólo ${task.id} en ${BACKLOG} por subtareas ordenadas y verificables de forma ` +
654
- `independiente: ${JSON.stringify(estimate.subtasks)}.`, { label: 'split' })
669
+ `independiente: ${JSON.stringify(estimate.subtasks)}.${historias}`, { label: 'split' })
655
670
  planning = await readContext()
656
671
  if (!planning) return stop('context-unavailable', `no se pudo releer el estado de ${P}`)
657
672
  // Misma forma que en Claim: si la cola sigue ofreciendo lo mismo, el estado no cambió y repetir
@@ -661,6 +676,18 @@ while (rounds++ < MAX_TASKS) {
661
676
  return stop('split-not-applied', `se pidió reemplazar ${task.id} en ${BACKLOG} por sus `
662
677
  + 'subtareas y la cola sigue ofreciéndola: la escritura no ocurrió como se pidió.')
663
678
  }
679
+ // La tarea partida ya no existe, así que su reclamo no reserva nada: lo único que hace es dejar
680
+ // al runner ocupado por un slug que no está ni en la cola ni en lo hecho. Ahí el Claim de la
681
+ // primera subtarea se niega —«este runner ya tiene …»— y la corrida entera para con
682
+ // `claim-stuck` sin construir nada. El reclamo lo puso esta corrida; soltarlo también le toca.
683
+ //
684
+ // Va **después** de la guarda de arriba y no antes de leer el contexto: si el reemplazo no
685
+ // ocurrió, la tarea sigue viva y su reclamo tiene que seguir puesto (caso 163).
686
+ await write(
687
+ `Corré "node tools/ops.js release ${P} ${task.id}" desde ${ROOT}: quedó partida y su reserva `
688
+ + 'ya no aplica. No escribas ningún archivo vos: lo escribe el comando.',
689
+ { label: `release:${task.id}` },
690
+ )
664
691
  log(`${task.id} quedó partida: la corrida sigue con ${nextUp(planning)}`)
665
692
  continue
666
693
  }
@@ -186,6 +186,22 @@ function nextResult(root, agent, date, kind) {
186
186
  return `${date}-${run}.md`
187
187
  }
188
188
 
189
+ // Qué veredictos trae un registro, leídos en un solo lugar porque `latest` y `composed` contestan sobre
190
+ // el mismo archivo y contestaban distinto: `latest` contaba cualquier línea `- Veredicto:` y `composed`
191
+ // sólo la que cuelga de un `### <caso>`. La diferencia no es teórica — el registro transcribe la
192
+ // respuesta literal del cargo, así que una respuesta que cita un veredicto ajeno sumaba un caso que no
193
+ // existe, y `latest` decía «2 de 3» sobre una corrida que midió dos y pasó las dos.
194
+ //
195
+ // Un veredicto es un caso con su línea, nunca una línea suelta: sin el encabezado no hay a qué atribuirlo.
196
+ const VERDICT = /\n###\s+([^\n]+)\n\n-\s*Veredicto:\s*(pasa|no pasa)\s*$/gim
197
+
198
+ function caseVerdicts(text) {
199
+ return [...text.matchAll(VERDICT)].map((hit) => ({
200
+ id: hit[1].trim(),
201
+ passed: hit[2].toLowerCase() === 'pasa',
202
+ }))
203
+ }
204
+
189
205
  // El último resultado registrado, para que `evaluate` pueda decir si el cargo se corrió alguna vez y
190
206
  // cómo le fue. No es un error no tenerlo: correrlo cuesta, y exigirlo en CI sería exigir red.
191
207
  function latest(root, agent, kind) {
@@ -195,7 +211,7 @@ function latest(root, agent, kind) {
195
211
  const name = names[names.length - 1]
196
212
  const file = path.join(dir, name)
197
213
  const text = fs.readFileSync(file, 'utf8')
198
- const verdicts = [...text.matchAll(/^-\s*Veredicto:\s*(pasa|no pasa)\s*$/gim)].map((hit) => hit[1].toLowerCase())
214
+ const verdicts = caseVerdicts(text)
199
215
  const [, date, run] = name.match(RESULT_NAME)
200
216
  return {
201
217
  file,
@@ -204,7 +220,7 @@ function latest(root, agent, kind) {
204
220
  date,
205
221
  run: Number(run || 1),
206
222
  total: verdicts.length,
207
- passed: verdicts.filter((verdict) => verdict === 'pasa').length,
223
+ passed: verdicts.filter((verdict) => verdict.passed).length,
208
224
  }
209
225
  }
210
226
 
@@ -225,9 +241,8 @@ function composed(root, agent, kind) {
225
241
  for (const name of names) {
226
242
  const file = path.join(dir, name)
227
243
  const [, date] = name.match(RESULT_NAME)
228
- const pattern = /\n###\s+([^\n]+)\n\n-\s*Veredicto:\s*(pasa|no pasa)\s*$/gim
229
- for (const hit of fs.readFileSync(file, 'utf8').matchAll(pattern)) {
230
- current.set(hit[1].trim(), { passed: hit[2].toLowerCase() === 'pasa', date, file })
244
+ for (const one of caseVerdicts(fs.readFileSync(file, 'utf8'))) {
245
+ current.set(one.id, { passed: one.passed, date, file })
231
246
  }
232
247
  }
233
248
  const entries = [...current.entries()]
@@ -16,7 +16,7 @@ const path = require('node:path')
16
16
  const catalog = require('./catalog')
17
17
  const manifest = require('../core/manifest')
18
18
  const ownership = require('../core/ownership')
19
- const { atomicWrite } = require('../core/files')
19
+ const { atomicWrite, assertWithin } = require('../core/files')
20
20
 
21
21
  // Informes, propuestas y veredictos no viajan: son lo que produjo nuestra versión del contrato, y el
22
22
  // fork nace para dejar de ser ese contrato. Heredar un veredicto le daría a la copia una garantía que
@@ -80,13 +80,26 @@ function fork(root, slug, date) {
80
80
  const fromPath = `agents/${type}/system/${slug}`
81
81
  const toPath = `agents/${type}/${slug}`
82
82
  const digests = {}
83
- for (const relative of files) {
84
- const from = path.join(found.dir, relative)
85
- const to = path.join(target, relative)
86
- fs.mkdirSync(path.dirname(to), { recursive: true })
87
- if (TEXT.test(relative)) fs.writeFileSync(to, fs.readFileSync(from, 'utf8').split(fromPath).join(toPath))
88
- else fs.copyFileSync(from, to)
89
- digests[relative] = manifest.digest(from)
83
+ try {
84
+ for (const relative of files) {
85
+ const from = path.join(found.dir, relative)
86
+ const to = path.join(target, relative)
87
+ fs.mkdirSync(path.dirname(to), { recursive: true })
88
+ if (TEXT.test(relative)) fs.writeFileSync(to, fs.readFileSync(from, 'utf8').split(fromPath).join(toPath))
89
+ else fs.copyFileSync(from, to)
90
+ digests[relative] = manifest.digest(from)
91
+ }
92
+ } catch (error) {
93
+ // Lo caro de un copiado cortado no es perder la copia: es dejarla. Con medio cargo puesto el
94
+ // catálogo pasa a resolver el slug contra esa copia, así que el intento siguiente no dice «se
95
+ // cortó» sino «ya lo mantiene esta empresa» —y lo que la empresa mantiene es un contrato
96
+ // incompleto que ningún manifiesto registra, o sea que `drift` tampoco lo mira—.
97
+ //
98
+ // Se borra sólo lo que esta llamada creó: `target` se comprobó inexistente antes de empezar, y
99
+ // `assertWithin` lo nombra contra la raíz de la instancia antes de tocar nada —el slug entra por
100
+ // la línea de comandos y ésta es la única parte de este archivo que destruye—.
101
+ fs.rmSync(assertWithin(root, target, `el fork de ${slug}`), { recursive: true, force: true })
102
+ throw error
90
103
  }
91
104
 
92
105
  const version = packageVersion(root)
@@ -175,12 +175,12 @@ function pendingSources(text) {
175
175
  if (one) out.push(one)
176
176
  one = {}
177
177
  last = ''
178
- const primero = line.match(/^\s{2}-\s*(name|url|why|since):\s*(.+?)\s*$/)
179
- if (primero) { one[primero[1]] = quitar(primero[2]); last = primero[1] }
178
+ const first = line.match(/^\s{2}-\s*(name|url|why|since):\s*(.+?)\s*$/)
179
+ if (first) { one[first[1]] = quitar(first[2]); last = first[1] }
180
180
  continue
181
181
  }
182
- const campo = line.match(PENDING_FIELD)
183
- if (campo && one) { one[campo[1]] = quitar(campo[2]); last = campo[1]; continue }
182
+ const field = line.match(PENDING_FIELD)
183
+ if (field && one) { one[field[1]] = quitar(field[2]); last = field[1]; continue }
184
184
  const sigue = line.match(/^\s{6,}(\S.*?)\s*$/)
185
185
  if (sigue && one && last) one[last] += ` ${sigue[1]}`
186
186
  }
@@ -229,9 +229,9 @@ function evaluate(root, agent) {
229
229
  // Una pendiente sin razón es un enlace muerto con fecha, y sin fecha no se puede ver que lleva
230
230
  // meses ahí. Los dos campos son la mitad del valor de la lista: lo que la vuelve revisable.
231
231
  for (const one of pendingSources(fs.readFileSync(sourcesFile, 'utf8'))) {
232
- const falta = ['name', 'why', 'since'].filter((campo) => !one[campo])
233
- if (falta.length) {
234
- errors.push(`sources.yaml: una pendiente no declara ${falta.join(' ni ')}`
232
+ const missing = ['name', 'why', 'since'].filter((key) => !one[key])
233
+ if (missing.length) {
234
+ errors.push(`sources.yaml: una pendiente no declara ${missing.join(' ni ')}`
235
235
  + `${one.name ? ` (${one.name})` : ''}`)
236
236
  }
237
237
  // Declarada y pendiente a la vez es una contradicción que el chequeo semanal no puede resolver:
@@ -18,16 +18,25 @@ const { SOURCE_TIERS, cadence, evaluate, evaluateTeam } = require('./learning-so
18
18
  // consolidó y el que se escribió tarde —después de que la propuesta del período ya existía, y por eso
19
19
  // no entra a ninguna— se leían igual. El segundo no es un descuido de forma: es un hallazgo que no
20
20
  // llega al contrato y que nada delata. Marcar al primero es lo que deja ver al segundo.
21
+ // El sello se busca **dentro del frontmatter** y no en el documento entero. Un registro de corrida nace
22
+ // sin `status` ahí y su cuerpo lleva la respuesta verbatim del cargo: si esa respuesta traía una línea
23
+ // `status: <palabra>` a columna cero, el sello caía en el cuerpo y el frontmatter quedaba sin marcar —o
24
+ // sea el registro sin sellar, y el mismo hallazgo entrando a la propuesta siguiente, que es exactamente
25
+ // lo que sellar existe para evitar (caso 168).
26
+ const FRONTMATTER = /^---\n([\s\S]*?)\n---\n/
27
+ const STATUS_LINE = /^status:\s*\S+\s*$/m
28
+
21
29
  function markConsolidated(file) {
22
30
  const text = fs.readFileSync(file, 'utf8')
23
- if (/^status:\s*\S+\s*$/m.test(text)) {
24
- return atomicWrite(file, text.replace(/^status:\s*\S+\s*$/m, 'status: consolidated'))
31
+ const front = text.match(FRONTMATTER)
32
+ if (front && STATUS_LINE.test(front[1])) {
33
+ return atomicWrite(file, text.replace(front[0],
34
+ `---\n${front[1].replace(STATUS_LINE, 'status: consolidated')}\n---\n`))
25
35
  }
26
36
  // Un informe nace con `status`; un registro de corrida no, porque lo escribe el recorrido de
27
37
  // evaluación y ahí el dato no existía. Se agrega en vez de exigirle a quien lo escriba que se
28
38
  // acuerde: el sello es lo que evita que el mismo hallazgo entre dos veces, y depender de una
29
39
  // convención para eso es depender de que nadie la olvide.
30
- const front = text.match(/^---\n([\s\S]*?)\n---\n/)
31
40
  if (!front) return
32
41
  atomicWrite(file, text.replace(front[0], `---\n${front[1]}\nstatus: consolidated\n---\n`))
33
42
  }
@@ -206,9 +215,9 @@ function verdictFindings(root, dir) {
206
215
  // encabezado, así que uno que escape puede hacer que se lea la sección equivocada.
207
216
  const nested = (detail) => detail.replace(/^(#{1,5}) /gm, '#$1 ')
208
217
  const findings = [...latest.values()].flatMap((item) => {
209
- const corrida = `Corrida: \`${path.relative(root, item.file)}\``
218
+ const runLine = `Corrida: \`${path.relative(root, item.file)}\``
210
219
  if (!item.passed) {
211
- return [`### ${item.id} — ${item.name.slice(0, -3)}\n\n${corrida}`
220
+ return [`### ${item.id} — ${item.name.slice(0, -3)}\n\n${runLine}`
212
221
  + `${item.failures > 1 ? ` — falló en ${item.failures} corridas de esta tanda` : ''}\n\n`
213
222
  + `${nested(item.detail)}`]
214
223
  }
@@ -222,7 +231,7 @@ function verdictFindings(root, dir) {
222
231
  // y ahí una línea con este prefijo sería una nota que el sujeto se escribe a sí mismo.
223
232
  const note = (item.detail.split('\n', 1)[0].match(CONTRACT_NOTE) || [])[1]
224
233
  return note
225
- ? [`### ${item.id} — ${item.name.slice(0, -3)} · el caso pasa\n\n${corrida}\n\n`
234
+ ? [`### ${item.id} — ${item.name.slice(0, -3)} · el caso pasa\n\n${runLine}\n\n`
226
235
  + `Lo que el contrato no cubre: ${note.trim()}`]
227
236
  : []
228
237
  })
@@ -52,9 +52,9 @@ function check(root) {
52
52
  }
53
53
  // Un choque que `upgrade` conservó (caso 110) también queda distinto del paquete, y mandarlo a correr
54
54
  // `upgrade` era una vuelta sin salida: lo conservaría otra vez. Se dice qué es y qué hacer.
55
- const choques = new Set(O.collisions(root))
55
+ const collisions = new Set(O.collisions(root))
56
56
  for (const { file, edited } of staleHooks(root)) {
57
- if (choques.has(`automatization/hooks/${file}`)) {
57
+ if (collisions.has(`automatization/hooks/${file}`)) {
58
58
  errors.push(`automatization/hooks/${file}: es tuyo y se llama como uno que trae el paquete, así que el `
59
59
  + "del paquete no está instalado; renombrá el tuyo y corré `cauce upgrade`")
60
60
  continue
@@ -173,16 +173,16 @@ function isSharedFile(root, target) {
173
173
  }
174
174
 
175
175
  function withoutBlock(text, name) {
176
- const sourceRoot = text.indexOf(blockStart(name))
177
- if (sourceRoot === -1) return text
178
- const until = text.indexOf(blockEnd(name), sourceRoot)
176
+ const from = text.indexOf(blockStart(name))
177
+ if (from === -1) return text
178
+ const until = text.indexOf(blockEnd(name), from)
179
179
  if (until === -1) return text
180
- return `${text.slice(0, sourceRoot)}${text.slice(until + blockEnd(name).length)}`.trimEnd()
180
+ return `${text.slice(0, from)}${text.slice(until + blockEnd(name).length)}`.trimEnd()
181
181
  }
182
182
 
183
183
  function mergeInstruction(file, name, content) {
184
- const actual = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
185
- const instructionBody = withoutBlock(actual, name).trimEnd()
184
+ const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
185
+ const instructionBody = withoutBlock(current, name).trimEnd()
186
186
  const block = `${blockStart(name)}\n\n${content.trim()}\n\n${blockEnd(name)}\n`
187
187
  F.atomicWrite(file, instructionBody ? `${instructionBody}\n\n${block}` : block)
188
188
  }
@@ -190,10 +190,10 @@ function mergeInstruction(file, name, content) {
190
190
  function blockUpToDate(file, name, content) {
191
191
  if (!fs.existsSync(file)) return false
192
192
  const body = fs.readFileSync(file, 'utf8')
193
- const sourceRoot = body.indexOf(blockStart(name))
193
+ const from = body.indexOf(blockStart(name))
194
194
  const until = body.indexOf(blockEnd(name))
195
- if (sourceRoot === -1 || until === -1) return false
196
- return body.slice(sourceRoot + blockStart(name).length, until).trim() === content.trim()
195
+ if (from === -1 || until === -1) return false
196
+ return body.slice(from + blockStart(name).length, until).trim() === content.trim()
197
197
  }
198
198
 
199
199
  module.exports = {