@saulwade/swl-ses 2.5.3 → 2.6.1

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 (214) hide show
  1. package/CLAUDE.md +9 -9
  2. package/README.md +37 -37
  3. package/agentes/_intent-spec.md +73 -73
  4. package/agentes/_propose-step.md +90 -90
  5. package/agentes/accesibilidad-wcag-swl.md +690 -690
  6. package/agentes/arquitecto-swl.md +267 -267
  7. package/agentes/auto-evolucion-swl.md +932 -908
  8. package/agentes/backend-csharp-swl.md +420 -420
  9. package/agentes/backend-go-swl.md +390 -390
  10. package/agentes/backend-java-swl.md +281 -281
  11. package/agentes/backend-rust-swl.md +364 -364
  12. package/agentes/backend-workers-swl.md +482 -482
  13. package/agentes/cloud-infra-swl.md +509 -509
  14. package/agentes/consolidador-swl.md +541 -541
  15. package/agentes/depurador-swl.md +352 -352
  16. package/agentes/devops-ci-swl.md +400 -400
  17. package/agentes/disenador-ui-swl.md +569 -569
  18. package/agentes/documentador-swl.md +345 -345
  19. package/agentes/frontend-angular-swl.md +621 -621
  20. package/agentes/frontend-css-swl.md +716 -716
  21. package/agentes/frontend-react-swl.md +692 -692
  22. package/agentes/frontend-swl.md +496 -496
  23. package/agentes/frontend-tailwind-swl.md +826 -826
  24. package/agentes/investigador-swl.md +432 -432
  25. package/agentes/investigador-ux-swl.md +505 -505
  26. package/agentes/migrador-swl.md +442 -442
  27. package/agentes/mobile-android-swl.md +511 -511
  28. package/agentes/mobile-cross-swl.md +541 -541
  29. package/agentes/mobile-ios-swl.md +502 -502
  30. package/agentes/mobile-testing-swl.md +302 -302
  31. package/agentes/nemesis-auditor-swl.md +285 -285
  32. package/agentes/observabilidad-swl.md +438 -438
  33. package/agentes/pagos-swl.md +310 -310
  34. package/agentes/perfilador-usuario-swl.md +321 -321
  35. package/agentes/planificador-swl.md +399 -399
  36. package/agentes/producto-prd-swl.md +589 -589
  37. package/agentes/red-team-swl.md +218 -218
  38. package/agentes/release-manager-swl.md +590 -590
  39. package/agentes/rendimiento-swl.md +713 -713
  40. package/agentes/revisor-angular-swl.md +278 -278
  41. package/agentes/revisor-csharp-swl.md +264 -264
  42. package/agentes/revisor-go-swl.md +259 -259
  43. package/agentes/revisor-java-swl.md +257 -257
  44. package/agentes/revisor-kotlin-swl.md +273 -273
  45. package/agentes/revisor-nextjs-swl.md +281 -281
  46. package/agentes/revisor-php-swl.md +271 -271
  47. package/agentes/revisor-react-swl.md +278 -278
  48. package/agentes/revisor-rust-swl.md +346 -346
  49. package/agentes/revisor-seguridad-swl.md +399 -399
  50. package/agentes/revisor-swift-swl.md +268 -268
  51. package/agentes/revisor-typescript-swl.md +346 -346
  52. package/agentes/tdd-qa-swl.md +393 -393
  53. package/bin/swl-ses.js +32 -7
  54. package/comandos/swl/actualizar.md +3 -3
  55. package/comandos/swl/aprender.md +13 -0
  56. package/comandos/swl/deuda-codigo.md +97 -97
  57. package/comandos/swl/evaluar-skill.md +18 -3
  58. package/comandos/swl/evolucion-continua.md +73 -0
  59. package/comandos/swl/evolucionar.md +13 -0
  60. package/comandos/swl/instalar.md +4 -4
  61. package/comandos/swl/notificaciones.md +1 -1
  62. package/comandos/swl/status.md +2 -2
  63. package/gateway/cron/jobs.example.json +12 -0
  64. package/habilidades/auto-evolucion-protocolo/SKILL.md +19 -1
  65. package/habilidades/autoresearch/SKILL.md +3 -2
  66. package/habilidades/backend-async-postgres-testing/SKILL.md +2 -1
  67. package/habilidades/benchmark-memoria/SKILL.md +7 -7
  68. package/habilidades/changelog-generator/SKILL.md +1 -1
  69. package/habilidades/changelog-generator/scripts/parse-commits.js +2 -1
  70. package/habilidades/checkpoints-verificacion/SKILL.md +6 -0
  71. package/habilidades/compactacion-contexto/SKILL.md +2 -1
  72. package/habilidades/contenedores-docker/SKILL.md +4 -2
  73. package/habilidades/context-builder/SKILL.md +4 -0
  74. package/habilidades/doubt-driven-review/SKILL.md +17 -1
  75. package/habilidades/drift-detection/SKILL.md +6 -1
  76. package/habilidades/ejecutar-fase/SKILL.md +6 -6
  77. package/habilidades/ejecutar-task-iterativo/SKILL.md +278 -278
  78. package/habilidades/eval-framework/SKILL.md +8 -3
  79. package/habilidades/extractor-de-aprendizajes/SKILL.md +8 -2
  80. package/habilidades/git-worktrees-paralelo/SKILL.md +19 -1
  81. package/habilidades/harness-claude-code/SKILL.md +7 -3
  82. package/habilidades/infra-github-actions/SKILL.md +4 -3
  83. package/habilidades/instalar-sistema/SKILL.md +5 -1
  84. package/habilidades/memoria-busqueda/SKILL.md +31 -39
  85. package/habilidades/planear-fase/SKILL.md +9 -1
  86. package/habilidades/prevencion-sobreingenieria/recursos/soluciones-nativas.md +166 -166
  87. package/habilidades/prevencion-sobreingenieria/recursos/variables-residuales-post-refactor.md +85 -85
  88. package/habilidades/proceso-ddia-fundamentos/SKILL.md +3 -2
  89. package/habilidades/proceso-ingenieria-requerimientos/SKILL.md +147 -147
  90. package/habilidades/release-semver/SKILL.md +2 -2
  91. package/habilidades/swl-claudemd/SKILL.md +6 -7
  92. package/habilidades/swl-dashboard/SKILL.md +11 -43
  93. package/habilidades/tdd-workflow/SKILL.md +12 -7
  94. package/habilidades/validacion-ci-sistema/SKILL.md +1 -1
  95. package/hooks/agente-lifecycle.js +2 -1
  96. package/hooks/aiisms-detector.js +13 -4
  97. package/hooks/audit-trail.js +2 -1
  98. package/hooks/auto-consolidacion.js +2 -1
  99. package/hooks/captura-acciones-post.js +2 -1
  100. package/hooks/captura-acciones-session.js +2 -1
  101. package/hooks/captura-feedback-usuario.js +3 -2
  102. package/hooks/claudemd-bloat-detector.js +12 -3
  103. package/hooks/claudemd-duplicacion-detector.js +13 -3
  104. package/hooks/contexto-iteracion.js +2 -1
  105. package/hooks/contexto-subagente.js +68 -68
  106. package/hooks/degradacion-instintos.js +2 -1
  107. package/hooks/extraccion-aprendizajes.js +109 -15
  108. package/hooks/grafo-contexto.js +2 -1
  109. package/hooks/guardrail-modelo.js +2 -1
  110. package/hooks/inbox-aviso.js +2 -1
  111. package/hooks/inyeccion-contexto.js +2 -1
  112. package/hooks/lib/agent-matcher.js +2 -1
  113. package/hooks/lib/agent-routing.js +2 -1
  114. package/hooks/lib/autonomia.js +5 -3
  115. package/hooks/lib/captura-acciones.js +2 -1
  116. package/hooks/lib/consolidation-lock.js +21 -10
  117. package/hooks/lib/etapa-auto-evolucion.js +10 -4
  118. package/hooks/lib/etapa-metricas.js +2 -1
  119. package/hooks/lib/etapa-perfil-usuario.js +20 -4
  120. package/hooks/lib/evolution-tracker.js +2 -1
  121. package/hooks/lib/gateway-notify.js +17 -3
  122. package/hooks/lib/loop-telemetry.js +5 -4
  123. package/hooks/lib/mcp-health.js +2 -1
  124. package/hooks/lib/memory-search.js +4 -0
  125. package/hooks/lib/merkle-audit.js +58 -6
  126. package/hooks/lib/notificacion-formato.js +58 -0
  127. package/hooks/lib/nudge-tracker.js +2 -1
  128. package/hooks/lib/otlp-exporter.js +2 -1
  129. package/hooks/lib/propose-step.js +3 -2
  130. package/hooks/lib/raiz-proyecto.js +127 -0
  131. package/hooks/lib/run-log.js +2 -1
  132. package/hooks/lib/singleton-guard.js +225 -27
  133. package/hooks/lib/telegram-cliente.js +28 -11
  134. package/hooks/notificacion-telegram.js +13 -3
  135. package/hooks/preservar-estado-pre-compact.js +2 -1
  136. package/hooks/proteccion-rutas.js +59 -3
  137. package/hooks/registro-turnos.js +2 -1
  138. package/hooks/resumen-sesion.js +2 -1
  139. package/hooks/risk-scoring.js +2 -1
  140. package/hooks/rotar-audit-auto.js +46 -20
  141. package/hooks/session-briefing.js +127 -1
  142. package/hooks/spec-gate.js +2 -1
  143. package/hooks/sugerir-contribuir.js +6 -3
  144. package/hooks/sugerir-regenerar-inventario.js +3 -2
  145. package/hooks/tdd-gate.js +2 -1
  146. package/hooks/telemetria-agentes.js +2 -1
  147. package/hooks/telemetria-skill-routing.js +2 -1
  148. package/hooks/tracking-costos.js +4 -3
  149. package/hooks/validar-formato-post-subagente.js +2 -1
  150. package/hooks/validar-intent-spec.js +2 -1
  151. package/hooks/validar-memoria-hook.js +13 -3
  152. package/hooks/validar-planning-paths.js +2 -1
  153. package/instintos/perfil-usuario.yaml +506 -3
  154. package/instintos/proyecto.yaml +78 -0
  155. package/llms.txt +29 -29
  156. package/manifiestos/canonical-hashes.json +5588 -4925
  157. package/manifiestos/hooks-config.json +469 -469
  158. package/manifiestos/invariantes-criticos.json +30 -30
  159. package/manifiestos/modulos.json +1429 -1423
  160. package/manifiestos/planning-paths.json +1 -0
  161. package/manifiestos/skills-lock.json +1275 -1275
  162. package/package.json +94 -95
  163. package/plugin.json +369 -369
  164. package/scripts/actualizar.js +3 -0
  165. package/scripts/auditar-clases-conocidas.js +134 -106
  166. package/scripts/benchmark-memoria.js +1 -0
  167. package/scripts/bootstrap-instintos.js +85 -14
  168. package/scripts/canario-hooks.js +166 -166
  169. package/scripts/cli/autonomia.js +23 -0
  170. package/scripts/cli/benchmark-memoria.js +37 -0
  171. package/scripts/cli/ciclo-autonomo.js +73 -0
  172. package/scripts/cli/ciclo-fase-b.js +102 -0
  173. package/scripts/cli/guardrail-metrics.js +39 -0
  174. package/scripts/cli/loop-telemetry.js +4 -2
  175. package/scripts/cli/memoria-search.js +69 -0
  176. package/scripts/cli/nudge-accionar.js +39 -0
  177. package/scripts/cli/run-eval.js +38 -0
  178. package/scripts/cli/run-skill-evals.js +13 -2
  179. package/scripts/derivar-feature-list.js +15 -14
  180. package/scripts/desinstalar.js +11 -0
  181. package/scripts/doctor.js +50 -13
  182. package/scripts/evidencia-valor.js +101 -101
  183. package/scripts/field-report.js +16 -16
  184. package/scripts/instalador.js +98 -7
  185. package/scripts/lib/activar-hooks-proyecto.js +116 -104
  186. package/scripts/lib/auditar-invocaciones-comandos.js +96 -6
  187. package/scripts/lib/ciclo-autonomo/candidatos.js +174 -0
  188. package/scripts/lib/ciclo-autonomo/config.js +165 -0
  189. package/scripts/lib/ciclo-autonomo/drenador-feedback.js +174 -0
  190. package/scripts/lib/ciclo-autonomo/fallback.js +77 -0
  191. package/scripts/lib/ciclo-autonomo/guard-convivencia.js +139 -0
  192. package/scripts/lib/ciclo-autonomo/higiene-nudges.js +112 -0
  193. package/scripts/lib/ciclo-autonomo/index.js +301 -0
  194. package/scripts/lib/ciclo-autonomo/lock.js +124 -0
  195. package/scripts/lib/ciclo-autonomo/presupuesto.js +122 -0
  196. package/scripts/lib/ciclo-autonomo/puente-degradacion.js +240 -0
  197. package/scripts/lib/ciclo-autonomo/runner-fase-b.js +248 -0
  198. package/scripts/lib/ciclo-autonomo/writer-instintos.js +190 -0
  199. package/scripts/lib/ciclo-autonomo/yaml-instintos.js +535 -0
  200. package/scripts/lib/estado.js +9 -0
  201. package/scripts/lib/evidencia-valor.js +228 -228
  202. package/scripts/lib/expandir-targets.js +71 -71
  203. package/scripts/lib/gitignore-manifest.js +8 -1
  204. package/scripts/lib/hooks-settings.js +45 -0
  205. package/scripts/lib/limpiar-basura-global.js +161 -0
  206. package/scripts/lib/toml-merge.js +204 -204
  207. package/scripts/mcp-server/auth.js +105 -105
  208. package/scripts/mcp-server/cache.js +106 -106
  209. package/scripts/rotar-audit-logs.js +48 -2
  210. package/scripts/run-eval.js +1 -0
  211. package/scripts/run-skill-evals.js +287 -8
  212. package/scripts/smoke-test.js +16 -8
  213. package/scripts/tui/pantallas/install-wizard.js +69 -13
  214. package/scripts/validar.js +40 -1
