@saulwade/swl-ses 2.5.2 → 2.6.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 (183) hide show
  1. package/CLAUDE.md +194 -192
  2. package/README.md +600 -600
  3. package/agentes/auto-evolucion-swl.md +27 -3
  4. package/bin/swl-ses.js +32 -6
  5. package/comandos/swl/actualizar.md +174 -174
  6. package/comandos/swl/adoptar-proyecto.md +265 -265
  7. package/comandos/swl/aprender.md +836 -823
  8. package/comandos/swl/aprobar-plan.md +146 -146
  9. package/comandos/swl/auditar-deps.md +134 -134
  10. package/comandos/swl/autoresearch.md +264 -264
  11. package/comandos/swl/ayuda.md +224 -224
  12. package/comandos/swl/brainstorm.md +51 -51
  13. package/comandos/swl/briefing.md +119 -119
  14. package/comandos/swl/checkpoint.md +325 -325
  15. package/comandos/swl/claudemd.md +234 -234
  16. package/comandos/swl/compactar.md +310 -310
  17. package/comandos/swl/configurar-ci.md +235 -235
  18. package/comandos/swl/contexto.md +110 -110
  19. package/comandos/swl/contribuir.md +233 -233
  20. package/comandos/swl/crear-skill.md +292 -292
  21. package/comandos/swl/cron.md +194 -194
  22. package/comandos/swl/deuda-codigo.md +97 -97
  23. package/comandos/swl/discutir-fase.md +169 -169
  24. package/comandos/swl/ejecutar-fase.md +233 -233
  25. package/comandos/swl/evaluar-skill.md +520 -505
  26. package/comandos/swl/evolucion-continua.md +73 -0
  27. package/comandos/swl/evolucionar.md +267 -254
  28. package/comandos/swl/exportar-vault.md +583 -583
  29. package/comandos/swl/fix.md +118 -118
  30. package/comandos/swl/gateway.md +158 -158
  31. package/comandos/swl/inbox.md +116 -116
  32. package/comandos/swl/instalar.md +220 -220
  33. package/comandos/swl/instintos.md +86 -86
  34. package/comandos/swl/mapear-codebase.md +312 -312
  35. package/comandos/swl/mcp-status.md +175 -175
  36. package/comandos/swl/modelo.md +100 -100
  37. package/comandos/swl/nemesis.md +433 -433
  38. package/comandos/swl/notificaciones.md +299 -299
  39. package/comandos/swl/nuevo-proyecto.md +251 -251
  40. package/comandos/swl/planear-fase.md +263 -263
  41. package/comandos/swl/plugins.md +256 -256
  42. package/comandos/swl/predecir.md +169 -169
  43. package/comandos/swl/reflect-skills.md +125 -125
  44. package/comandos/swl/release.md +450 -450
  45. package/comandos/swl/revisar-impacto.md +201 -201
  46. package/comandos/swl/revisar.md +330 -330
  47. package/comandos/swl/seguridad.md +189 -189
  48. package/comandos/swl/sesiones.md +200 -200
  49. package/comandos/swl/skill-search.md +113 -113
  50. package/comandos/swl/status.md +343 -343
  51. package/comandos/swl/verificar.md +817 -817
  52. package/comandos/swl/wiki.md +620 -620
  53. package/gateway/cron/jobs.example.json +12 -0
  54. package/habilidades/auto-evolucion-protocolo/SKILL.md +294 -276
  55. package/habilidades/autoresearch/SKILL.md +3 -2
  56. package/habilidades/benchmark-memoria/SKILL.md +7 -7
  57. package/habilidades/changelog-generator/SKILL.md +174 -174
  58. package/habilidades/changelog-generator/scripts/parse-commits.js +2 -1
  59. package/habilidades/checkpoints-verificacion/SKILL.md +6 -0
  60. package/habilidades/context-builder/SKILL.md +4 -0
  61. package/habilidades/doubt-driven-review/SKILL.md +207 -191
  62. package/habilidades/drift-detection/SKILL.md +6 -1
  63. package/habilidades/ejecutar-fase/SKILL.md +6 -6
  64. package/habilidades/eval-framework/SKILL.md +8 -3
  65. package/habilidades/harness-claude-code/SKILL.md +314 -308
  66. package/habilidades/infra-github-actions/SKILL.md +4 -3
  67. package/habilidades/instalar-sistema/SKILL.md +227 -223
  68. package/habilidades/memoria-busqueda/SKILL.md +31 -39
  69. package/habilidades/planear-fase/SKILL.md +358 -350
  70. package/habilidades/proceso-ddia-fundamentos/SKILL.md +3 -2
  71. package/habilidades/release-semver/SKILL.md +4 -2
  72. package/habilidades/swl-claudemd/SKILL.md +6 -7
  73. package/habilidades/swl-dashboard/SKILL.md +11 -43
  74. package/habilidades/tdd-workflow/SKILL.md +749 -744
  75. package/habilidades/validacion-ci-sistema/SKILL.md +1 -1
  76. package/hooks/agente-lifecycle.js +2 -1
  77. package/hooks/aiisms-detector.js +13 -4
  78. package/hooks/audit-trail.js +2 -1
  79. package/hooks/auto-consolidacion.js +2 -1
  80. package/hooks/captura-acciones-post.js +2 -1
  81. package/hooks/captura-acciones-session.js +2 -1
  82. package/hooks/captura-feedback-usuario.js +3 -2
  83. package/hooks/claudemd-bloat-detector.js +12 -3
  84. package/hooks/claudemd-duplicacion-detector.js +13 -3
  85. package/hooks/contexto-iteracion.js +2 -1
  86. package/hooks/degradacion-instintos.js +2 -1
  87. package/hooks/extraccion-aprendizajes.js +109 -15
  88. package/hooks/grafo-contexto.js +2 -1
  89. package/hooks/guardrail-modelo.js +2 -1
  90. package/hooks/inbox-aviso.js +2 -1
  91. package/hooks/inyeccion-contexto.js +2 -1
  92. package/hooks/lib/agent-matcher.js +2 -1
  93. package/hooks/lib/agent-routing.js +2 -1
  94. package/hooks/lib/autonomia.js +5 -3
  95. package/hooks/lib/captura-acciones.js +2 -1
  96. package/hooks/lib/consolidation-lock.js +21 -10
  97. package/hooks/lib/etapa-auto-evolucion.js +10 -4
  98. package/hooks/lib/etapa-metricas.js +2 -1
  99. package/hooks/lib/etapa-perfil-usuario.js +20 -4
  100. package/hooks/lib/evolution-tracker.js +2 -1
  101. package/hooks/lib/gateway-notify.js +193 -179
  102. package/hooks/lib/loop-telemetry.js +5 -4
  103. package/hooks/lib/mcp-health.js +2 -1
  104. package/hooks/lib/memory-search.js +4 -0
  105. package/hooks/lib/merkle-audit.js +58 -6
  106. package/hooks/lib/nudge-tracker.js +2 -1
  107. package/hooks/lib/otlp-exporter.js +2 -1
  108. package/hooks/lib/propose-step.js +3 -2
  109. package/hooks/lib/raiz-proyecto.js +102 -0
  110. package/hooks/lib/run-log.js +2 -1
  111. package/hooks/lib/singleton-guard.js +218 -27
  112. package/hooks/lib/telegram-cliente.js +17 -8
  113. package/hooks/preservar-estado-pre-compact.js +2 -1
  114. package/hooks/proteccion-rutas.js +59 -3
  115. package/hooks/registro-turnos.js +2 -1
  116. package/hooks/resumen-sesion.js +2 -1
  117. package/hooks/risk-scoring.js +2 -1
  118. package/hooks/rotar-audit-auto.js +46 -20
  119. package/hooks/session-briefing.js +127 -1
  120. package/hooks/spec-gate.js +2 -1
  121. package/hooks/sugerir-contribuir.js +6 -3
  122. package/hooks/sugerir-regenerar-inventario.js +3 -2
  123. package/hooks/tdd-gate.js +2 -1
  124. package/hooks/telemetria-agentes.js +2 -1
  125. package/hooks/telemetria-skill-routing.js +2 -1
  126. package/hooks/tracking-costos.js +4 -3
  127. package/hooks/validar-formato-post-subagente.js +2 -1
  128. package/hooks/validar-intent-spec.js +2 -1
  129. package/hooks/validar-memoria-hook.js +13 -3
  130. package/hooks/validar-planning-paths.js +2 -1
  131. package/instintos/.backups/perfil-usuario.yaml.2026-07-10-165128.bak +53 -0
  132. package/instintos/.backups/proyecto.yaml.2026-07-10-165128.bak +372 -0
  133. package/instintos/perfil-usuario.yaml +506 -3
  134. package/instintos/proyecto.yaml +78 -0
  135. package/llms.txt +2 -2
  136. package/manifiestos/canonical-hashes.json +664 -2
  137. package/manifiestos/modulos.json +19 -14
  138. package/manifiestos/planning-paths.json +1 -0
  139. package/manifiestos/skills-lock.json +53 -53
  140. package/package.json +2 -3
  141. package/plugin.json +2 -2
  142. package/scripts/actualizar.js +3 -0
  143. package/scripts/auditar-clases-conocidas.js +32 -4
  144. package/scripts/benchmark-memoria.js +1 -0
  145. package/scripts/cli/autonomia.js +23 -0
  146. package/scripts/cli/benchmark-memoria.js +37 -0
  147. package/scripts/cli/ciclo-autonomo.js +73 -0
  148. package/scripts/cli/ciclo-fase-b.js +102 -0
  149. package/scripts/cli/guardrail-metrics.js +39 -0
  150. package/scripts/cli/loop-telemetry.js +4 -2
  151. package/scripts/cli/memoria-search.js +69 -0
  152. package/scripts/cli/nudge-accionar.js +39 -0
  153. package/scripts/cli/run-eval.js +38 -0
  154. package/scripts/cli/run-skill-evals.js +13 -2
  155. package/scripts/derivar-feature-list.js +15 -14
  156. package/scripts/desinstalar.js +11 -0
  157. package/scripts/doctor.js +24 -10
  158. package/scripts/instalador.js +106 -7
  159. package/scripts/lib/activar-hooks-proyecto.js +116 -0
  160. package/scripts/lib/auditar-invocaciones-comandos.js +96 -6
  161. package/scripts/lib/ciclo-autonomo/candidatos.js +174 -0
  162. package/scripts/lib/ciclo-autonomo/config.js +165 -0
  163. package/scripts/lib/ciclo-autonomo/drenador-feedback.js +174 -0
  164. package/scripts/lib/ciclo-autonomo/fallback.js +77 -0
  165. package/scripts/lib/ciclo-autonomo/guard-convivencia.js +139 -0
  166. package/scripts/lib/ciclo-autonomo/higiene-nudges.js +112 -0
  167. package/scripts/lib/ciclo-autonomo/index.js +301 -0
  168. package/scripts/lib/ciclo-autonomo/lock.js +124 -0
  169. package/scripts/lib/ciclo-autonomo/presupuesto.js +122 -0
  170. package/scripts/lib/ciclo-autonomo/puente-degradacion.js +240 -0
  171. package/scripts/lib/ciclo-autonomo/runner-fase-b.js +248 -0
  172. package/scripts/lib/ciclo-autonomo/writer-instintos.js +190 -0
  173. package/scripts/lib/ciclo-autonomo/yaml-instintos.js +535 -0
  174. package/scripts/lib/estado.js +9 -0
  175. package/scripts/lib/evidencia-valor.js +1 -1
  176. package/scripts/lib/gitignore-manifest.js +8 -1
  177. package/scripts/lib/hooks-settings.js +45 -0
  178. package/scripts/rotar-audit-logs.js +48 -2
  179. package/scripts/run-eval.js +1 -0
  180. package/scripts/run-skill-evals.js +287 -8
  181. package/scripts/smoke-test.js +16 -8
  182. package/scripts/tui/pantallas/install-wizard.js +403 -347
  183. package/scripts/validar.js +40 -1
