@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.
- package/CLAUDE.md +9 -9
- package/README.md +37 -37
- package/agentes/_intent-spec.md +73 -73
- package/agentes/_propose-step.md +90 -90
- package/agentes/accesibilidad-wcag-swl.md +690 -690
- package/agentes/arquitecto-swl.md +267 -267
- package/agentes/auto-evolucion-swl.md +932 -908
- package/agentes/backend-csharp-swl.md +420 -420
- package/agentes/backend-go-swl.md +390 -390
- package/agentes/backend-java-swl.md +281 -281
- package/agentes/backend-rust-swl.md +364 -364
- package/agentes/backend-workers-swl.md +482 -482
- package/agentes/cloud-infra-swl.md +509 -509
- package/agentes/consolidador-swl.md +541 -541
- package/agentes/depurador-swl.md +352 -352
- package/agentes/devops-ci-swl.md +400 -400
- package/agentes/disenador-ui-swl.md +569 -569
- package/agentes/documentador-swl.md +345 -345
- package/agentes/frontend-angular-swl.md +621 -621
- package/agentes/frontend-css-swl.md +716 -716
- package/agentes/frontend-react-swl.md +692 -692
- package/agentes/frontend-swl.md +496 -496
- package/agentes/frontend-tailwind-swl.md +826 -826
- package/agentes/investigador-swl.md +432 -432
- package/agentes/investigador-ux-swl.md +505 -505
- package/agentes/migrador-swl.md +442 -442
- package/agentes/mobile-android-swl.md +511 -511
- package/agentes/mobile-cross-swl.md +541 -541
- package/agentes/mobile-ios-swl.md +502 -502
- package/agentes/mobile-testing-swl.md +302 -302
- package/agentes/nemesis-auditor-swl.md +285 -285
- package/agentes/observabilidad-swl.md +438 -438
- package/agentes/pagos-swl.md +310 -310
- package/agentes/perfilador-usuario-swl.md +321 -321
- package/agentes/planificador-swl.md +399 -399
- package/agentes/producto-prd-swl.md +589 -589
- package/agentes/red-team-swl.md +218 -218
- package/agentes/release-manager-swl.md +590 -590
- package/agentes/rendimiento-swl.md +713 -713
- package/agentes/revisor-angular-swl.md +278 -278
- package/agentes/revisor-csharp-swl.md +264 -264
- package/agentes/revisor-go-swl.md +259 -259
- package/agentes/revisor-java-swl.md +257 -257
- package/agentes/revisor-kotlin-swl.md +273 -273
- package/agentes/revisor-nextjs-swl.md +281 -281
- package/agentes/revisor-php-swl.md +271 -271
- package/agentes/revisor-react-swl.md +278 -278
- package/agentes/revisor-rust-swl.md +346 -346
- package/agentes/revisor-seguridad-swl.md +399 -399
- package/agentes/revisor-swift-swl.md +268 -268
- package/agentes/revisor-typescript-swl.md +346 -346
- package/agentes/tdd-qa-swl.md +393 -393
- package/bin/swl-ses.js +32 -7
- package/comandos/swl/actualizar.md +3 -3
- package/comandos/swl/aprender.md +13 -0
- package/comandos/swl/deuda-codigo.md +97 -97
- package/comandos/swl/evaluar-skill.md +18 -3
- package/comandos/swl/evolucion-continua.md +73 -0
- package/comandos/swl/evolucionar.md +13 -0
- package/comandos/swl/instalar.md +4 -4
- package/comandos/swl/notificaciones.md +1 -1
- package/comandos/swl/status.md +2 -2
- package/gateway/cron/jobs.example.json +12 -0
- package/habilidades/auto-evolucion-protocolo/SKILL.md +19 -1
- package/habilidades/autoresearch/SKILL.md +3 -2
- package/habilidades/backend-async-postgres-testing/SKILL.md +2 -1
- package/habilidades/benchmark-memoria/SKILL.md +7 -7
- package/habilidades/changelog-generator/SKILL.md +1 -1
- package/habilidades/changelog-generator/scripts/parse-commits.js +2 -1
- package/habilidades/checkpoints-verificacion/SKILL.md +6 -0
- package/habilidades/compactacion-contexto/SKILL.md +2 -1
- package/habilidades/contenedores-docker/SKILL.md +4 -2
- package/habilidades/context-builder/SKILL.md +4 -0
- package/habilidades/doubt-driven-review/SKILL.md +17 -1
- package/habilidades/drift-detection/SKILL.md +6 -1
- package/habilidades/ejecutar-fase/SKILL.md +6 -6
- package/habilidades/ejecutar-task-iterativo/SKILL.md +278 -278
- package/habilidades/eval-framework/SKILL.md +8 -3
- package/habilidades/extractor-de-aprendizajes/SKILL.md +8 -2
- package/habilidades/git-worktrees-paralelo/SKILL.md +19 -1
- package/habilidades/harness-claude-code/SKILL.md +7 -3
- package/habilidades/infra-github-actions/SKILL.md +4 -3
- package/habilidades/instalar-sistema/SKILL.md +5 -1
- package/habilidades/memoria-busqueda/SKILL.md +31 -39
- package/habilidades/planear-fase/SKILL.md +9 -1
- package/habilidades/prevencion-sobreingenieria/recursos/soluciones-nativas.md +166 -166
- package/habilidades/prevencion-sobreingenieria/recursos/variables-residuales-post-refactor.md +85 -85
- package/habilidades/proceso-ddia-fundamentos/SKILL.md +3 -2
- package/habilidades/proceso-ingenieria-requerimientos/SKILL.md +147 -147
- package/habilidades/release-semver/SKILL.md +2 -2
- package/habilidades/swl-claudemd/SKILL.md +6 -7
- package/habilidades/swl-dashboard/SKILL.md +11 -43
- package/habilidades/tdd-workflow/SKILL.md +12 -7
- package/habilidades/validacion-ci-sistema/SKILL.md +1 -1
- package/hooks/agente-lifecycle.js +2 -1
- package/hooks/aiisms-detector.js +13 -4
- package/hooks/audit-trail.js +2 -1
- package/hooks/auto-consolidacion.js +2 -1
- package/hooks/captura-acciones-post.js +2 -1
- package/hooks/captura-acciones-session.js +2 -1
- package/hooks/captura-feedback-usuario.js +3 -2
- package/hooks/claudemd-bloat-detector.js +12 -3
- package/hooks/claudemd-duplicacion-detector.js +13 -3
- package/hooks/contexto-iteracion.js +2 -1
- package/hooks/contexto-subagente.js +68 -68
- package/hooks/degradacion-instintos.js +2 -1
- package/hooks/extraccion-aprendizajes.js +109 -15
- package/hooks/grafo-contexto.js +2 -1
- package/hooks/guardrail-modelo.js +2 -1
- package/hooks/inbox-aviso.js +2 -1
- package/hooks/inyeccion-contexto.js +2 -1
- package/hooks/lib/agent-matcher.js +2 -1
- package/hooks/lib/agent-routing.js +2 -1
- package/hooks/lib/autonomia.js +5 -3
- package/hooks/lib/captura-acciones.js +2 -1
- package/hooks/lib/consolidation-lock.js +21 -10
- package/hooks/lib/etapa-auto-evolucion.js +10 -4
- package/hooks/lib/etapa-metricas.js +2 -1
- package/hooks/lib/etapa-perfil-usuario.js +20 -4
- package/hooks/lib/evolution-tracker.js +2 -1
- package/hooks/lib/gateway-notify.js +17 -3
- package/hooks/lib/loop-telemetry.js +5 -4
- package/hooks/lib/mcp-health.js +2 -1
- package/hooks/lib/memory-search.js +4 -0
- package/hooks/lib/merkle-audit.js +58 -6
- package/hooks/lib/notificacion-formato.js +58 -0
- package/hooks/lib/nudge-tracker.js +2 -1
- package/hooks/lib/otlp-exporter.js +2 -1
- package/hooks/lib/propose-step.js +3 -2
- package/hooks/lib/raiz-proyecto.js +127 -0
- package/hooks/lib/run-log.js +2 -1
- package/hooks/lib/singleton-guard.js +225 -27
- package/hooks/lib/telegram-cliente.js +28 -11
- package/hooks/notificacion-telegram.js +13 -3
- package/hooks/preservar-estado-pre-compact.js +2 -1
- package/hooks/proteccion-rutas.js +59 -3
- package/hooks/registro-turnos.js +2 -1
- package/hooks/resumen-sesion.js +2 -1
- package/hooks/risk-scoring.js +2 -1
- package/hooks/rotar-audit-auto.js +46 -20
- package/hooks/session-briefing.js +127 -1
- package/hooks/spec-gate.js +2 -1
- package/hooks/sugerir-contribuir.js +6 -3
- package/hooks/sugerir-regenerar-inventario.js +3 -2
- package/hooks/tdd-gate.js +2 -1
- package/hooks/telemetria-agentes.js +2 -1
- package/hooks/telemetria-skill-routing.js +2 -1
- package/hooks/tracking-costos.js +4 -3
- package/hooks/validar-formato-post-subagente.js +2 -1
- package/hooks/validar-intent-spec.js +2 -1
- package/hooks/validar-memoria-hook.js +13 -3
- package/hooks/validar-planning-paths.js +2 -1
- package/instintos/perfil-usuario.yaml +506 -3
- package/instintos/proyecto.yaml +78 -0
- package/llms.txt +29 -29
- package/manifiestos/canonical-hashes.json +5588 -4925
- package/manifiestos/hooks-config.json +469 -469
- package/manifiestos/invariantes-criticos.json +30 -30
- package/manifiestos/modulos.json +1429 -1423
- package/manifiestos/planning-paths.json +1 -0
- package/manifiestos/skills-lock.json +1275 -1275
- package/package.json +94 -95
- package/plugin.json +369 -369
- package/scripts/actualizar.js +3 -0
- package/scripts/auditar-clases-conocidas.js +134 -106
- package/scripts/benchmark-memoria.js +1 -0
- package/scripts/bootstrap-instintos.js +85 -14
- package/scripts/canario-hooks.js +166 -166
- package/scripts/cli/autonomia.js +23 -0
- package/scripts/cli/benchmark-memoria.js +37 -0
- package/scripts/cli/ciclo-autonomo.js +73 -0
- package/scripts/cli/ciclo-fase-b.js +102 -0
- package/scripts/cli/guardrail-metrics.js +39 -0
- package/scripts/cli/loop-telemetry.js +4 -2
- package/scripts/cli/memoria-search.js +69 -0
- package/scripts/cli/nudge-accionar.js +39 -0
- package/scripts/cli/run-eval.js +38 -0
- package/scripts/cli/run-skill-evals.js +13 -2
- package/scripts/derivar-feature-list.js +15 -14
- package/scripts/desinstalar.js +11 -0
- package/scripts/doctor.js +50 -13
- package/scripts/evidencia-valor.js +101 -101
- package/scripts/field-report.js +16 -16
- package/scripts/instalador.js +98 -7
- package/scripts/lib/activar-hooks-proyecto.js +116 -104
- package/scripts/lib/auditar-invocaciones-comandos.js +96 -6
- package/scripts/lib/ciclo-autonomo/candidatos.js +174 -0
- package/scripts/lib/ciclo-autonomo/config.js +165 -0
- package/scripts/lib/ciclo-autonomo/drenador-feedback.js +174 -0
- package/scripts/lib/ciclo-autonomo/fallback.js +77 -0
- package/scripts/lib/ciclo-autonomo/guard-convivencia.js +139 -0
- package/scripts/lib/ciclo-autonomo/higiene-nudges.js +112 -0
- package/scripts/lib/ciclo-autonomo/index.js +301 -0
- package/scripts/lib/ciclo-autonomo/lock.js +124 -0
- package/scripts/lib/ciclo-autonomo/presupuesto.js +122 -0
- package/scripts/lib/ciclo-autonomo/puente-degradacion.js +240 -0
- package/scripts/lib/ciclo-autonomo/runner-fase-b.js +248 -0
- package/scripts/lib/ciclo-autonomo/writer-instintos.js +190 -0
- package/scripts/lib/ciclo-autonomo/yaml-instintos.js +535 -0
- package/scripts/lib/estado.js +9 -0
- package/scripts/lib/evidencia-valor.js +228 -228
- package/scripts/lib/expandir-targets.js +71 -71
- package/scripts/lib/gitignore-manifest.js +8 -1
- package/scripts/lib/hooks-settings.js +45 -0
- package/scripts/lib/limpiar-basura-global.js +161 -0
- package/scripts/lib/toml-merge.js +204 -204
- package/scripts/mcp-server/auth.js +105 -105
- package/scripts/mcp-server/cache.js +106 -106
- package/scripts/rotar-audit-logs.js +48 -2
- package/scripts/run-eval.js +1 -0
- package/scripts/run-skill-evals.js +287 -8
- package/scripts/smoke-test.js +16 -8
- package/scripts/tui/pantallas/install-wizard.js +69 -13
- 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
|
-
*
|
|
63
|
-
*
|
|
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
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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.
|
|
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 `<b>` 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
|
|
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,
|
|
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
|
-
//
|
|
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
|
|
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,
|
package/hooks/registro-turnos.js
CHANGED
|
@@ -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
|
package/hooks/resumen-sesion.js
CHANGED
|
@@ -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'],
|
package/hooks/risk-scoring.js
CHANGED
|
@@ -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
|
-
|
|
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, '
|
|
44
|
+
const DIR_ARCHIVO = path.join(DIR_PLANNING, 'archive', 'audit');
|
|
40
45
|
const MARCA_ULTIMA_ROTACION = path.join(DIR_ARCHIVO, '.ultima-rotacion');
|
|
41
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|