@ingeniomaps/cauce 0.94.0 → 0.96.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 (43) hide show
  1. package/CHANGELOG.md +238 -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 +8 -2
  8. package/engine/automation/config.js +9 -9
  9. package/engine/automation/index.js +19 -8
  10. package/engine/cli/archive.js +3 -6
  11. package/engine/cli/args.js +16 -2
  12. package/engine/cli/bench.js +9 -9
  13. package/engine/cli/catalog.js +36 -17
  14. package/engine/cli/claims.js +30 -32
  15. package/engine/cli/contract.js +29 -11
  16. package/engine/cli/dependency.js +2 -2
  17. package/engine/cli/instance.js +25 -24
  18. package/engine/cli/io.js +26 -4
  19. package/engine/cli/ops.js +24 -15
  20. package/engine/cli/planning.js +8 -8
  21. package/engine/cli/upgrade-report.js +10 -10
  22. package/engine/cli/validate.js +9 -9
  23. package/engine/cli/wiring.js +16 -16
  24. package/engine/cli/worktree.js +8 -8
  25. package/engine/core/onboarding.js +4 -1
  26. package/engine/core/ownership.js +9 -2
  27. package/engine/core/repos.js +18 -12
  28. package/engine/hooks/approval.js +9 -1
  29. package/engine/hooks/chat.js +27 -5
  30. package/engine/hooks/files.js +6 -6
  31. package/engine/hooks/input.js +5 -2
  32. package/engine/hooks/run.js +3 -3
  33. package/engine/hooks/shell.js +20 -6
  34. package/engine/hooks/verify.js +7 -7
  35. package/engine/integrations/proposals.js +5 -1
  36. package/engine/integrations/registry.js +7 -0
  37. package/engine/integrations/state.js +6 -3
  38. package/engine/planning/adoption.js +2 -2
  39. package/engine/planning/claims.js +8 -8
  40. package/engine/planning/parser.js +8 -8
  41. package/engine/planning/state.js +2 -2
  42. package/engine/planning/structure.js +7 -7
  43. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -14,6 +14,244 @@ 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.96.0] - 2026-09-16