@@ -18,8 +18,34 @@
18
18
  const crypto = require('crypto');
19
19
  const fs = require('fs');
20
20
 
21
- function _sha256(data) {
22
- return crypto.createHash('sha256').update(data).digest('hex');
21
+ /**
22
+ * Hash de cada entrada de la cadena. Si `SWL_AUDIT_HMAC_KEY` está definida, usa
23
+ * HMAC-SHA256 con esa clave; si no, SHA256 plano (default zero-config).
24
+ *
25
+ * MODELO DE AMENAZA (resuelve el residual "SHA256 sin clave"):
26
+ * - SIN clave (default): la cadena es tamper-EVIDENTE ante corrupción
27
+ * accidental (escrituras parciales, error de disco, edición por una
28
+ * herramienta buggy) y ediciones parciales. NO defiende contra quien tenga
29
+ * acceso de escritura Y pueda recomputar hashes — pero en una máquina local
30
+ * de un solo usuario ESE es el dueño, que es la raíz de confianza: no hay
31
+ * adversario distinto del dueño. Correcto por diseño para audit local.
32
+ * - CON clave en env (opt-in): defiende contra un adversario a nivel de
33
+ * ARCHIVO —dependencia npm maliciosa, ransomware, conflicto de sync— que
34
+ * puede escribir `.planning/` pero NO leer el entorno del proceso del hook.
35
+ * Sin la clave del env no puede forjar HMACs válidos → la manipulación se
36
+ * detecta. NO defiende contra quien lea el env del proceso (el dueño);
37
+ * para eso hace falta anclaje externo (publicar el último hash en un store
38
+ * append-only inmutable), fuera del alcance de este módulo.
39
+ * - La clave vive en el ENV (no en disco): `setx SWL_AUDIT_HMAC_KEY <valor>`
40
+ * o export. Cambiar la clave inicia una época nueva e invalida cadenas
41
+ * previas (esperado). Cargar la clave desde el env en cada llamada para
42
+ * respetar rotación sin reiniciar procesos de larga vida.
43
+ */
44
+ function _hash(data) {
45
+ const clave = process.env.SWL_AUDIT_HMAC_KEY;
46
+ return clave
47
+ ? crypto.createHmac('sha256', clave).update(data).digest('hex')
48
+ : crypto.createHash('sha256').update(data).digest('hex');
23
49
  }
24
50
 
25
51
  function crearEntrada(datos, hashAnterior) {
@@ -32,7 +58,7 @@ function crearEntrada(datos, hashAnterior) {
32
58
  result: datos.result || 'ok',
33
59
  prev: hashAnterior,
34
60
  });
35
- const hash = _sha256(payload);
61
+ const hash = _hash(payload);
36
62
  return { payload, hash };
37
63
  }