@@ -25,12 +25,14 @@
25
25
  */
26
26
 
27
27
  const fs = require('fs');
28
+ const { raizProyecto } = require('./raiz-proyecto');
28
29
  const path = require('path');
29
30
 
30
- const DIR_LOCKS = path.join(process.cwd(), '.planning', 'locks');
31
+ const DIR_LOCKS = path.join(raizProyecto(process.cwd()), '.planning', 'locks');
31
32
  const STALE_MS_DEFAULT = 5 * 60 * 1000; // 5 minutos
32
33
  const locksAdquiridos = new Set();
33
34
  let handlersRegistrados = false;
35
+ let seqTmp = 0; // secuencia para nombres de temp únicos por proceso
34
36
 
35
37
  function rutaLock(nombre) {
36
38
  return path.join(DIR_LOCKS, `${nombre}.lock`);
@@ -59,8 +61,64 @@ function pidVivo(pid) {
59
61
  }
60
62
 
61
63
  /**
62
- * Intenta adquirir el lock. Retorna true si se adquirió, false si otro
63
- * proceso lo tiene activo.
64
+ * Escribe el lockfile con exclusión atómica y SIN ventana de archivo vacío:
65
+ * escribe el contenido completo en un temp único y lo ENLAZA (link(2)) a `ruta`.
66
+ * link es atómico y falla con EEXIST si `ruta` ya existe — misma garantía de
67
+ * exclusión mutua que O_EXCL — pero, a diferencia de open('wx')+write, `ruta`
68
+ * NUNCA aparece vacío: el inodo enlazado ya trae el JSON completo.
69
+ *
70
+ * Por qué NO open('wx')+write (hallazgo 2026-07-15): esa forma crea el archivo
71
+ * vacío y ESCRIBE después. Un contendiente en el path de takeover que lo leyera
72
+ * en esa ventana obtenía '' → JSON.parse lanza → leerLock=null →
73
+ * contenidoTomable(null)=true → hacía takeover de un lock YA ganado → DOS
74
+ * holders. Reproducido a ~10% con 16 procesos simultáneos sobre el path stale;
75
+ * antes no se veía porque el test no forzaba simultaneidad real.
76
+ * (Precedente: fs.existsSync+writeFileSync tampoco sirve — nemesis iter-2.)
77
+ *
78
+ * @param {string} ruta
79
+ * @param {string} nombre
80
+ * @returns {boolean} true si este proceso creó el lock.
81
+ */
82
+ function crearLockExclusivo(ruta, nombre) {
83
+ const tmp = `${ruta}.${process.pid}.${++seqTmp}.tmp`;
84
+ try {
85
+ fs.writeFileSync(tmp, JSON.stringify({ pid: process.pid, ts: new Date().toISOString(), nombre }));
86
+ fs.linkSync(tmp, ruta); // atómico; lanza EEXIST si `ruta` ya existe
87
+ return true;
88
+ } catch (err) {
89
+ return false; // EEXIST (otro ganó) u otro error de I/O → no se adquirió
90
+ } finally {
91
+ try { fs.unlinkSync(tmp); } catch (_) { /* temp ya removido o nunca creado */ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * ¿Un CONTENIDO de lock ya leído es tomable? (proceso muerto, edad > staleMs,
97
+ * o contenido corrupto/nulo). Trabaja sobre el objeto, no re-lee el archivo —
98
+ * clave para el takeover: la decisión de remover se toma sobre el MISMO
99
+ * contenido que se acaba de leer bajo el guard, sin un segundo read que
100
+ * reintroduzca TOCTOU.
101
+ * @returns {boolean}
102
+ */
103
+ function contenidoTomable(contenido, staleMs) {
104
+ if (!contenido || typeof contenido.pid === 'undefined') return true; // corrupto/nulo → tomable
105
+ const edad = Date.now() - Date.parse(contenido.ts);
106
+ const vivo = pidVivo(contenido.pid);
107
+ return !(vivo && edad < staleMs);
108
+ }
109
+
110
+ /**
111
+ * ¿El lock existente (en disco) es tomable? Lee el archivo y delega en
112
+ * contenidoTomable.
113
+ * @returns {boolean}
114
+ */
115
+ function lockTomable(ruta, staleMs) {
116
+ return contenidoTomable(leerLock(ruta), staleMs);
117
+ }
118
+
119
+ /**
120
+ * Intenta adquirir el lock con exclusión ATÓMICA (O_EXCL). Retorna true si se
121
+ * adquirió, false si otro proceso lo tiene activo.
64
122
  *
65
123
  * @param {string} nombre - Identificador del lock (ej: 'auto-evolucion').
66
124
  * @param {object} [opts]
@@ -72,38 +130,113 @@ function adquirir(nombre, opts = {}) {
72
130
  ensureDir();
73
131
  const ruta = rutaLock(nombre);
74
132
 
75
- // Verificar lock existente
76
- if (fs.existsSync(ruta)) {
77
- try {
78
- const contenido = JSON.parse(fs.readFileSync(ruta, 'utf8'));
79
- const edad = Date.now() - Date.parse(contenido.ts);
80
- const vivo = pidVivo(contenido.pid);
133
+ // Caso primario: crear el lock de forma atómica. Dos procesos frescos
134
+ // simultáneos sobre un lock inexistente → solo UNO gana el O_EXCL.
135
+ if (crearLockExclusivo(ruta, nombre)) {
136
+ locksAdquiridos.add(nombre);
137
+ registrarHandlersCleanup();
138
+ return true;
139
+ }
81
140
 
82
- if (vivo && edad < staleMs) {
83
- // Lock activo y proceso vivo no adquirir
84
- return false;
85
- }
86
- // Lock stale o proceso muerto → tomar posesión
87
- } catch {
88
- // Lock corrupto → sobrescribir
141
+ // El lock existe. Si parece tomable (muerto/stale/corrupto), removerlo de
142
+ // forma SERIALIZADA (ver takeoverStaleSerializado) y reintentar el O_EXCL.
143
+ if (lockTomable(ruta, staleMs)) {
144
+ takeoverStaleSerializado(ruta, staleMs);
145
+ if (crearLockExclusivo(ruta, nombre)) {
146
+ locksAdquiridos.add(nombre);
147
+ registrarHandlersCleanup();
148
+ return true;
89
149
  }
90
150
  }
91
151
 
92
- // Escribir lock con PID actual
152
+ return false;
153
+ }
154
+
155
+ /** Lee el contenido {pid, ts} del lock, o null si no existe/corrupto. */
156
+ function leerLock(ruta) {
93
157
  try {
94
- fs.writeFileSync(ruta, JSON.stringify({
95
- pid: process.pid,
96
- ts: new Date().toISOString(),
97
- nombre,
98
- }), 'utf8');
99
- locksAdquiridos.add(nombre);
100
- registrarHandlersCleanup();
101
- return true;
158
+ return JSON.parse(fs.readFileSync(ruta, 'utf8'));
102
159
  } catch {
103
- return false;
160
+ return null;
104
161
  }
105
162
  }
106
163
 
164
+ // Un token de takeover se considera leaked (dueño muerto a mitad) si supera
165
+ // esto: la sección crítica del takeover es de µs, así que segundos = fuga.
166
+ const TAKEOVER_TOKEN_STALE_MS = 5000;
167
+
168
+ /**
169
+ * Remueve un lock stale de forma SERIALIZADA y sin ventana de create fresco.
170
+ *
171
+ * La clase de bug que dos rondas de re-auditoría destaparon: cualquier diseño
172
+ * que "saque" el stale del path (unlink ciego, rename-out) abre una ventana en
173
+ * la que `ruta` queda libre y un proceso fresco puede colarse con O_EXCL, y
174
+ * luego el remover restaura/recrea encima → DOS holders. El fix de CLASE:
175
+ *
176
+ * 1. Serializar el takeover con un token O_EXCL (`<ruta>.takeover`): solo UN
177
+ * taker entra a la sección crítica; los demás salen (otro lo maneja).
178
+ * 2. Dentro del token, re-leer `ruta` y `unlinkSync` SOLO si sigue tomable
179
+ * (muerto/stale/corrupto). `ruta` está PRESENTE hasta ese unlink → ningún
180
+ * create fresco puede colarse antes (falla EEXIST). Si el contenido pasó a
181
+ * ser un lock VIVO distinto (otro tomó posesión legítima), NO se toca.
182
+ *
183
+ * Tras esto, el `crearLockExclusivo` del caller compite limpio por O_EXCL: si
184
+ * un proceso fresco ganó la ventana unlink→create, el caller falla EEXIST en
185
+ * vez de clobberear. Nunca dos holders.
186
+ *
187
+ * El token puede quedar leaked si el taker muere a mitad; se reclama por edad
188
+ * (>TAKEOVER_TOKEN_STALE_MS), acotando el bloqueo a segundos.
189
+ *
190
+ * @param {string} ruta
191
+ * @param {number} staleMs
192
+ */
193
+ function takeoverStaleSerializado(ruta, staleMs) {
194
+ const token = `${ruta}.takeover`;
195
+ if (!adquirirTokenTakeover(token)) return; // otro taker está removiendo el stale
196
+ try {
197
+ const actual = leerLock(ruta);
198
+ if (contenidoTomable(actual, staleMs)) {
199
+ try { fs.unlinkSync(ruta); } catch { /* ya removido */ }
200
+ }
201
+ // else: `ruta` pasó a ser un lock VIVO legítimo — no tocar.
202
+ } finally {
203
+ try { fs.unlinkSync(token); } catch { /* nada */ }
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Adquiere el token de takeover (O_EXCL). Si existe pero está leaked, lo reclama
209
+ * una vez. Un token queda leaked SOLO si el taker murió dentro de su sección
210
+ * crítica (leer+unlink, del orden de µs). Dos señales de leak, en orden:
211
+ * 1. pid del token MUERTO → el taker crasheó → reclamo INSTANTÁNEO (sin
212
+ * esperar TAKEOVER_TOKEN_STALE_MS). Es el caso realista (SIGKILL/crash) y
213
+ * con esto la ventana de bloqueo del leak baja de segundos a ~0.
214
+ * 2. pid vivo pero token viejo (>TAKEOVER_TOKEN_STALE_MS) → taker atascado
215
+ * (patológico para una sección de µs) → reclamo por edad.
216
+ * El reclamo final es un crearLockExclusivo (O_EXCL): si dos takers reclaman a
217
+ * la vez, solo uno crea el token. Límite inherente (documentado): con locks de
218
+ * archivo zero-dep no hay exclusión mutua PERFECTA para el reclamo de stale
219
+ * —igual que proper-lockfile—; el riesgo residual es una carrera compuesta
220
+ * (leak en µs + doble-reclamo simultáneo) de probabilidad despreciable.
221
+ * @returns {boolean}
222
+ */
223
+ function adquirirTokenTakeover(token) {
224
+ if (crearLockExclusivo(token, 'takeover')) return true;
225
+ const contenido = leerLock(token);
226
+ const pidMuerto = contenido && typeof contenido.pid !== 'undefined' && !pidVivo(contenido.pid);
227
+ let viejo = false;
228
+ try {
229
+ viejo = (Date.now() - fs.statSync(token).mtimeMs) > TAKEOVER_TOKEN_STALE_MS;
230
+ } catch {
231
+ return crearLockExclusivo(token, 'takeover'); // desapareció entre create y stat → reintentar
232
+ }
233
+ if (pidMuerto || viejo) {
234
+ try { fs.unlinkSync(token); } catch { /* otro lo removió */ }
235
+ return crearLockExclusivo(token, 'takeover');
236
+ }
237
+ return false;
238
+ }
239
+
107
240
  /**
108
241
  * Libera el lock. Idempotente.
109
242
  *
@@ -123,6 +256,67 @@ function liberar(nombre) {
123
256
  locksAdquiridos.delete(nombre);
124
257
  }
125
258
 
259
+ /**
260
+ * Sleep SÍNCRONO sin quemar CPU (Atomics.wait sobre un buffer efímero).
261
+ * Los hooks son procesos cortos de un solo propósito — bloquear el event
262
+ * loop unos ms para serializar un RMW es aceptable y evita el busy-loop.
263
+ * @param {number} ms
264
+ */
265
+ function dormirSync(ms) {
266
+ try {
267
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
268
+ } catch {
269
+ // SharedArrayBuffer no disponible: degradar a busy-wait acotado.
270
+ const fin = Date.now() + ms;
271
+ while (Date.now() < fin) { /* espera */ }
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Ejecuta `fn` dentro de una sección crítica serializada por lock, con
277
+ * reintentos de adquisición (poll). Para RMW de archivos compartidos que NO
278
+ * deben perder datos por race (APRENDIZAJES.md) o cuyo incremento puede
279
+ * omitirse bajo contención (dirty.json).
280
+ *
281
+ * @param {string} nombre - Identificador del lock.
282
+ * @param {Function} fn - Trabajo a ejecutar dentro de la sección crítica.
283
+ * @param {object} [opts]
284
+ * @param {number} [opts.maxWaitMs=1000] - Tope total de espera para adquirir.
285
+ * @param {number} [opts.pollMs=25] - Intervalo entre intentos.
286
+ * @param {number} [opts.staleMs] - Se pasa a adquirir().
287
+ * @param {'ejecutar'|'omitir'} [opts.siNoAdquiere='ejecutar'] - Qué hacer si
288
+ * no se adquiere el lock dentro de maxWaitMs: `ejecutar` corre fn igual
289
+ * (best-effort, no pierde datos — la ventana de race ya se redujo al mínimo);
290
+ * `omitir` salta fn (el trabajo es descartable, ej. un contador).
291
+ * @returns {{ejecutado:boolean, conLock:boolean}}
292
+ */
293
+ function conLockRetry(nombre, fn, opts = {}) {
294
+ const maxWaitMs = opts.maxWaitMs != null ? opts.maxWaitMs : 1000;
295
+ const pollMs = opts.pollMs != null ? opts.pollMs : 25;
296
+ const siNoAdquiere = opts.siNoAdquiere || 'ejecutar';
297
+ const deadline = Date.now() + maxWaitMs;
298
+
299
+ let adquirido = adquirir(nombre, { staleMs: opts.staleMs });
300
+ while (!adquirido && Date.now() < deadline) {
301
+ dormirSync(pollMs);
302
+ adquirido = adquirir(nombre, { staleMs: opts.staleMs });
303
+ }
304
+
305
+ if (!adquirido) {
306
+ if (siNoAdquiere === 'omitir') return { ejecutado: false, conLock: false };
307
+ // best-effort: correr sin lock (mejor que perder el dato)
308
+ try { fn(); } finally { /* sin lock que liberar */ }
309
+ return { ejecutado: true, conLock: false };
310
+ }
311
+
312
+ try {
313
+ fn();
314
+ return { ejecutado: true, conLock: true };
315
+ } finally {
316
+ liberar(nombre);
317
+ }
318
+ }
319
+
126
320
  /**
127
321
  * Libera todos los locks adquiridos por este proceso.
128
322
  * Invocado por handlers de SIGTERM/SIGINT/exit.
@@ -155,5 +349,9 @@ module.exports = {
155
349
  adquirir,
156
350
  liberar,
157
351
  liberarTodos,
158
- _internals: { pidVivo, rutaLock, STALE_MS_DEFAULT },
352
+ conLockRetry,
353
+ _internals: {
354
+ pidVivo, rutaLock, dormirSync, crearLockExclusivo, lockTomable, contenidoTomable,
355
+ leerLock, takeoverStaleSerializado, adquirirTokenTakeover, STALE_MS_DEFAULT,
356
+ },
159
357
  };
@@ -117,37 +117,54 @@ function _esperar(ms) {
117
117
  /**
118
118
  * Envía un mensaje de Telegram con escape HTML, truncado y reintentos.
119
119
  *
120
- * Solo reintenta en errores 5xx (falla de servidor). Los errores 4xx
121
- * (credenciales inválidas, chat no encontrado) no se reintentan porque
122
- * volver a intentar no resolvería el problema.
120
+ * Solo reintenta en errores 5xx (falla de servidor con no-entrega confirmada).
121
+ * NO reintenta en:
122
+ * - 4xx (credenciales inválidas, chat no encontrado): reintentar no ayuda.
123
+ * - statusCode 0 (timeout o error de red): la entrega es INDETERMINADA — el
124
+ * POST /sendMessage pudo LLEGAR a Telegram y solo perderse la respuesta
125
+ * (ECONNRESET tras enviar). Como sendMessage NO es idempotente, reintentar
126
+ * DUPLICA el mensaje. Esa era la causa raíz de la triplicación de
127
+ * notificaciones (diagnóstico 2026-07-11): una conexión inestable entregaba
128
+ * el mensaje 1-3 veces mientras el hook logueaba un solo "ok". Se prefiere
129
+ * perder una notificación best-effort ante un fallo de red a triplicarla.
123
130
  *
124
131
  * @param {string} token - Token del bot (`123:ABC...`).
125
132
  * @param {string} chatId - ID numérico del chat destino.
126
- * @param {string} texto - Texto del mensaje. Se escapa y trunca automáticamente.
133
+ * @param {string} texto - Texto del mensaje. Por default se escapa (el llamador
134
+ * pasa texto PLANO). Si el llamador ya construyó HTML válido (tags + contenido
135
+ * escapado), pasar `opts.escaparContenido = false` para NO re-escapar — de lo
136
+ * contrario los `<b>` se convierten en `&lt;b&gt;` y Telegram los muestra
137
+ * literales (doble escape; bug 2026-07-15).
127
138
  * @param {object} [opts] - Campos adicionales para el body de sendMessage.
139
+ * @param {boolean} [opts.escaparContenido=true] - Escapar HTML del texto (para
140
+ * llamadores que pasan texto plano). false si el texto YA es HTML listo.
128
141
  * @returns {Promise<{ok: boolean, statusCode: number, error?: string}>}
129
142
  */
130
143
  async function enviarMensaje(token, chatId, texto, opts = {}) {
131
- const textoPrepado = truncar(escaparHtml(texto), LIMITE_CHARS);
144
+ const { escaparContenido = true, ...payloadOpts } = opts;
145
+ const base = escaparContenido ? escaparHtml(texto) : String(texto);
146
+ const textoPrepado = truncar(base, LIMITE_CHARS);
132
147
 
133
148
  for (let intento = 0; intento < REINTENTOS_MAX; intento++) {
134
- const resultado = await _intentarEnvio(token, chatId, textoPrepado, opts);
135
-
136
- if (resultado.cuerpo === 'timeout') {
137
- return { ok: false, statusCode: 0, error: 'timeout' };
138
- }
149
+ const resultado = await _intentarEnvio(token, chatId, textoPrepado, payloadOpts);
139
150
 
140
151
  if (resultado.ok) {
141
152
  return { ok: true, statusCode: resultado.statusCode };
142
153
  }
143
154
 
155
+ // Entrega INDETERMINADA (timeout o error de red): NO reintentar — un
156
+ // reintento duplicaría un mensaje que quizá ya se entregó.
157
+ if (resultado.statusCode === 0) {
158
+ return { ok: false, statusCode: 0, error: resultado.cuerpo === 'timeout' ? 'timeout' : 'network-error' };
159
+ }
160
+
144
161
  // Errores 4xx: no reintentar (credenciales inválidas, chat inexistente, etc.)
145
162
  if (resultado.statusCode >= 400 && resultado.statusCode < 500) {
146
163
  const etiqueta = resultado.statusCode === 401 ? 'unauthorized' : `http-${resultado.statusCode}`;
147
164
  return { ok: false, statusCode: resultado.statusCode, error: etiqueta };
148
165
  }
149
166
 
150
- // Error de red (statusCode 0) o 5xx: reintento con backoff exponencial
167
+ // Solo 5xx (falla de servidor con no-entrega): reintento con backoff.
151
168
  if (intento < REINTENTOS_MAX - 1) {
152
169
  await _esperar(BACKOFF_BASE_MS * Math.pow(2, intento));
153
170
  }
@@ -23,6 +23,7 @@ const path = require('node:path');
23
23
 
24
24
  const { cargarConfig, estaMuted } = require('./lib/telegram-config');
25
25
  const { enviarMensaje, escaparHtml } = require('./lib/telegram-cliente');
26
+ const { resumirParaNotificacion } = require('./lib/notificacion-formato');
26
27
 
27
28
  // ----------------------------------------------------------------
28
29
  // Utilidades de logging
@@ -121,7 +122,7 @@ function construirCampos(nombreEvento, nombreProyecto, transcriptPath, evt) {
121
122
  if (nombreEvento === 'Stop' || nombreEvento === 'SubagentStop') {
122
123
  tituloBase = nombreEvento === 'SubagentStop' ? 'Subagente terminó' : 'Claude terminó';
123
124
  subtitulo = 'Turno completado';
124
- cuerpo = leerUltimoMensajeAsistente(transcriptPath) || 'Claude terminó el turno.';
125
+ cuerpo = resumirParaNotificacion(leerUltimoMensajeAsistente(transcriptPath)) || 'Claude terminó el turno.';
125
126
 
126
127
  } else if (nombreEvento === 'Notification') {
127
128
  tituloBase = 'Claude espera respuesta';
@@ -262,14 +263,23 @@ async function run() {
262
263
  `<b>Estado:</b> ${escaparHtml(subtitulo)}\n\n` +
263
264
  `${escaparHtml(cuerpo)}`;
264
265
 
265
- // Enviar notificación Telegram
266
+ // Enviar notificación Telegram. escaparContenido:false porque `texto` YA es
267
+ // HTML listo (tags <b> + contenido escapado arriba); re-escaparlo mostraría
268
+ // los tags literales en Telegram (doble escape, bug 2026-07-15).
266
269
  const resultado = await enviarMensaje(
267
270
  config.TELEGRAM_BOT_TOKEN,
268
271
  config.TELEGRAM_CHAT_ID,
269
- texto
272
+ texto,
273
+ { escaparContenido: false }
270
274
  );
271
275
 
272
276
  log('telegram resultado', resultado.ok ? 'ok' : resultado.error);
273
277
  }
274
278
 
279
+ // Sin gate de entrypoint: los hooks se invocan vía el wrapper
280
+ // `node -e "require('./hooks/notificacion-telegram.js')"` de settings.json (y
281
+ // desde los wrappers -notification/-subagent), donde require.main es undefined;
282
+ // un gate de entrypoint dejaría el hook muerto en producción (audit
283
+ // entrypoint-wrapper.test.js). Las funciones puras testeables viven en
284
+ // ./lib/notificacion-formato.js.
275
285
  run().catch(e => log('run error', e && e.message));
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  const fs = require('fs');
23
+ const { raizProyecto } = require('./lib/raiz-proyecto');
23
24
  const path = require('path');
24
25
 
25
26
  // Atomic write para backups pre-compact (.planning/backups/). Si la
@@ -32,7 +33,7 @@ try {
32
33
  atomicWriteJSON = (p, o) => fs.writeFileSync(p, JSON.stringify(o, null, 2), 'utf8');
33
34
  }
34
35
 
35
- const CWD = process.cwd();
36
+ const CWD = raizProyecto(process.cwd());
36
37
 
37
38
  // ---------------------------------------------------------------------------
38
39
  // Archivos de estado a preservar
@@ -228,8 +228,62 @@ const RUTAS_PERMITIDAS_EXTERNAS = [
228
228
  path.join(os.homedir(), '.claude', 'commands'),
229
229
  // Reglas globales del usuario (modificables durante consolidación cross-proyecto)
230
230
  path.join(os.homedir(), '.claude', 'rules'),
231
+ // Scratchpad de sesión del harness de Claude Code (os.tmpdir()/claude/<proyecto>/<sesión>/)
232
+ // — el propio harness instruye usarlo para todo temporal (caso real 2026-07-10:
233
+ // el hook bloqueaba al agente escribiendo en su scratchpad oficial).
234
+ path.join(os.tmpdir(), 'claude'),
231
235
  ];
232
236
 
237
+ /**
238
+ * Asciende desde `desde` buscando la raíz del repositorio git que lo contiene
239
+ * (directorio con `.git` — directorio normal o archivo, para worktrees).
240
+ *
241
+ * La frontera de seguridad correcta es el PROYECTO, no el subdirectorio donde
242
+ * se abrió la sesión: con CWD=<repo>/backend, escribir `<repo>/.planning/...`
243
+ * es legítimo (todo el sistema SWL escribe `.planning/` en la raíz). Caso real
244
+ * SIGM 2026-07-10: el loop de auditoría se auto-bloqueaba en un monorepo.
245
+ *
246
+ * @param {string} desde - Directorio inicial (típicamente el CWD).
247
+ * @returns {string|null} Raíz del repo, o null si `desde` no está en un repo git.
248
+ */
249
+ function raizRepoGit(desde) {
250
+ const fs = require('fs');
251
+ let actual = path.resolve(desde);
252
+ for (let i = 0; i < 40; i++) {
253
+ try {
254
+ if (fs.existsSync(path.join(actual, '.git'))) return actual;
255
+ } catch (_) {
256
+ return null;
257
+ }
258
+ const padre = path.dirname(actual);
259
+ if (padre === actual) return null; // raíz del filesystem
260
+ actual = padre;
261
+ }
262
+ return null;
263
+ }
264
+
265
+ /**
266
+ * Verifica si la ruta resuelta cae dentro de la raíz del repositorio git que
267
+ * contiene al CWD. Boundary check por componente completo (prefijo + '/'),
268
+ * case-insensitive en Windows — un sibling `<repo>-evil` NO pasa.
269
+ *
270
+ * @param {string} rutaResuelta
271
+ * @param {string} cwdActual
272
+ * @returns {boolean}
273
+ */
274
+ function estaDentroDeRaizGit(rutaResuelta, cwdActual) {
275
+ const raiz = raizRepoGit(cwdActual);
276
+ if (!raiz) return false;
277
+
278
+ const raizNorm = normalizarParaComparacion(raiz);
279
+ const rutaNorm = normalizarParaComparacion(rutaResuelta);
280
+ const [raizCmp, rutaCmp] = process.platform === 'win32'
281
+ ? [raizNorm.toLowerCase(), rutaNorm.toLowerCase()]
282
+ : [raizNorm, rutaNorm];
283
+
284
+ return rutaCmp === raizCmp || rutaCmp.startsWith(raizCmp + '/');
285
+ }
286
+
233
287
  /**
234
288
  * Parsea la variable de entorno SWL_PROTECCION_RUTAS_PERMITIR (CSV de rutas
235
289
  * absolutas) y devuelve un array de paths normalizados.
@@ -361,7 +415,8 @@ function construirMensajeBloqueo(tipoViolacion, rutaRaw, rutaResuelta, cwdActual
361
415
  `Directorio CWD: ${cwdActual}`,
362
416
  ``,
363
417
  `Reglas de seguridad:`,
364
- ` - La ruta de destino debe estar dentro del directorio de trabajo actual.`,
418
+ ` - La ruta de destino debe estar dentro del directorio de trabajo actual`,
419
+ ` o de la raíz del repositorio git que lo contiene (monorepos).`,
365
420
  ` - No se permiten secuencias de path traversal (../, ~/, %2e%2e, etc.).`,
366
421
  ` - No se permiten rutas absolutas fuera del proyecto.`,
367
422
  ];
@@ -440,9 +495,10 @@ process.stdin.on('end', () => {
440
495
  process.exit(2);
441
496
  }
442
497
 
443
- // --- Verificación 2: destino dentro del CWD o en rutas externas permitidas ---
498
+ // --- Verificación 2: destino dentro del CWD, de la raíz del repo git
499
+ // del CWD, o en rutas externas permitidas ---
444
500
  const { dentro, rutaResuelta } = verificarDentroDeCwd(rutaRaw, cwdActual);
445
- if (!dentro && !esRutaExternaPermitida(rutaResuelta)) {
501
+ if (!dentro && !estaDentroDeRaizGit(rutaResuelta, cwdActual) && !esRutaExternaPermitida(rutaResuelta)) {
446
502
  const razon = construirMensajeBloqueo(
447
503
  `Destino fuera del directorio de trabajo actual`,
448
504
  rutaRaw,
@@ -54,12 +54,13 @@
54
54
  */
55
55
 
56
56
  const fs = require('fs');
57
+ const { raizProyecto } = require('./lib/raiz-proyecto');
57
58
  const path = require('path');
58
59
 
59
60
  const LIMITE_TURNOS = parseInt(process.env.SWL_REGISTRO_TURNOS_LIMITE || '1000', 10);
60
61
  const MAX_INPUT_CHARS = 200;
61
62
  const MAX_OUTPUT_CHARS = 500;
62
- const DIR_BASE = path.join(process.cwd(), '.planning', 'sessions');
63
+ const DIR_BASE = path.join(raizProyecto(process.cwd()), '.planning', 'sessions');
63
64
 
64
65
  // Patrones de sanitización conservadores: tokens largos, llaves API, JWT,
65
66
  // password en URLs, paths con .env. Si aparece un match, se reemplaza con
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  const fs = require('fs');
21
+ const { raizProyecto } = require('./lib/raiz-proyecto');
21
22
  const path = require('path');
22
23
  const os = require('os');
23
24
  const { execFile } = require('child_process');
@@ -83,7 +84,7 @@ function hooksDisparados(sessionId) {
83
84
 
84
85
  // Fallback: heurística por archivos JSONL de hooks conocidos en .planning/
85
86
  try {
86
- const planningDir = path.join(process.cwd(), '.planning');
87
+ const planningDir = path.join(raizProyecto(process.cwd()), '.planning');
87
88
  const jsonlCandidatos = [
88
89
  ['evolution/nudges.jsonl', 'nudge-tracker'],
89
90
  ['auto-evolution/agentes.jsonl', 'auto-evolucion'],
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  const fs = require('fs');
29
+ const { raizProyecto } = require('./lib/raiz-proyecto');
29
30
  const path = require('path');
30
31
  const os = require('os');
31
32
 
@@ -104,7 +105,7 @@ function trackHighOverride(score, factors, toolName) {
104
105
  });
105
106
 
106
107
  if (state.highOverrides >= MAX_HIGH_OVERRIDES && !state.auditWritten) {
107
- const auditPath = path.join(process.cwd(), '.planning', 'AUDITORIA.md');
108
+ const auditPath = path.join(raizProyecto(process.cwd()), '.planning', 'AUDITORIA.md');
108
109
  try {
109
110
  const auditEntry = [
110
111
  '',
@@ -29,16 +29,30 @@
29
29
  const fs = require('fs');
30
30
  const path = require('path');
31
31
  const { spawnSync } = require('child_process');
32
+ const { raizProyecto } = require('./lib/raiz-proyecto');
33
+ const guard = require('./lib/singleton-guard');
32
34
 
33
- const RAIZ_PROYECTO = path.resolve(__dirname, '..');
35
+ // Ancla: raíz del PROYECTO (no __dirname/.. — en instalaciones destino el hook
36
+ // vive en ~/.claude/hooks/ y esa ancla apuntaba a ~/.claude/, nunca al
37
+ // proyecto; misma clase que DT-HOOKS-RAIZ-GIT).
38
+ const RAIZ_PROYECTO = raizProyecto(process.cwd());
34
39
  const DIR_PLANNING = path.join(RAIZ_PROYECTO, '.planning');
35
40
  const ARCHIVOS_AUDIT = [
36
41
  path.join(DIR_PLANNING, 'audit.jsonl'),
37
42
  path.join(DIR_PLANNING, 'audit-merkle.jsonl'),
38
43
  ];
39
- const DIR_ARCHIVO = path.join(DIR_PLANNING, 'archivo', 'audit');
44
+ const DIR_ARCHIVO = path.join(DIR_PLANNING, 'archive', 'audit');
40
45
  const MARCA_ULTIMA_ROTACION = path.join(DIR_ARCHIVO, '.ultima-rotacion');
41
- const SCRIPT_ROTAR = path.join(RAIZ_PROYECTO, 'scripts', 'rotar-audit-logs.js');
46
+ // El script de rotación es EJECUTABLE de la distribución SWL: se resuelve
47
+ // SOLO desde la ubicación instalada del hook (__dirname) — repo madre en
48
+ // scripts/, destino aplanado junto al hook. NUNCA desde la raíz del
49
+ // proyecto: un repo no confiable podría plantar el script y ejecutarse en
50
+ // el primer Stop (hallazgo MAYOR revisor-seguridad 2026-07-10). Los DATOS
51
+ // (DIR_PLANNING) sí anclan a raizProyecto.
52
+ const SCRIPT_ROTAR = [
53
+ path.join(__dirname, '..', 'scripts', 'rotar-audit-logs.js'),
54
+ path.join(__dirname, 'rotar-audit-logs.js'),
55
+ ].find(p => { try { return fs.existsSync(p); } catch (_) { return false; } }) || '';
42
56
 
43
57
  const COOLDOWN_MS = 6 * 60 * 60 * 1000; // 6 horas
44
58
 
@@ -93,24 +107,36 @@ function main() {
93
107
  if (!check.excedido) return;
94
108
  if (enCooldown()) return;
95
109
 
96
- const dias = diasRetencion();
97
- const nodeBin = process.execPath;
98
- const result = spawnSync(
99
- nodeBin,
100
- [SCRIPT_ROTAR, `--dias=${dias}`, `--raiz=${DIR_PLANNING}`],
101
- { encoding: 'utf8', timeout: 30_000, cwd: RAIZ_PROYECTO }
102
- );
103
-
104
- if (result.status === 0) {
105
- marcarRotacion();
106
- const sizeMb = (check.size / 1024 / 1024).toFixed(1);
107
- const limiteMb = (check.limite / 1024 / 1024).toFixed(1);
108
- const nombreArchivo = path.basename(check.ruta);
109
- const match = (result.stdout || '').match(/(\d+) archivadas/g);
110
- const archivadas = match ? match.map((m) => m.replace(' archivadas', '')).join('+') : '?';
111
- process.stdout.write(
112
- `[rotacion-audit] ${nombreArchivo}=${sizeMb}MB > ${limiteMb}MB → archivadas ${archivadas} entradas (>${dias}d)\n`
110
+ // Lock cross-proceso (nemesis iter-1 S2-1/S3-4): dos sesiones con Stop
111
+ // simultáneo podían rotar a la vez → re-archivar las mismas entradas
112
+ // (duplicados en el .gz). Si otra rotación está activa, salir — esa corrida
113
+ // ya archiva. Ancla del lock = raíz del proyecto (misma que RAIZ_PROYECTO).
114
+ if (!guard.adquirir('rotar-audit', { staleMs: 300_000 })) return;
115
+ try {
116
+ const dias = diasRetencion();
117
+ const nodeBin = process.execPath;
118
+ const result = spawnSync(
119
+ nodeBin,
120
+ [SCRIPT_ROTAR, `--dias=${dias}`, `--raiz=${DIR_PLANNING}`],
121
+ // Archivos de auditoría de decenas de MB pueden tardar; 30s los mataba a
122
+ // mitad de rotación (nemesis iter-1 S3-4). 5 min da margen; el escribirActivo
123
+ // atómico protege contra la interrupción residual.
124
+ { encoding: 'utf8', timeout: 300_000, cwd: RAIZ_PROYECTO }
113
125
  );
126
+
127
+ if (result.status === 0) {
128
+ marcarRotacion();
129
+ const sizeMb = (check.size / 1024 / 1024).toFixed(1);
130
+ const limiteMb = (check.limite / 1024 / 1024).toFixed(1);
131
+ const nombreArchivo = path.basename(check.ruta);
132
+ const match = (result.stdout || '').match(/(\d+) archivadas/g);
133
+ const archivadas = match ? match.map((m) => m.replace(' archivadas', '')).join('+') : '?';
134
+ process.stdout.write(
135
+ `[rotacion-audit] ${nombreArchivo}=${sizeMb}MB > ${limiteMb}MB → archivadas ${archivadas} entradas (>${dias}d)\n`
136
+ );
137
+ }
138
+ } finally {
139
+ guard.liberar('rotar-audit');
114
140
  }
115
141
  }
116
142