18
+
19
+ ### Corregido
20
+
21
+ - **Tres errores que el CLI contestaba con exit 1 ahora contestan 2, que es lo que dice la convención.**
22
+ Son «no se llegó a la pregunta»: `integrations/config.json` ilegible, un proveedor que no está en ese
23
+ registro, y un `package.json` inválido al declarar el motor. Sus hermanos —un `ops.config.json`
24
+ ilegible, un proveedor que Cauce no trae— ya contestaban 2.
25
+
26
+ El corte, que hasta ahora estaba en la cabeza de quien escribía cada salida, está escrito y sale en
27
+ `ops --help`: **2** es que el comando no existe, falta un argumento, o la raíz que nombrás no es lo que
28
+ dice ser —lo arreglás cambiando la invocación—; **1** es que se llegó y la respuesta es que no —una
29
+ validación encontró problemas, el estado se niega, o una operación falló a mitad de camino—.
30
+
31
+ Si tenés un script que distingue los dos, revisá esos tres casos. Si sólo mira «distinto de cero», no
32
+ cambia nada.
33
+
34
+ - **`ops flow list|check|show` acepta la raíz de la instancia, como el resto.** Todos los comandos que leen
35
+ una instancia la toman como posicional; `flow` la descartaba y resolvía por el directorio actual, así que
36
+ `ops flow list <raíz>` contestaba sobre otra cosa — y una lista de recorridos se lee igual de bien venga
37
+ de donde venga. Ahora es `ops flow list [ops-root]`, y con recorrido va después de él.
38
+
39
+ - **Una lista de cargos o recorridos vacía dice si es porque no hay o porque no se pudo resolver el
40
+ paquete.** Eran dos hechos distintos con la misma respuesta, y las acciones son opuestas. El aviso sale
41
+ por `stderr` y sólo cuando la lista viene vacía; el código de salida y el `--json` no cambian, porque
42
+ ese JSON lo consume el cron del ciclo de aprendizaje.
43
+
44
+ - **`automation check` nombra un `ops.config.json` ilegible en vez de culpar al motor.** La resolución del
45
+ paquete cuelga de esa configuración, así que un JSON roto se propagaba como archivos del motor que faltan
46
+ y el aviso mandaba a correr `npm install` sobre un motor instalado: la acción sugerida no arreglaba nada.
47
+
48
+ - **El catálogo de cargos y recorridos se encuentra también cuando el paquete vive un nivel arriba.** Es el
49
+ layout que documenta el arranque —`npm install @ingeniomaps/cauce` en la carpeta de la empresa y la
50
+ instancia adentro—, y 0.92.0 lo arregló para el motor y dejó afuera el catálogo. En ese estado `check`
51
+ pasaba en verde y `agents list` contestaba **una lista vacía con exit 0**, indistinguible de «esta
52
+ instancia no tiene cargos»; `flow list` igual, y `evaluate` culpaba a un `SKILL.md` que falta, mandando
53
+ a mirar el cargo en vez del paquete.
54
+
55
+ Si te pasó, no hacía falta bajar una segunda copia: con esta versión los cargos aparecen donde ya estaban.
56
+ Verificado instalando el paquete publicado desde el registro y recorriendo la superficie del CLI: de 0
57
+ cargos y 0 recorridos a **53 y 7**, sobre la misma instancia.
58
+
59
+ ## [0.95.0] - 2026-09-16
60
+
61
+ ### Corregido
62
+
63
+ - **El bloqueo dice hasta dónde llega el «dale».** Ofrece dos salidas y sólo la del pegado decía su
64
+ alcance —«valen para ese conjunto y dejan de valer en cuanto cambie»—; la del chat, que es la que se
65
+ ofrece primero, decía sólo «reintentá el mismo cambio y pasa».
66
+
67
+ Faltaban **dos** cosas y no son la misma. Que el «dale» cubre lo que se frenó y nada más: por eso en una
68
+ carpeta donde se itera cada archivo nuevo vuelve a frenar, que desde afuera se lee como si el guard se
69
+ hubiera olvidado de que ya la aprobaste. Y que lo concedido **sigue valiendo en los mensajes
70
+ siguientes** hasta que lo niegues — la mitad permisiva, la que no se nota porque lo que no ocurre es un
71
+ bloqueo. `check` la muestra al final de la corrida; ahora también se dice al concederla.
72
+
73
+ No cambia ningún permiso: el diff es texto.
74
+
75
+ - **Partir una tarea de una épica actualiza también sus historias, así que `check` no queda en rojo.** Una
76
+ tarea que viene de una épica es además una historia suya, y la partición reemplazaba sólo su línea del
77
+ BACKLOG. La lista de historias quedaba nombrando un slug que ya no existe en ninguna parte, y eso rompe
78
+ por los dos lados según qué escriba la partición: con las subtareas declarando la épica, `check` falla en
79
+ el acto con «BACKLOG \<sub\>: no existe en epic-NNN»; sin declararla pasa en verde y **la épica no puede
80
+ cerrar nunca**, porque `closed` exige evidencia de cada historia y la original jamás la va a tener.
81
+
82
+ Ahí es donde la unidad partida se cierra diciendo en qué se partió, que es lo que pide R25. **No va una
83
+ entrada en `done/`**: ese directorio es la evidencia de lo que una tarea entregó, y una partida no
84
+ entregó nada. Una tarea sin épica no necesita ningún cierre — deja de existir limpiamente y no hay
85
+ cruce que se rompa.
86
+
87
+ Si tenés una épica con una historia que ninguna tarea de la cola nombra y que nunca vas a poder cerrar,
88
+ viene de esto: reemplazá esa historia por las de las subtareas que la partición dejó en el BACKLOG.
89
+
90
+ - **Un límite escrito en dos líneas ya viaja entero a los agentes.** Una viñeta bajo `### Límites` se
91
+ 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
92
+ escribe cualquiera de verdad— llegaba **sólo la primera línea**, cortada a mitad de frase. Y lo que se
93
+ pierde ahí suele ser lo que el límite decide: «…crear, editar o borrar productos, pedidos o» sin el «no
94
+ las hace un runner» que venía abajo.
95
+
96
+ La continuación, además, volvía por el otro lado: no coincidía con ninguna viñeta declarada y `check` la
97
+ reportaba como un párrafo «que no llega a los agentes», citando media frase tuya. O sea que el proyecto
98
+ hacía lo que el aviso pedía, el aviso seguía sonando, y el límite que sí viajaba era el mutilado.
99
+
100
+ Si escribiste tus límites en una sola línea para que el aviso bajara a cero, ya podés volver a partirlos
101
+ donde corresponda. Una línea en blanco cierra la viñeta, así que la prosa que venga después sigue sin
102
+ contar como límite — y sigue avisándose.
103
+
104
+ - **Una notificación de tarea de fondo ya no borra del chat a la persona que está mirando.** Cuando un
105
+ guard frena algo, el bloqueo ofrece dos salidas: contestar «dale» —corto, y es lo que la persona ya está
106
+ haciendo— o pegar líneas a mano en `planning/.ops-approval`. La primera sólo se ofrecía si el registro
107
+ de la sesión decía que había alguien.
108
+
109
+ Una notificación de una tarea de fondo entra por el mismo hook que un mensaje, así que el registro
110
+ pasaba a decir que el último que habló no era una persona — y en el turno que esa notificación despierta
111
+ **la persona sigue ahí**. El bloqueo le ofrecía la salida cara y terminaba copiando y pegando un comando
112
+ que no hacía falta. Se disparaba solo en el patrón más común de una sesión larga: lanzar trabajo de
113
+ fondo y retomar cuando vuelve.
114
+
115
+ Que haya alguien a quien preguntarle pasa a ser de la sesión y no del mensaje. **No cambia ninguna
116
+ autorización**: preguntar no es conceder, y lo que decide si un «dale» viejo cubre algo nuevo quedó
117
+ intacto — un mensaje de hace tres turnos sigue sin autorizar lo que se frenó ahora. Y un recorrido de
118
+ Cauce sigue sin ofrecer el «dale», porque ahí no hay nadie leyendo.
119
+
120
+ - **Una tarea que `autobuild` parte ya suelta su reserva, así que la corrida sigue.** El recorrido reclama
121
+ la tarea antes de estimarla, y cuando Decompose la partía el reclamo quedaba puesto sobre un slug que ya
122
+ no estaba ni en la cola ni en lo hecho. Con eso el runner quedaba ocupado por una tarea que no existe: el
123
+ Claim de la primera subtarea se negaba —«este runner ya tiene …»— y la corrida entera terminaba en
124
+ `claim-stuck` **sin construir nada**, dejando además `check` en rojo con un reclamo huérfano que soltaba
125
+ una persona a mano.
126
+
127
+ No dependía de una carrera ni de un id de runner compartido, que es a lo que el mensaje mandaba a mirar:
128
+ pasaba siempre que Decompose partiera, que es una fase que el propio protocolo manda correr. Si venís de
129
+ una versión anterior y tenés un reclamo huérfano de esto, se suelta con
130
+ `node tools/ops.js release planning <tarea>`.
131
+
132
+ La reserva se suelta **después** de comprobar que el reemplazo en el BACKLOG ocurrió: si no ocurrió, la
133
+ tarea sigue viva y su reserva tiene que seguir puesta.
134
+
135
+ - **Un veredicto se lee de una sola forma, así que las dos cuentas de un registro coinciden.** El registro
136
+ de una evaluación transcribe la respuesta literal del cargo —es la evidencia de la corrida—, así que su
137
+ cuerpo puede traer cualquier cosa, incluida una línea con forma de veredicto. La cuenta de la última
138
+ corrida las tomaba todas y la cuenta compuesta sólo las que cuelgan de un `### <caso>`: dos números
139
+ distintos sobre el mismo archivo y ninguna forma de saber cuál valía.
140
+
141
+ Con un registro de dos casos cuya respuesta citaba un veredicto ajeno, la última corrida decía «2 de 3»
142
+ sobre una corrida que midió dos y pasó las dos. Ahora un veredicto es un caso con su línea —sin
143
+ encabezado no hay a qué atribuirlo— y las dos lecturas salen del mismo lugar.
144
+
145
+ - **Un `agents fork` que se corta a la mitad se retira en vez de quedarse puesto.** Si el copiado fallaba
146
+ —un archivo ilegible, disco lleno— quedaba medio cargo en `agents/`, y lo caro no es perder la copia: es
147
+ dejarla. El catálogo pasa a resolver el slug contra esa copia, así que el intento siguiente ya no dice
148
+ que se cortó sino **«ya lo mantiene esta empresa»** — y lo que la empresa mantiene es un contrato
149
+ incompleto que ningún manifiesto registra, o sea que el aviso de deriva tampoco lo mira nunca.
150
+
151
+ Verificado provocando el corte con un archivo ilegible: antes el segundo intento contestaba «ya lo
152
+ mantiene esta empresa» con `.cauce` vacío; ahora no queda nada en `agents/` y arreglando lo que lo
153
+ cortó el fork sale entero. Sólo se borra lo que esa llamada creó, y la ruta se comprueba contra la raíz
154
+ de la instancia antes de tocarla.
155
+
156
+ - **`automation uninstall` no borra nada si no puede leer la configuración de tu runner.** Borraba
157
+ primero y leía el `settings.json` al final, con un `JSON.parse` sin proteger. Con un archivo que alguien
158
+ dejó a medio fusionar, el comando moría con «Expected property name or '}' in JSON at position 13»
159
+ —que no nombra ni un archivo— **después** de haber borrado los workflows y los cargos: la instancia
160
+ quedaba a medio desinstalar, con la configuración registrando guards que ya no existen.
161
+
162
+ Verificado sobre un banco: el comando se para antes de tocar el disco, dice qué archivo no pudo leer, y
163
+ arreglando ese archivo vuelve a correr.
164
+
165
+ - **Un adaptador propio que no trae `components` ya no rompe el borrador.** El README declara el contrato
166
+ de un adaptador por sus tres funciones y no dice qué campos trae un item, así que un adaptador de la
167
+ empresa —que es lo que ese README invita a escribir— puede no traerlo. Con `serviceFrom: "component"`,
168
+ que es lo que trae el molde, eso era un `TypeError` sobre `undefined` sin nada que lo atribuyera.
169
+
170
+ Sin el campo la respuesta es la que el borrador ya sabía dar: `service: ""` y «Debe definirse un
171
+ servicio único para la promoción». Lo que se toleró es la ausencia y no el caso de uso: con un
172
+ componente sigue resolviendo el servicio y con dos sigue sin resolverlo.
173
+
174
+ - **`promotionEpic` se valida también cuando la promoción es una épica.** El campo se exigía `NNN` sólo
175
+ para una historia. Para una épica es opcional —sin él el número lo elige el motor—, y por eso nadie lo
176
+ miraba; pero cuando está se usa tal cual para armar el nombre del archivo. O sea que el único caso sin
177
+ validar era el único que escribe una ruta con lo que diga el campo.
178
+
179
+ `promotionEpic: 7` producía `epic-7-…`, que el roadmap no reconoce como épica: la promoción se anunciaba
180
+ bien y lo escrito quedaba invisible para la cola. Y con un `..` la épica salía de `roadmap/` entera.
181
+ Verificado sobre una instancia desechable: `integration check` daba exit 0 y `integration promote`
182
+ devolvía «✓ jira:DEMO-42 promovido como epic» dejando el archivo fuera del roadmap. Ahora `check` lo
183
+ rechaza con «promotionEpic debe ser NNN» y `promote`, que valida antes de escribir, no llega a tocar el
184
+ disco.
185
+
186
+ - **El aviso de commits sin entrada de DONE vuelve a decir lo que ve.** El filtro por fecha leía la
187
+ columna del `git log` en una posición fija, y `%h` no mide siempre lo mismo: git sube el largo del hash
188
+ abreviado solo cuando el repositorio crece. En cuanto pasa de siete, la posición fija lee un espacio en
189
+ vez de la fecha y **ningún commit pasa el filtro**, así que el aviso quedaba mudo sin decir por qué —la
190
+ peor forma de que una puerta falle, porque se lee igual que «no hay nada que avisar»—. Ahora se parte
191
+ por espacios y se lee el campo.
192
+
193
+ Verificado en este repositorio, donde el abreviado mide ocho: el aviso pasó de cero a «66 commit(s)
194
+ desde 2026-09-15 que ninguna entrada de DONE nombra». La prueba nueva lo fija en 7, 8 y 12.
195
+
196
+ - **El sello de un registro de evaluación se escribe en el frontmatter y no en el cuerpo.**
197
+ `markConsolidated` buscaba `status:` en el documento entero. Un registro nace sin `status` en su
198
+ frontmatter y su cuerpo transcribe la respuesta literal del cargo: si esa respuesta traía una línea
199
+ `status: <algo>` en la columna cero, el sello aterrizaba ahí y el frontmatter quedaba sin sellar.
200
+
201
+ Un registro sin sellar vuelve a entrar en la propuesta siguiente, o sea que el mismo hallazgo llega dos
202
+ veces — que es exactamente lo que sellar existe para evitar.
203
+
204
+ - **`ops -h` pide la ayuda, y un nombre heredado de `Object` ya no pasa por comando.** Dos huecos de la
205
+ puerta de entrada del CLI, los dos de la misma forma: leer algo que no era ni un comando ni una bandera.
206
+
207
+ `-h` se anunciaba en el uso y no existía: el parser sólo reconoce lo que empieza con `--`, así que caía
208
+ de argumento posicional y `ops check -h` contestaba «no existe el planning en …/-h» **saliendo con 0**.
209
+ Pedir ayuda y recibir un error sobre un directorio inventado es la peor forma de contestar, porque
210
+ parece que el comando corrió.
211
+
212
+ Y la tabla de comandos se consultaba con `FLAGS[comando]`, que resuelve contra `Object.prototype`:
213
+ `ops constructor` salía con 0 sin hacer nada y `ops toString --json` moría con
214
+ «FLAGS[command].includes is not a function». No es un nombre exótico — es lo que sale de pasarle al CLI
215
+ una variable que vino vacía o mal leída desde un script.
216
+
217
+ - **El tope de horas de una propuesta deja de desaparecer cuando la configuración no lo declara.** La
218
+ validación de propuestas lee `runner.maxTaskHours` de `ops.config.json` y tenía un default de cuatro
219
+ horas para cuando no se puede leer. Ese default sólo cubría el archivo ilegible: un `runner` sin el
220
+ campo no lanza nada, así que el tope quedaba en `undefined` y la comparación pasaba a ser falsa
221
+ siempre. O sea que el límite no se aflojaba: se iba entero, y en silencio.
222
+
223
+ Se nota poco a propósito de lo que es — lo que deja de pasar es un **rechazo**—, así que una propuesta
224
+ de cuarenta horas viajaba a Jira sin que nada la mirara. Verificado sobre una configuración con
225
+ `runner: {}`: antes no salía ni un error, ahora sale «supera 4h; debe dividirse o justificarlo».
226
+
227
+ Si tu `ops.config.json` declara `maxTaskHours`, nada cambia — `check` ya lo exigía mayor que cero.
228
+
229
+ - **`rm -r` sobre la raíz o el home se frena también entrecomillado.** La regla reconocía el destino sólo
230
+ desnudo: `rm -rf /` bloqueaba y `rm -rf "/"`, `rm -rf "$HOME"`, `rm -rf ${HOME}`, `rm -rf $HOME/` y
231
+ `rm -rf -- /` pasaban. La comilla es lo que más importa — citar una variable es la forma *correcta* de
232
+ escribirlo en bash, así que el hueco premiaba al que tiene el hábito bueno.
233
+
234
+ Los borrados con destino concreto siguen pasando, también entrecomillados: se comprobó con
235
+ `rm -rf "$HOME/proyecto/dist"` entre otros cinco.
236
+
237
+ - **Un commit escrito en dos líneas vuelve a pasar por los guards.** `isCommit` reconocía el commit
238
+ después de `;`, `&` o `|` y no después de un salto de línea ni dentro de un subshell — o sea que
239
+ `git add x` ⏎ `git commit -m x` en un solo Bash, que es como se escriben dos pasos, dejaba sin correr
240
+ `governance`, `dependencies` y `verify`, y también el bloqueo que impide stagear y commitear a la vez.
241
+
242
+ `bash -c "git commit …"` sigue sin reconocerse, a propósito: hacerlo exige desentrecomillar, y eso
243
+ choca con que el mensaje de un commit sea dato y no código.
244
+
245
+ - **Redirigir con `>|` ya no evade el guard que mira a dónde escribe un comando.** `>|` es el override de
246
+ `noclobber` y escribe igual que `>`, pero el guard no veía su destino: el comando se partía en segmentos
247
+ por `|` antes de buscar destinos, así que la redirección quedaba separada de su ruta. Verificado sobre
248
+ un banco: `echo x > <fuera>` y `echo x >> <fuera>` bloqueaban y `echo x >| <fuera>` pasaba.
249
+
250
+ Importa porque una de las rutas que se escriben por ese hueco es `planning/.ops-approval` — la
251
+ aprobación que después habilita el push a la rama viva—, que es lo que cerraron el 098 y el 119.
252
+
253
+ Las tuberías corrientes siguen sin producir destino: se comprobó con `ls | wc -l` y `a || b`.
254
+
17
255
  ## [0.94.0] - 2026-09-16