38
64
 
@@ -56,12 +82,38 @@ function appendAudit(rutaArchivo, datos) {
56
82
  return hash;
57
83
  }
58
84
 
59
- function verificarCadena(rutaArchivo) {
85
+ /**
86
+ * Verifica la integridad de la cadena Merkle de un archivo de auditoría.
87
+ *
88
+ * @param {string} rutaArchivo
89
+ * @param {object} [opts]
90
+ * @param {string} [opts.hashInicial] - Hash del que la cadena continúa. Por
91
+ * defecto el génesis ('0'*64). Tras una rotación, el archivo activo empieza
92
+ * en una entrada cuyo `prev` apunta a una entrada ya archivada (no al
93
+ * génesis) — pasar aquí el `hashInicial` del checkpoint
94
+ * (`.checkpoint-<archivo>.json`) para validar el activo como CONTINUACIÓN de
95
+ * la cadena archivada, sin falso positivo de rotura (nemesis iter-1 S3-5).
96
+ *
97
+ * LÍMITE (re-auditoría nemesis iter-2): con `hashInicial` esto prueba la
98
+ * consistencia INTERNA del activo, NO que continúe la cadena archivada — el
99
+ * check `prev===hashInicial` de la entrada-0 es tautológico si `hashInicial`
100
+ * viene del propio checkpoint auto-derivado. Un consumidor que quiera probar
101
+ * la continuidad archivo↔activo debe recomputar independientemente el hash de
102
+ * la última entrada del `.gz` archivado y verificar que coincide con
103
+ * `hashInicial`. La fuerza criptográfica de la cadena depende de `_hash`
104
+ * (ver su docstring): SHA256 plano (accidental) o HMAC con `SWL_AUDIT_HMAC_KEY`
105
+ * (defensa contra manipulación a nivel de archivo sin la clave del env).
106
+ * verificarCadena usa la MISMA función `_hash`, así que recomputa con la
107
+ * clave activa del env — verificar con la clave con que se escribió.
108
+ */
109
+ function verificarCadena(rutaArchivo, opts = {}) {
60
110
  if (!fs.existsSync(rutaArchivo)) return { valido: true, entradas: 0 };
61
111
 
62
112
  const contenido = fs.readFileSync(rutaArchivo, 'utf8').trim();
63
113
  const lineas = contenido.split('\n').filter(Boolean);
64
- let hashEsperado = '0'.repeat(64);
114
+ let hashEsperado = (typeof opts.hashInicial === 'string' && opts.hashInicial)
115
+ ? opts.hashInicial
116
+ : '0'.repeat(64);
65
117
  let roturaEn = -1;
66
118
 
67
119
  for (let i = 0; i < lineas.length; i++) {
@@ -73,7 +125,7 @@ function verificarCadena(rutaArchivo) {
73
125
  }
74
126
  const { hash: storedHash, ...resto } = entrada;
75
127
  const payloadRecalc = JSON.stringify(resto);
76
- const hashRecalc = _sha256(payloadRecalc);
128
+ const hashRecalc = _hash(payloadRecalc);
77
129
  if (hashRecalc !== storedHash) {
78
130
  roturaEn = i;
79
131
  break;
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  const fs = require('fs');
25
+ const { raizProyecto } = require('./raiz-proyecto');
25
26
  const path = require('path');
26
27
 
27
28
  // Escritura atómica para reescritura completa del JSONL (marcar accionado).
@@ -45,7 +46,7 @@ try {
45
46
  // Configuración
46
47
  // ---------------------------------------------------------------------------
47
48
 
48
- const DIR_EVOL = path.join(process.cwd(), '.planning', 'evolution');
49
+ const DIR_EVOL = path.join(raizProyecto(process.cwd()), '.planning', 'evolution');
49
50
  const LOG_PATH = path.join(DIR_EVOL, 'nudges.jsonl');
50
51
  const ALERT_PATH = path.join(DIR_EVOL, 'alertas-persistentes.json');
51
52
 
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  const fs = require('fs');
22
+ const { raizProyecto } = require('./raiz-proyecto');
22
23
  const path = require('path');
23
24
  const crypto = require('crypto');
24
25
  const http = require('http');
@@ -277,7 +278,7 @@ class OtlpLocalProcessor extends TracingProcessor {
277
278
  constructor(cwd) {
278
279
  super();
279
280
  /** @type {string} */
280
- this._cwd = String(cwd || process.cwd());
281
+ this._cwd = String(cwd || raizProyecto(process.cwd()));
281
282
  }
282
283
 
283
284
  /**
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  const fs = require('fs');
23
+ const { raizProyecto } = require('./raiz-proyecto');
23
24
  const path = require('path');
24
25
  const { execFileSync } = require('child_process');
25
26
 
@@ -209,7 +210,7 @@ const UMBRAL = {
209
210
  };
210
211
 
211
212
  function telemetriaPath(baseDir) {
212
- return path.join(baseDir || process.cwd(), ...TELE_PATH);
213
+ return path.join(baseDir || raizProyecto(process.cwd()), ...TELE_PATH);
213
214
  }
214
215
 
215
216
  function _catVacia() {
@@ -332,7 +333,7 @@ function main(argv) {
332
333
  for (const a of args) {
333
334
  if (a.startsWith('--rango=')) rango = a.slice('--rango='.length);
334
335
  }
335
- const baseDir = process.cwd();
336
+ const baseDir = raizProyecto(process.cwd());
336
337
  const { paths, diff } = _gitDiff(rango);
337
338
  const { señales } = evaluarSenales(paths, diff);
338
339
  registrarPropose(baseDir, señales);
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * raiz-proyecto.js — Resolución de la raíz del proyecto para hooks que
5
+ * escriben telemetría/memoria en `.planning/`.
6
+ *
7
+ * El CWD de arranque de una sesión NO es la frontera del proyecto: en un
8
+ * monorepo con sesiones abiertas en subdirectorios (`backend/`, `frontend/`),
9
+ * anclar `.planning/` a `process.cwd()` fragmenta la memoria del proyecto en
10
+ * árboles huérfanos (DT-HOOKS-RAIZ-GIT; misma clase que el fix de
11
+ * proteccion-rutas). La frontera correcta es la raíz del repositorio git,
12
+ * con una excepción: un sub-proyecto anidado que ya tiene su propio
13
+ * `.planning/` REAL (con marcadores de proyecto) conserva su árbol local.
14
+ *
15
+ * Reglas de resolución (en orden):
16
+ * 1. Ascendiendo desde `desde`, el primer directorio cuyo `.planning/`
17
+ * contiene un marcador de proyecto (PROYECTO.md, HOJA-RUTA.md,
18
+ * ESTADO.md) gana — respeta sub-proyectos legítimos en monorepos.
19
+ * Un `.planning/` sin marcadores (solo telemetría huérfana de hooks)
20
+ * NO cuenta: eso es el síntoma del bug, no un proyecto.
21
+ * 2. Si ningún nivel tiene `.planning/` real, gana la raíz del repo git
22
+ * (`.git` como directorio o como archivo — worktrees).
23
+ * 3. Sin `.git` alcanzable: fallback al propio `desde`.
24
+ *
25
+ * La ascensión se detiene en el HOME del usuario (sin incluirlo) cuando se
26
+ * llega ahí subiendo: un dotfiles-repo en `~` no debe capturar la memoria
27
+ * de un proyecto sin git que viva debajo.
28
+ *
29
+ * Zero-deps (regla de hooks/lib/). Consumidor inicial:
30
+ * hooks/extraccion-aprendizajes.js; la flota completa migró el 2026-07-10
31
+ * (cierre de DT-HOOKS-RAIZ-GIT).
32
+ *
33
+ * LÍMITE DE USO (hallazgo revisor-seguridad 2026-07-10): esta ancla es SOLO
34
+ * para DATOS (.planning/, instintos/, lecturas de config del proyecto).
35
+ * NUNCA resolver con ella EJECUTABLES ni módulos a require()/spawn(): la
36
+ * raíz resuelta pertenece al árbol del proyecto, y un repositorio no
37
+ * confiable puede plantar el script (marcador .planning/PROYECTO.md +
38
+ * scripts/x.js hostil = ejecución de código al disparar el hook). Los
39
+ * ejecutables de la distribución SWL se resuelven desde __dirname
40
+ * (ubicación instalada del hook). Test guardián:
41
+ * tests/hooks/ejecutables-desde-instalacion.test.js.
42
+ */
43
+
44
+ const fs = require('fs');
45
+ const path = require('path');
46
+ const os = require('os');
47
+
48
+ const MARCADORES_PROYECTO = ['PROYECTO.md', 'HOJA-RUTA.md', 'ESTADO.md'];
49
+ const MAX_NIVELES = 40;
50
+
51
+ /**
52
+ * ¿El directorio tiene un `.planning/` de proyecto real (no huérfano)?
53
+ * @param {string} dir
54
+ * @returns {boolean}
55
+ */
56
+ function tienePlanningReal(dir) {
57
+ try {
58
+ return MARCADORES_PROYECTO.some(m =>
59
+ fs.existsSync(path.join(dir, '.planning', m))
60
+ );
61
+ } catch (_) {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Resuelve la raíz del proyecto a la que deben anclarse las escrituras
68
+ * de `.planning/`.
69
+ *
70
+ * @param {string} [desde] - Directorio de partida (default: process.cwd()).
71
+ * @returns {string} Ruta absoluta de la raíz del proyecto.
72
+ */
73
+ function raizProyecto(desde) {
74
+ const inicio = path.resolve(desde || process.cwd());
75
+ let actual = inicio;
76
+ let homedir = null;
77
+ try {
78
+ homedir = path.resolve(os.homedir());
79
+ } catch (_) { /* sin home resolvible — seguir sin el guard */ }
80
+
81
+ for (let i = 0; i < MAX_NIVELES; i++) {
82
+ // Frontera del HOME: si llegamos aquí ASCENDIENDO, no seguir — un
83
+ // dotfiles-repo en ~ no es la raíz de un proyecto que vive debajo.
84
+ if (homedir && actual === homedir && i > 0) break;
85
+
86
+ if (tienePlanningReal(actual)) return actual;
87
+
88
+ try {
89
+ if (fs.existsSync(path.join(actual, '.git'))) return actual;
90
+ } catch (_) {
91
+ break;
92
+ }
93
+
94
+ const padre = path.dirname(actual);
95
+ if (padre === actual) break; // raíz del filesystem
96
+ actual = padre;
97
+ }
98
+
99
+ return inicio;
100
+ }
101
+
102
+ module.exports = { raizProyecto, tienePlanningReal };
@@ -38,6 +38,7 @@
38
38
  */
39
39
 
40
40
  const fs = require('fs');
41
+ const { raizProyecto } = require('./raiz-proyecto');
41
42
  const path = require('path');
42
43
  const readline = require('readline');
43
44
  const os = require('os');
@@ -50,7 +51,7 @@ const os = require('os');
50
51
  * Directorio donde se almacenan los JSONL de runs.
51
52
  * Relativo al cwd del proceso (raíz del proyecto SWL).
52
53
  */
53
- const RUNS_DIR = path.join(process.cwd(), '.planning', 'runs');
54
+ const RUNS_DIR = path.join(raizProyecto(process.cwd()), '.planning', 'runs');
54
55
 
55
56
  /**
56
57
  * Número máximo de runs a mantener por directorio.
@@ -25,9 +25,10 @@
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;
@@ -59,8 +60,58 @@ function pidVivo(pid) {
59
60
  }
60
61
 
61
62
  /**
62
- * Intenta adquirir el lock. Retorna true si se adquirió, false si otro
63
- * proceso lo tiene activo.
63
+ * Escribe el lockfile con exclusión atómica (O_EXCL vía flag 'wx'): falla con
64
+ * EEXIST si el archivo ya existe, sin ventana check-then-write. Es lo que
65
+ * garantiza la exclusión mutua real entre DOS procesos simultáneos —
66
+ * fs.existsSync + fs.writeFileSync NO la garantiza (ambos ven no-existe y
67
+ * ambos escriben; hallazgo re-auditoría nemesis iter-2).
68
+ *
69
+ * @param {string} ruta
70
+ * @param {string} nombre
71
+ * @returns {boolean} true si este proceso creó el lock.
72
+ */
73
+ function crearLockExclusivo(ruta, nombre) {
74
+ try {
75
+ const fd = fs.openSync(ruta, 'wx'); // O_CREAT | O_EXCL | O_WRONLY
76
+ try {
77
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, ts: new Date().toISOString(), nombre }));
78
+ } finally {
79
+ fs.closeSync(fd);
80
+ }
81
+ return true;
82
+ } catch (err) {
83
+ if (err.code === 'EEXIST') return false;
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * ¿Un CONTENIDO de lock ya leído es tomable? (proceso muerto, edad > staleMs,
90
+ * o contenido corrupto/nulo). Trabaja sobre el objeto, no re-lee el archivo —
91
+ * clave para el takeover: la decisión de remover se toma sobre el MISMO
92
+ * contenido que se acaba de leer bajo el guard, sin un segundo read que
93
+ * reintroduzca TOCTOU.
94
+ * @returns {boolean}
95
+ */
96
+ function contenidoTomable(contenido, staleMs) {
97
+ if (!contenido || typeof contenido.pid === 'undefined') return true; // corrupto/nulo → tomable
98
+ const edad = Date.now() - Date.parse(contenido.ts);
99
+ const vivo = pidVivo(contenido.pid);
100
+ return !(vivo && edad < staleMs);
101
+ }
102
+
103
+ /**
104
+ * ¿El lock existente (en disco) es tomable? Lee el archivo y delega en
105
+ * contenidoTomable.
106
+ * @returns {boolean}
107
+ */
108
+ function lockTomable(ruta, staleMs) {
109
+ return contenidoTomable(leerLock(ruta), staleMs);
110
+ }
111
+
112
+ /**
113
+ * Intenta adquirir el lock con exclusión ATÓMICA (O_EXCL). Retorna true si se
114
+ * adquirió, false si otro proceso lo tiene activo.
64
115
  *
65
116
  * @param {string} nombre - Identificador del lock (ej: 'auto-evolucion').
66
117
  * @param {object} [opts]
@@ -72,38 +123,113 @@ function adquirir(nombre, opts = {}) {
72
123
  ensureDir();
73
124
  const ruta = rutaLock(nombre);
74
125
 
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);
126
+ // Caso primario: crear el lock de forma atómica. Dos procesos frescos
127
+ // simultáneos sobre un lock inexistente → solo UNO gana el O_EXCL.
128
+ if (crearLockExclusivo(ruta, nombre)) {
129
+ locksAdquiridos.add(nombre);
130
+ registrarHandlersCleanup();
131
+ return true;
132
+ }
81
133
 
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
134
+ // El lock existe. Si parece tomable (muerto/stale/corrupto), removerlo de
135
+ // forma SERIALIZADA (ver takeoverStaleSerializado) y reintentar el O_EXCL.
136
+ if (lockTomable(ruta, staleMs)) {
137
+ takeoverStaleSerializado(ruta, staleMs);
138
+ if (crearLockExclusivo(ruta, nombre)) {
139
+ locksAdquiridos.add(nombre);
140
+ registrarHandlersCleanup();
141
+ return true;
89
142
  }
90
143
  }
91
144
 
92
- // Escribir lock con PID actual
145
+ return false;
146
+ }
147
+
148
+ /** Lee el contenido {pid, ts} del lock, o null si no existe/corrupto. */
149
+ function leerLock(ruta) {
93
150
  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;
151
+ return JSON.parse(fs.readFileSync(ruta, 'utf8'));
102
152
  } catch {
103
- return false;
153
+ return null;
104
154
  }
105
155
  }
106
156
 
157
+ // Un token de takeover se considera leaked (dueño muerto a mitad) si supera
158
+ // esto: la sección crítica del takeover es de µs, así que segundos = fuga.
159
+ const TAKEOVER_TOKEN_STALE_MS = 5000;
160
+
161
+ /**
162
+ * Remueve un lock stale de forma SERIALIZADA y sin ventana de create fresco.
163
+ *
164
+ * La clase de bug que dos rondas de re-auditoría destaparon: cualquier diseño
165
+ * que "saque" el stale del path (unlink ciego, rename-out) abre una ventana en
166
+ * la que `ruta` queda libre y un proceso fresco puede colarse con O_EXCL, y
167
+ * luego el remover restaura/recrea encima → DOS holders. El fix de CLASE:
168
+ *
169
+ * 1. Serializar el takeover con un token O_EXCL (`<ruta>.takeover`): solo UN
170
+ * taker entra a la sección crítica; los demás salen (otro lo maneja).
171
+ * 2. Dentro del token, re-leer `ruta` y `unlinkSync` SOLO si sigue tomable
172
+ * (muerto/stale/corrupto). `ruta` está PRESENTE hasta ese unlink → ningún
173
+ * create fresco puede colarse antes (falla EEXIST). Si el contenido pasó a
174
+ * ser un lock VIVO distinto (otro tomó posesión legítima), NO se toca.
175
+ *
176
+ * Tras esto, el `crearLockExclusivo` del caller compite limpio por O_EXCL: si
177
+ * un proceso fresco ganó la ventana unlink→create, el caller falla EEXIST en
178
+ * vez de clobberear. Nunca dos holders.
179
+ *
180
+ * El token puede quedar leaked si el taker muere a mitad; se reclama por edad
181
+ * (>TAKEOVER_TOKEN_STALE_MS), acotando el bloqueo a segundos.
182
+ *
183
+ * @param {string} ruta
184
+ * @param {number} staleMs
185
+ */
186
+ function takeoverStaleSerializado(ruta, staleMs) {
187
+ const token = `${ruta}.takeover`;
188
+ if (!adquirirTokenTakeover(token)) return; // otro taker está removiendo el stale
189
+ try {
190
+ const actual = leerLock(ruta);
191
+ if (contenidoTomable(actual, staleMs)) {
192
+ try { fs.unlinkSync(ruta); } catch { /* ya removido */ }
193
+ }
194
+ // else: `ruta` pasó a ser un lock VIVO legítimo — no tocar.
195
+ } finally {
196
+ try { fs.unlinkSync(token); } catch { /* nada */ }
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Adquiere el token de takeover (O_EXCL). Si existe pero está leaked, lo reclama
202
+ * una vez. Un token queda leaked SOLO si el taker murió dentro de su sección
203
+ * crítica (leer+unlink, del orden de µs). Dos señales de leak, en orden:
204
+ * 1. pid del token MUERTO → el taker crasheó → reclamo INSTANTÁNEO (sin
205
+ * esperar TAKEOVER_TOKEN_STALE_MS). Es el caso realista (SIGKILL/crash) y
206
+ * con esto la ventana de bloqueo del leak baja de segundos a ~0.
207
+ * 2. pid vivo pero token viejo (>TAKEOVER_TOKEN_STALE_MS) → taker atascado
208
+ * (patológico para una sección de µs) → reclamo por edad.
209
+ * El reclamo final es un crearLockExclusivo (O_EXCL): si dos takers reclaman a
210
+ * la vez, solo uno crea el token. Límite inherente (documentado): con locks de
211
+ * archivo zero-dep no hay exclusión mutua PERFECTA para el reclamo de stale
212
+ * —igual que proper-lockfile—; el riesgo residual es una carrera compuesta
213
+ * (leak en µs + doble-reclamo simultáneo) de probabilidad despreciable.
214
+ * @returns {boolean}
215
+ */
216
+ function adquirirTokenTakeover(token) {
217
+ if (crearLockExclusivo(token, 'takeover')) return true;
218
+ const contenido = leerLock(token);
219
+ const pidMuerto = contenido && typeof contenido.pid !== 'undefined' && !pidVivo(contenido.pid);
220
+ let viejo = false;
221
+ try {
222
+ viejo = (Date.now() - fs.statSync(token).mtimeMs) > TAKEOVER_TOKEN_STALE_MS;
223
+ } catch {
224
+ return crearLockExclusivo(token, 'takeover'); // desapareció entre create y stat → reintentar
225
+ }
226
+ if (pidMuerto || viejo) {
227
+ try { fs.unlinkSync(token); } catch { /* otro lo removió */ }
228
+ return crearLockExclusivo(token, 'takeover');
229
+ }
230
+ return false;
231
+ }
232
+
107
233
  /**
108
234
  * Libera el lock. Idempotente.
109
235
  *
@@ -123,6 +249,67 @@ function liberar(nombre) {
123
249
  locksAdquiridos.delete(nombre);
124
250
  }
125
251
 
252
+ /**
253
+ * Sleep SÍNCRONO sin quemar CPU (Atomics.wait sobre un buffer efímero).
254
+ * Los hooks son procesos cortos de un solo propósito — bloquear el event
255
+ * loop unos ms para serializar un RMW es aceptable y evita el busy-loop.
256
+ * @param {number} ms
257
+ */
258
+ function dormirSync(ms) {
259
+ try {
260
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
261
+ } catch {
262
+ // SharedArrayBuffer no disponible: degradar a busy-wait acotado.
263
+ const fin = Date.now() + ms;
264
+ while (Date.now() < fin) { /* espera */ }
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Ejecuta `fn` dentro de una sección crítica serializada por lock, con
270
+ * reintentos de adquisición (poll). Para RMW de archivos compartidos que NO
271
+ * deben perder datos por race (APRENDIZAJES.md) o cuyo incremento puede
272
+ * omitirse bajo contención (dirty.json).
273
+ *
274
+ * @param {string} nombre - Identificador del lock.
275
+ * @param {Function} fn - Trabajo a ejecutar dentro de la sección crítica.
276
+ * @param {object} [opts]
277
+ * @param {number} [opts.maxWaitMs=1000] - Tope total de espera para adquirir.
278
+ * @param {number} [opts.pollMs=25] - Intervalo entre intentos.
279
+ * @param {number} [opts.staleMs] - Se pasa a adquirir().
280
+ * @param {'ejecutar'|'omitir'} [opts.siNoAdquiere='ejecutar'] - Qué hacer si
281
+ * no se adquiere el lock dentro de maxWaitMs: `ejecutar` corre fn igual
282
+ * (best-effort, no pierde datos — la ventana de race ya se redujo al mínimo);
283
+ * `omitir` salta fn (el trabajo es descartable, ej. un contador).
284
+ * @returns {{ejecutado:boolean, conLock:boolean}}
285
+ */
286
+ function conLockRetry(nombre, fn, opts = {}) {
287
+ const maxWaitMs = opts.maxWaitMs != null ? opts.maxWaitMs : 1000;
288
+ const pollMs = opts.pollMs != null ? opts.pollMs : 25;
289
+ const siNoAdquiere = opts.siNoAdquiere || 'ejecutar';
290
+ const deadline = Date.now() + maxWaitMs;
291
+
292
+ let adquirido = adquirir(nombre, { staleMs: opts.staleMs });
293
+ while (!adquirido && Date.now() < deadline) {
294
+ dormirSync(pollMs);
295
+ adquirido = adquirir(nombre, { staleMs: opts.staleMs });
296
+ }
297
+
298
+ if (!adquirido) {
299
+ if (siNoAdquiere === 'omitir') return { ejecutado: false, conLock: false };
300
+ // best-effort: correr sin lock (mejor que perder el dato)
301
+ try { fn(); } finally { /* sin lock que liberar */ }
302
+ return { ejecutado: true, conLock: false };
303
+ }
304
+
305
+ try {
306
+ fn();
307
+ return { ejecutado: true, conLock: true };
308
+ } finally {
309
+ liberar(nombre);
310
+ }
311
+ }
312
+
126
313
  /**
127
314
  * Libera todos los locks adquiridos por este proceso.
128
315
  * Invocado por handlers de SIGTERM/SIGINT/exit.
@@ -155,5 +342,9 @@ module.exports = {
155
342
  adquirir,
156
343
  liberar,
157
344
  liberarTodos,
158
- _internals: { pidVivo, rutaLock, STALE_MS_DEFAULT },
345
+ conLockRetry,
346
+ _internals: {
347
+ pidVivo, rutaLock, dormirSync, crearLockExclusivo, lockTomable, contenidoTomable,
348
+ leerLock, takeoverStaleSerializado, adquirirTokenTakeover, STALE_MS_DEFAULT,
349
+ },
159
350
  };
@@ -117,9 +117,16 @@ 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.
@@ -133,21 +140,23 @@ async function enviarMensaje(token, chatId, texto, opts = {}) {
133
140
  for (let intento = 0; intento < REINTENTOS_MAX; intento++) {
134
141
  const resultado = await _intentarEnvio(token, chatId, textoPrepado, opts);
135
142
 
136
- if (resultado.cuerpo === 'timeout') {
137
- return { ok: false, statusCode: 0, error: 'timeout' };
138
- }
139
-
140
143
  if (resultado.ok) {
141
144
  return { ok: true, statusCode: resultado.statusCode };
142
145
  }
143
146
 
147
+ // Entrega INDETERMINADA (timeout o error de red): NO reintentar — un
148
+ // reintento duplicaría un mensaje que quizá ya se entregó.
149
+ if (resultado.statusCode === 0) {
150
+ return { ok: false, statusCode: 0, error: resultado.cuerpo === 'timeout' ? 'timeout' : 'network-error' };
151
+ }
152
+
144
153
  // Errores 4xx: no reintentar (credenciales inválidas, chat inexistente, etc.)
145
154
  if (resultado.statusCode >= 400 && resultado.statusCode < 500) {
146
155
  const etiqueta = resultado.statusCode === 401 ? 'unauthorized' : `http-${resultado.statusCode}`;
147
156
  return { ok: false, statusCode: resultado.statusCode, error: etiqueta };
148
157
  }
149
158
 
150
- // Error de red (statusCode 0) o 5xx: reintento con backoff exponencial
159
+ // Solo 5xx (falla de servidor con no-entrega): reintento con backoff.
151
160
  if (intento < REINTENTOS_MAX - 1) {
152
161
  await _esperar(BACKOFF_BASE_MS * Math.pow(2, intento));
153
162
  }
@@ -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