@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.
- package/CLAUDE.md +194 -192
- package/README.md +600 -600
- package/agentes/auto-evolucion-swl.md +27 -3
- package/bin/swl-ses.js +32 -6
- package/comandos/swl/actualizar.md +174 -174
- package/comandos/swl/adoptar-proyecto.md +265 -265
- package/comandos/swl/aprender.md +836 -823
- package/comandos/swl/aprobar-plan.md +146 -146
- package/comandos/swl/auditar-deps.md +134 -134
- package/comandos/swl/autoresearch.md +264 -264
- package/comandos/swl/ayuda.md +224 -224
- package/comandos/swl/brainstorm.md +51 -51
- package/comandos/swl/briefing.md +119 -119
- package/comandos/swl/checkpoint.md +325 -325
- package/comandos/swl/claudemd.md +234 -234
- package/comandos/swl/compactar.md +310 -310
- package/comandos/swl/configurar-ci.md +235 -235
- package/comandos/swl/contexto.md +110 -110
- package/comandos/swl/contribuir.md +233 -233
- package/comandos/swl/crear-skill.md +292 -292
- package/comandos/swl/cron.md +194 -194
- package/comandos/swl/deuda-codigo.md +97 -97
- package/comandos/swl/discutir-fase.md +169 -169
- package/comandos/swl/ejecutar-fase.md +233 -233
- package/comandos/swl/evaluar-skill.md +520 -505
- package/comandos/swl/evolucion-continua.md +73 -0
- package/comandos/swl/evolucionar.md +267 -254
- package/comandos/swl/exportar-vault.md +583 -583
- package/comandos/swl/fix.md +118 -118
- package/comandos/swl/gateway.md +158 -158
- package/comandos/swl/inbox.md +116 -116
- package/comandos/swl/instalar.md +220 -220
- package/comandos/swl/instintos.md +86 -86
- package/comandos/swl/mapear-codebase.md +312 -312
- package/comandos/swl/mcp-status.md +175 -175
- package/comandos/swl/modelo.md +100 -100
- package/comandos/swl/nemesis.md +433 -433
- package/comandos/swl/notificaciones.md +299 -299
- package/comandos/swl/nuevo-proyecto.md +251 -251
- package/comandos/swl/planear-fase.md +263 -263
- package/comandos/swl/plugins.md +256 -256
- package/comandos/swl/predecir.md +169 -169
- package/comandos/swl/reflect-skills.md +125 -125
- package/comandos/swl/release.md +450 -450
- package/comandos/swl/revisar-impacto.md +201 -201
- package/comandos/swl/revisar.md +330 -330
- package/comandos/swl/seguridad.md +189 -189
- package/comandos/swl/sesiones.md +200 -200
- package/comandos/swl/skill-search.md +113 -113
- package/comandos/swl/status.md +343 -343
- package/comandos/swl/verificar.md +817 -817
- package/comandos/swl/wiki.md +620 -620
- package/gateway/cron/jobs.example.json +12 -0
- package/habilidades/auto-evolucion-protocolo/SKILL.md +294 -276
- package/habilidades/autoresearch/SKILL.md +3 -2
- package/habilidades/benchmark-memoria/SKILL.md +7 -7
- package/habilidades/changelog-generator/SKILL.md +174 -174
- package/habilidades/changelog-generator/scripts/parse-commits.js +2 -1
- package/habilidades/checkpoints-verificacion/SKILL.md +6 -0
- package/habilidades/context-builder/SKILL.md +4 -0
- package/habilidades/doubt-driven-review/SKILL.md +207 -191
- package/habilidades/drift-detection/SKILL.md +6 -1
- package/habilidades/ejecutar-fase/SKILL.md +6 -6
- package/habilidades/eval-framework/SKILL.md +8 -3
- package/habilidades/harness-claude-code/SKILL.md +314 -308
- package/habilidades/infra-github-actions/SKILL.md +4 -3
- package/habilidades/instalar-sistema/SKILL.md +227 -223
- package/habilidades/memoria-busqueda/SKILL.md +31 -39
- package/habilidades/planear-fase/SKILL.md +358 -350
- package/habilidades/proceso-ddia-fundamentos/SKILL.md +3 -2
- package/habilidades/release-semver/SKILL.md +4 -2
- package/habilidades/swl-claudemd/SKILL.md +6 -7
- package/habilidades/swl-dashboard/SKILL.md +11 -43
- package/habilidades/tdd-workflow/SKILL.md +749 -744
- 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/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 +193 -179
- 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/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 +102 -0
- package/hooks/lib/run-log.js +2 -1
- package/hooks/lib/singleton-guard.js +218 -27
- package/hooks/lib/telegram-cliente.js +17 -8
- 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/.backups/perfil-usuario.yaml.2026-07-10-165128.bak +53 -0
- package/instintos/.backups/proyecto.yaml.2026-07-10-165128.bak +372 -0
- package/instintos/perfil-usuario.yaml +506 -3
- package/instintos/proyecto.yaml +78 -0
- package/llms.txt +2 -2
- package/manifiestos/canonical-hashes.json +664 -2
- package/manifiestos/modulos.json +19 -14
- package/manifiestos/planning-paths.json +1 -0
- package/manifiestos/skills-lock.json +53 -53
- package/package.json +2 -3
- package/plugin.json +2 -2
- package/scripts/actualizar.js +3 -0
- package/scripts/auditar-clases-conocidas.js +32 -4
- package/scripts/benchmark-memoria.js +1 -0
- 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 +24 -10
- package/scripts/instalador.js +106 -7
- package/scripts/lib/activar-hooks-proyecto.js +116 -0
- 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 +1 -1
- package/scripts/lib/gitignore-manifest.js +8 -1
- package/scripts/lib/hooks-settings.js +45 -0
- 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 +403 -347
- 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
|
-
|
|
22
|
-
|
|
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 =
|
|
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
|
-
|
|
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 = '
|
|
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 =
|
|
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 };
|
package/hooks/lib/run-log.js
CHANGED
|
@@ -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
|
-
*
|
|
63
|
-
*
|
|
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
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
@@ -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
|
-
//
|
|
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
|