18
256
 
19
257
  ### Agregado
@@ -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
  })
@@ -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')
@@ -52,9 +58,9 @@ function check(root) {
52
58
  }
53
59
  // Un choque que `upgrade` conservó (caso 110) también queda distinto del paquete, y mandarlo a correr
54
60
  // `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))
61
+ const collisions = new Set(O.collisions(root))
56
62
  for (const { file, edited } of staleHooks(root)) {
57
- if (choques.has(`automatization/hooks/${file}`)) {
63
+ if (collisions.has(`automatization/hooks/${file}`)) {
58
64
  errors.push(`automatization/hooks/${file}: es tuyo y se llama como uno que trae el paquete, así que el `
59
65
  + "del paquete no está instalado; renombrá el tuyo y corré `cauce upgrade`")
60
66
  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 = {
@@ -45,9 +45,9 @@ function probeBridge(paths, runner) {
45
45
  const problems = []
46
46
  // Desde la raíz y desde una carpeta de adentro: si el runner lanza el hook con otro cwd, la ruta
47
47
  // relativa de su configuración deja de resolver y eso hay que verlo acá, no en la primera sesión.
48
- const sourceRoot = [paths.install, path.dirname(script)]
48
+ const launchDirs = [paths.install, path.dirname(script)]
49
49
  for (const event of hookEvents) {
50
- for (const cwd of sourceRoot) {
50
+ for (const cwd of launchDirs) {
51
51
  const payload = JSON.stringify({ toolCall: { args: { CommandLine: 'ls', Cwd: paths.install } } })
52
52
  const result = spawnSync(process.execPath, [script, event], { cwd, input: payload, encoding: 'utf8' })
53
53
  let response = {}
@@ -75,11 +75,11 @@ function doctor(root, name, output = console) {
75
75
  const warnings = []
76
76
  try {
77
77
  const expected = runnerConfig(paths, root)
78
- const actual = JSON.parse(fs.readFileSync(paths.configTarget, 'utf8'))
79
- if (!includesConfig(actual, expected)) {
78
+ const installed = JSON.parse(fs.readFileSync(paths.configTarget, 'utf8'))
79
+ if (!includesConfig(installed, expected)) {
80
80
  errors.push(`${runner.config.target}: configuración instalada incompleta o divergente`)
81
81
  }
82
- const legacy = legacyGuardWiring(actual)
82
+ const legacy = legacyGuardWiring(installed)
83
83
  if (legacy.length) {
84
84
  warnings.push(`${runner.config.target}: ${legacy.join(', ')} siguen registrados sueltos junto al grupo; `
85
85
  + 'cada uno se ejecuta dos veces. Borrá esas entradas del archivo para quedarte sólo con el grupo')
@@ -221,6 +221,18 @@ function uninstall(root, name, output = console) {
221
221
  }
222
222
  const runner = runnerManifest(root, name)
223
223
  const paths = runnerPaths(root, name, runner)
224
+ // La configuración se lee **antes** de borrar nada, aunque se use al final. Leyéndola al final, un
225
+ // `settings.json` a medio fusionar hacía morir el comando con el mensaje crudo del parser —que no
226
+ // nombra ni un archivo— con los workflows y los cargos ya borrados: la instancia quedaba a medio
227
+ // desinstalar, con la configuración registrando guards que no existen. Acá no hay nada que deshacer
228
+ // porque todavía no se tocó el disco, que es más barato que cualquier rollback.
229
+ const hasConfig = fs.existsSync(paths.configTarget)
230
+ let config
231
+ if (hasConfig) {
232
+ try { config = JSON.parse(fs.readFileSync(paths.configTarget, 'utf8')) } catch (error) {
233
+ throw new Error(`${runner.config.target} contiene JSON inválido (${error.message})`)
234
+ }
235
+ }
224
236
  const prefix = opsPrefix(root)
225
237
  const recorded = M.readRunners(root)
226
238
  const kept = []
@@ -266,9 +278,8 @@ function uninstall(root, name, output = console) {
266
278
  }
267
279
  }
268
280
 
269
- if (fs.existsSync(paths.configTarget)) {
270
- const current = JSON.parse(fs.readFileSync(paths.configTarget, 'utf8'))
271
- const clean = unmergeConfig(unmergeConfig(current, runnerConfig(paths, root)), runner.config.retired || {})
281
+ if (hasConfig) {
282
+ const clean = unmergeConfig(unmergeConfig(config, runnerConfig(paths, root)), runner.config.retired || {})
272
283
  if (clean && Object.keys(clean).length) F.atomicWriteJson(paths.configTarget, clean)
273
284
  else { removeFile(paths.configTarget, paths.install); removed += 1 }
274
285
  output.log(`✓ ${name}: ${runner.config.target} sin las entradas de Cauce`)
@@ -11,10 +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 } = require('./io')
15
-
16
- // La fecha de hoy, la misma que usan los comandos que leen.
17
- const TODAY = () => new Date().toISOString().slice(0, 10)
14
+ const { fail, planningRoot, TODAY, USAGE, REFUSED } = require('./io')
18
15
 
19
16
  // El historial de acciones humanas se acumula en un solo archivo y no por épica: una fila no pertenece
20
17
  // a ninguna, y esperar el cierre de una épica dejaría sin archivar las de un planning que todavía no
@@ -33,7 +30,7 @@ function adopt(dir) {
33
30
  const existing = fs.readFileSync(target, 'utf8')
34
31
  if (!AD.sealWarnings(root).some((one) => /sin huella/.test(one))) {
35
32
  fail(`${AD.BASELINE} ya existe: se genera una vez. Para retirar un renglón, ponele \`#~\` `
36
- + 'delante; `check` marca los que ya cumplen.')
33
+ + 'delante; `check` marca los que ya cumplen.', REFUSED)
37
34
  }
38
35
  const slugs = AD.declared(existing)
39
36
  F.atomicWrite(target, existing.replace(/\n?$/, `\n# huella: ${slugs.length} entradas · `
@@ -81,7 +78,7 @@ function archiveHumanActions(root) {
81
78
  function archive(dir, rawNum) {
82
79
  if (String(rawNum || '') === 'human-actions') return archiveHumanActions(planningRoot(dir))
83
80
  return fail('Sólo se archiva `human-actions`. La evidencia de una tarea ya vive en su propio archivo '
84
- + '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)
85
82
  }
86
83
 
87
84
  module.exports = { archive, adopt }