---
name: node-experto
description: Node.js y TypeScript backend moderno. Cubre patterns de Express/Fastify/NestJS, error handling middleware, streams y buffers, worker threads y clustering, Prisma/Drizzle ORM, validación con Zod, graceful shutdown y anti-patrones críticos como callback hell y event loop blocking.
version: "1.1.0"
evolved: true
evolved-from: "1.0.2"
evolved-at: "2026-05-16"
evolved-by: "aprender"
evolved-note: "v1.1.0 (MINOR): sección completa nueva 'Patrones de readline + prompts asíncronos' — Promise+close obligatorio, pausa de spinners durante prompts, listener leak prevention. Origen: H1 sesión 2026-05-16 (race condition spinner/prompt en swl-ses update + premature close detectado por Cursor IDE + /swl:nemesis --remediar iter-2 con 2 fixes adicionales). v1.0.2: gotchas `undefined<N` (NaN comparison), first-wins vs deep merge en config (PR #27 #30 v1.5.2), `assert.notMatch` no existe. v1.0.1: gotcha req.destroy() + CRLF + TOML literal."
herramientasPermitidas: [Read, Write, Glob]
exclusiones:
  - "No cargar para aplicaciones Next.js con App Router — Next.js tiene patrones de Server Components, SSR y caché que difieren del backend Node puro; cargar `nextjs-experto`."
  - "No cargar para proyectos NestJS con DI compleja — NestJS tiene su propio sistema de módulos y decoradores que merece su skill; cargar `nestjs-experto`."
  - "No cargar para scripting Python o backend no-JS — este skill cubre el stack Node.js específicamente."
  - "No cargar para errores de compilación TypeScript o tsconfig — para esos cargar `build-errors-typescript`; este skill asume que el código compila."
evolvable: true  # default para skill estandar
---
# Node.js Experto — Backend TypeScript Moderno

## Cuándo NO cargar

- El proyecto usa Next.js App Router — los patrones de Server Components, SSR y caché son específicos de Next.js; cargar `nextjs-experto`.
- El proyecto usa NestJS con inyección de dependencias, decoradores y módulos — NestJS tiene su propio sistema opinionado; cargar `nestjs-experto`.
- El backend es Python, Go, Java u otro lenguaje no-JS — este skill cubre Node.js específicamente.
- La pregunta es sobre errores de compilación TypeScript o tsconfig — cargar `build-errors-typescript`; este skill asume que el código compila.

## Filosofía

Node.js es monohilo y asíncrono. Todo el rendimiento depende de
**no bloquear el event loop**. Operaciones I/O siempre asíncronas,
CPU-intensivo en worker threads, errores siempre manejados.

---

## Frameworks: Cuándo Usar Cuál

| Framework | Usar cuando |
|-----------|------------|
| **Fastify** | API de alto rendimiento, microservicio |
| **Express** | Proyecto legacy, equipo familiarizado |
| **NestJS** | Aplicación empresarial grande, DI, estructura opinionada |

---

## Error Handling — Reglas Obligatorias

1. **Jerarquía de errores tipados**: `AppError` > `ValidationError`, `NotFoundError`, etc.
2. **Error middleware SIEMPRE al final** de la cadena.
3. **`asyncHandler` wrapper** para capturar errores async sin try/catch repetido.
4. **Errores operacionales** (400, 404): respuesta descriptiva al cliente.
5. **Errores no esperados** (500): log completo, respuesta genérica. NUNCA stack traces al cliente.

```typescript
export class AppError extends Error {
  constructor(
    public readonly message: string,
    public readonly statusCode: number,
    public readonly code: string,
    public readonly isOperational = true,
  ) {
    super(message);
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string, id: string) {
    super(`${resource} con id ${id} no encontrado`, 404, 'NOT_FOUND');
  }
}
```

---

## Streams — Reglas

- SIEMPRE usar `pipeline()` de `stream/promises` (maneja errores y backpressure).
- Para archivos grandes: NUNCA cargar todo en memoria. Usar streams.
- Para respuestas HTTP grandes: `Readable.from()` con generador async.
- Concatenar buffers con `Buffer.concat()`, NUNCA con `+=` de strings.

---

## Worker Threads y Clustering

- CPU-intensivo SIEMPRE en worker threads, NUNCA en el hilo principal.
- Pool de workers con Piscina para reutilización.
- Clustering: un worker por CPU core con auto-restart en muerte.

---

## ORM — Prisma y Drizzle

- **Prisma**: singleton con `globalThis` para evitar múltiples conexiones.
- **Transacciones**: `prisma.$transaction()` para operaciones atómicas.
- **Drizzle**: esquema tipado con `pgTable`, queries type-safe.

---

## Validación con Zod

- Schemas reutilizables con `z.infer<typeof Schema>` para tipos.
- Middleware `validar(schema)` que parsea y lanza `ValidationError` automáticamente.

---

## Graceful Shutdown — OBLIGATORIO

1. Dejar de aceptar nuevas conexiones: `server.close()`
2. Cerrar conexiones a BD: `prisma.$disconnect()`
3. Timeout de seguridad (10s) para forzar cierre.
4. Capturar `SIGTERM`, `SIGINT`, `unhandledRejection`, `uncaughtException`.

---

## Anti-patrones Críticos

| Anti-patrón | Corrección |
|-------------|------------|
| Callback hell | async/await |
| `crypto.pbkdf2Sync` en handler | Worker thread |
| `promise.then()` sin `.catch()` | asyncHandler o try/catch |
| `require()` dinámico en hot path | Importar al inicio |
| Operaciones síncronas de fs en requests | `fs.promises.*` |
| **Comparación `undefined < N`** evalúa `false` silenciosamente (NaN) | `(x \|\| 0) < N` con fallback explícito |
| **First-wins en config merging** cuando el consumidor hace deep merge | Replicar deep merge del consumidor (override por key, env mergeado por sub-clave) |
| **`assert.notMatch`** no existe en `node:assert` | Usar `assert.doesNotMatch` (con minúscula en "Match") |

```typescript
// MAL: bloquea el event loop
const hash = crypto.pbkdf2Sync(pass, salt, 100000, 64, 'sha512');

// BIEN: en worker thread
const hash = await pool.run({ pass, salt });
```

### Gotcha: `undefined < N` evalúa a false (NaN comparison)

```javascript
// MAL — el branch nunca dispara porque `notificado` puede ser undefined
const data = JSON.parse(readFileSync(flagPath, 'utf8'));
if (data.hayNueva && data.notificado < 2) {
  emitirAviso();
  data.notificado = (data.notificado || 0) + 1;  // ← inconsistente: defensive aquí pero no arriba
  writeFileSync(flagPath, JSON.stringify(data));
}

// BIEN — defense-in-depth con fallback explícito en ambos sitios
const data = JSON.parse(readFileSync(flagPath, 'utf8'));
const actual = data.notificado || 0;             // fallback explícito al leer
if (data.hayNueva && actual < 2) {
  emitirAviso();
  data.notificado = actual + 1;
  writeFileSync(flagPath, JSON.stringify(data));
}

// MEJOR aún — inicializar el campo al escribir para que nunca sea undefined
function registrarCheck(local, remota, hayNueva) {
  writeFileSync(flagPath, JSON.stringify({
    timestamp: Date.now(),
    local,
    remota,
    hayNueva,
    notificado: 0,    // ← inicialización explícita
  }));
}
```

**Por qué importa**: bugs de comparación con `undefined` son **silenciosos** (no
tiran error). El código se ve sintácticamente correcto y los tests sin
escenario adversarial pasan. Detectados típicamente solo cuando un usuario
reporta que "el aviso nunca aparece" — investigación cuesta horas.

### Gotcha: first-wins vs deep merge en config

Cuando un sistema carga config desde múltiples archivos (`base.json` +
`overrides.json` + `local.json`) y el **consumidor** del config hace deep
merge (ejemplo: Claude Code con `settings.json` + `settings.local.json`), el
loader interno **DEBE** replicar la misma semántica:

```javascript
// MAL — first-wins descarta info silenciosamente
function cargarConfig(paths) {
  for (const p of paths) {
    if (fs.existsSync(p)) {
      const data = JSON.parse(fs.readFileSync(p, 'utf8'));
      if (data.servers && Object.keys(data.servers).length) {
        return data.servers;  // ← primer archivo con servers !== {} gana, resto invisible
      }
    }
  }
  return {};
}

// BIEN — deep merge replicando el consumidor canónico
function cargarConfig(paths) {
  // Orden: base → override por key. Último gana en escalares;
  //        merge profundo en sub-objects (env, etc.).
  const merged = {};
  for (const p of paths) {
    if (!fs.existsSync(p)) continue;
    const data = JSON.parse(fs.readFileSync(p, 'utf8'));
    for (const [nombre, cfg] of Object.entries(data.servers || {})) {
      if (merged[nombre]) {
        merged[nombre] = {
          ...merged[nombre],
          ...cfg,
          env: { ...(merged[nombre].env || {}), ...(cfg.env || {}) },
        };
      } else {
        merged[nombre] = cfg;
      }
    }
  }
  return merged;
}
```

**Regla**: si el archivo de config tiene múltiples capas que el consumidor
mergea, el loader interno NUNCA usa first-wins. Replicar la semántica del
consumidor evita confusiones donde override parcial (solo `env` en local)
"rompe" la config completa.

---

## Checklist Node.js

- [ ] NUNCA bloquear el event loop con operaciones síncronas
- [ ] Error middleware al final de la cadena
- [ ] Graceful shutdown implementado
- [ ] CPU-intensivo en worker threads
- [ ] Streams para archivos/respuestas grandes
- [ ] Validación de input con Zod
- [ ] Singleton de Prisma client
- [ ] `unhandledRejection` y `uncaughtException` capturados

Para implementaciones completas de Fastify, Express, streams, workers, ORMs, Zod y graceful shutdown, ver [recursos/patrones-completos.md](recursos/patrones-completos.md).

---

## Gotchas de JSDoc y comentarios multilínea

### NUNCA usar glob `*` dentro de bloques `/** */`

**Problema**: El patrón `habilidades/*/SKILL.md` dentro de un bloque JSDoc `/** ... */`
es interpretado por el parser JS como cierre del comentario en `*/`.
Produce `SyntaxError: Unexpected identifier` al cargar el módulo.

```javascript
// MAL — el */ en "habilidades/*/SKILL.md" cierra el bloque JSDoc prematuramente
/**
 * Scannea habilidades/*/SKILL.md y extrae metadata.
 *                           ^ este */ cierra el comentario aquí
 */

// BIEN — usar <nombre> o texto descriptivo sin glob en JSDoc
/**
 * Scannea habilidades/<nombre>/SKILL.md y extrae metadata.
 */
```

Este mismo anti-patrón aplica a cualquier ruta con `*/` dentro de `/** */`:
`src/*/index.js`, `dist/**/*.js`, etc.

---

## Patrones de I/O para observabilidad

### JSONL + `appendFileSync` para streams de eventos de alta frecuencia

Para archivos de telemetría, auditoría o logs append-only, `fs.appendFileSync` es
superior a escritura atómica JSON:

```javascript
// MAL para eventos frecuentes — lee y reescribe todo el archivo cada vez
const eventos = JSON.parse(fs.readFileSync(archivo, 'utf8'));
eventos.push(nuevoEvento);
fs.writeFileSync(archivo, JSON.stringify(eventos, null, 2));

// BIEN — append-only O(1), sin leer el archivo existente
fs.appendFileSync(archivo, JSON.stringify(nuevoEvento) + '\n', 'utf8');

// Para leer de vuelta: parsear línea a línea
const eventos = fs.readFileSync(archivo, 'utf8')
  .split('\n').filter(Boolean).map(l => JSON.parse(l));
```

Usar `atomicWriteJSON` para archivos de estado (mutation frecuente).
Usar `appendFileSync` para archivos de eventos (solo escritura secuencial).

### Webhook fire-and-forget con `http`/`https` nativo (zero-deps)

Para exportaciones opcionales (OTLP, audit webhooks, Slack, Datadog):

```javascript
const http  = require('http');
const https = require('https');

function enviarWebhookAsync(url, payload) {
  if (!url) return; // No lanzar si no está configurado

  try {
    const body   = JSON.stringify(payload);
    const parsed = new URL(url);
    const modulo = parsed.protocol === 'https:' ? https : http;

    const req = modulo.request({
      hostname: parsed.hostname,
      port:     parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
      path:     parsed.pathname + (parsed.search || ''),
      method:   'POST',
      headers:  {
        'Content-Type':   'application/json',
        'Content-Length': Buffer.byteLength(body),
      },
    });
    req.on('error', () => {}); // Silencioso — nunca interrumpir el flujo principal
    req.write(body);
    req.end();
  } catch (_) {}
}
```

Verificar siempre `process.env.WEBHOOK_URL` antes de llamar — sin config requerida.

---

## Patrones de readline + prompts asíncronos

### NUNCA: usar `rl.question(cb)` envuelto en Promise sin manejar el cierre

**Problema**: `readline.Interface.question(prompt, callback)` solo invoca el
callback cuando el usuario presiona Enter con input válido. Si el stream
`stdin` se cierra antes (Ctrl+C, EOF, pipe roto, error), el callback nunca
se ejecuta y el Promise queda colgado para siempre. Cualquier código que
dependa de ese resolve (spinners pausados, locks, cleanup) queda bloqueado.

```javascript
// MAL — Promise huérfano si stdin se cierra antes del input
function preguntarSiNo(mensaje) {
  return new Promise((resolve) => {
    const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
    rl.question(`${mensaje} [s/N] `, (respuesta) => {
      rl.close();
      resolve(respuesta.trim().toLowerCase() === 's');
    });
    // Si el usuario presiona Ctrl+C, rl emite 'close' pero el callback
    // de question() nunca corre. Promise queda colgado.
  });
}
```

```javascript
// BIEN — 'close' como única ruta de finalización + flag idempotente
function preguntarSiNo(mensaje, valorDefault = false) {
  return new Promise((resolve) => {
    const rl = readline.createInterface({ input: process.stdin, output: process.stdout });

    let valorFinal = valorDefault;
    let resuelto = false;

    function finalizar() {
      if (resuelto) return;
      resuelto = true;
      resolve(valorFinal);
    }

    // 'close' se dispara SIEMPRE: input válido (tras rl.close()),
    // Ctrl+C, EOF, error. Garantiza resolución del Promise.
    rl.on('close', finalizar);

    rl.question(`${mensaje} [s/N] `, (respuesta) => {
      valorFinal = respuesta.trim().toLowerCase() === 's';
      rl.close(); // dispara 'close' → finalizar(); idempotente vía `resuelto`
    });
  });
}
```

**Regla de oro**: el callback de `rl.question` NUNCA debe llamar `resolve()`
directamente. Solo debe **setear el valor final** y llamar `rl.close()`. El
listener `rl.on('close', ...)` es el único punto que llama `resolve()`.

### NUNCA: ejecutar `setInterval` que escriba a stdout durante un prompt readline

**Problema**: un spinner que escribe `\r ⠏ Procesando...   ` cada 80ms sobre
stdout sobrescribe el prompt renderizado por `readline.createInterface()`.
El cursor lógico de readline está en el lugar correcto, pero la
representación visual queda mezclada — el usuario ve algo como
`⠏ Procesando...   ntes? [S/n]` (fragmentos del prompt cortados).

```javascript
// MAL — race entre spinner y prompt
const sp = spinner('Cargando datos...');  // setInterval cada 80ms
try {
  const datos = await fetchDatos();
  if (necesitaConfirmacion(datos)) {
    // BUG: el spinner sigue escribiendo \r mientras readline renderiza el prompt
    const confirma = await preguntarSiNo('¿Continuar?');
  }
} finally {
  sp.detener();
}
```

```javascript
// BIEN — registro global de spinners + pausa coordinada
const _spinnersActivos = new Set();

function _pausarSpinnersActivos() {
  for (const sp of _spinnersActivos) sp._pausar(); // clearInterval + limpiarLinea
}
function _reanudarSpinnersActivos() {
  for (const sp of _spinnersActivos) sp._reanudar(); // re-setInterval si sigue activo
}

function spinner(mensaje) {
  let pausado = false, intervalo = null;
  function _arrancarIntervalo() {
    if (!intervalo) intervalo = setInterval(tick, 80);
  }
  const handle = {
    _pausar() {
      if (pausado) return;
      pausado = true;
      if (intervalo) { clearInterval(intervalo); intervalo = null; }
      process.stdout.write('\r\x1b[2K');  // limpiar línea para el prompt
    },
    _reanudar() {
      if (!pausado) return;
      pausado = false;
      _arrancarIntervalo();
    },
    detener() {
      _spinnersActivos.delete(handle);
      if (intervalo) clearInterval(intervalo);
    },
  };
  _spinnersActivos.add(handle);
  _arrancarIntervalo();
  return handle;
}

// preguntarSiNo pausa antes de createInterface y reanuda en finalizar
function preguntarSiNo(mensaje) {
  return new Promise((resolve) => {
    _pausarSpinnersActivos();
    const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
    let resuelto = false;
    rl.on('close', () => {
      if (resuelto) return;
      resuelto = true;
      _reanudarSpinnersActivos();
      resolve(valorFinal);
    });
    // ... rl.question como en el patrón anterior
  });
}
```

### NUNCA: registrar listeners en `process` desde un spinner sin removerlos

**Problema**: un spinner registra `process.once('exit', cleanup)` al
crearse. Si el spinner termina con éxito (no por exit del proceso), el
listener queda colgado en `process`. Un loop que crea N spinners
secuenciales (un instalador iterando runtimes) acumula N listeners hasta
disparar `MaxListenersExceededWarning` con default 10.

```javascript
// BIEN — declarar exitHandler antes del handle y removerlo en terminación
function spinner(mensaje) {
  // Declarado antes del handle para que las funciones de terminación
  // puedan removerlo. Closure: la arrow function captura la binding de
  // `handle`, no su valor — se resuelve al invocar 'exit'.
  const exitHandler = () => { handle.detener(); };

  const handle = {
    exito(msg) {
      // ... limpieza interna
      process.removeListener('exit', exitHandler);  // ← OBLIGATORIO
      console.log(`  ✓ ${msg}`);
    },
    fallo(msg) {
      process.removeListener('exit', exitHandler);  // ← OBLIGATORIO
      console.log(`  ✗ ${msg}`);
    },
    detener() {
      process.removeListener('exit', exitHandler);  // ← OBLIGATORIO
    },
  };

  process.once('exit', exitHandler);
  return handle;
}
```

Para verificar que no hay leak en tests:

```javascript
test('spinner: no acumula listeners en process al iterar', () => {
  const antes = process.listenerCount('exit');
  for (let i = 0; i < 12; i++) {
    const sp = spinner(`iter-${i}`);
    if (i % 3 === 0) sp.exito(`ok-${i}`);
    else if (i % 3 === 1) sp.fallo(`err-${i}`);
    else sp.detener();
  }
  const despues = process.listenerCount('exit');
  assert.equal(despues, antes, 'listeners no removidos');
});
```

**Detección iterativa con `/swl:nemesis --remediar`**: este patrón
(race spinner/prompt + premature close + listener leak) emergió en swl-ses
v1.6.0 tras 3 iteraciones de fix. La primera detectó el race obvio; Cursor
IDE detectó el premature close en revisión estática del fix; nemesis iter-2
detectó el listener leak (F-1) y `preguntarOpcion` pausando tarde (F-2).
Lección operativa: para módulos pequeños con alta densidad de control de
flujo (event handlers, callbacks, setInterval/setTimeout, readline),
nemesis con `--remediar` es costo-efectivo (~15 turnos para ~300 LOC).

## Scripts dual-use: librería + CLI

### `require.main === module` para scripts en `scripts/lib/`

Permite que el mismo archivo funcione como librería importable Y como CLI
sin duplicar lógica:

```javascript
// scripts/lib/mi-script.js
function miFuncion(cwd) { /* ... */ }
module.exports = { miFuncion };

// Solo se ejecuta cuando se invoca directamente con `node scripts/lib/mi-script.js`
// NO se ejecuta cuando otro módulo lo importa con require('./mi-script')
if (require.main === module) {
  const args     = process.argv.slice(2);
  const query    = args.filter(a => !a.startsWith('--'))[0] || '';
  const resultado = miFuncion(process.cwd(), query);
  process.stdout.write(JSON.stringify(resultado, null, 2) + '\n');
}
```

---

## Gotchas / Errores comunes no obvios

**Prisma Client creado en cada request agota el pool de conexiones**: instanciar `new PrismaClient()` dentro de un handler crea una nueva pool por cada request. En producción esto provoca `Too many connections` en la BD. Fix: exportar un singleton con el patrón `globalThis.__prisma || new PrismaClient()` y reutilizarlo en todos los handlers.

**`unhandledRejection` no captura errores en callbacks síncronos dentro de Promises**: el handler `process.on('unhandledRejection', ...)` captura promesas rechazadas sin `.catch()`, pero no captura errores lanzados en código síncrono dentro de un `Promise` executor si ese error no se propaga al reject. Causa: `new Promise((resolve, reject) => { throw new Error('X') })` sí se captura, pero `setTimeout(() => { throw new Error('X') }, 0)` dentro de una promesa no. Fix: usar `uncaughtException` también, y never usar setTimeout con código que puede lanzar dentro de promesas.

**`stream.pipeline()` no limpia streams en versiones < Node 16 con errores en mitad del pipe**: en Node 14, un error en un transform stream intermedio deja el readable stream sin destruir, causando memory leak. Fix: usar Node 18+ o agregar listeners de `error` en cada stream del pipeline manualmente en proyectos que deben soportar Node 14/16.

**Zod `.parse()` vs `.safeParse()` en middleware**: usar `schema.parse(body)` en un middleware Express lanza una excepción no capturada si no hay un try/catch, y el error llega al cliente con stack trace completo. Fix: usar siempre `schema.safeParse(body)` en middleware y verificar `result.success` antes de continuar; pasar `result.error` al error handler con `next(result.error)`.

**`response.json()` sobre body vacío lanza `SyntaxError: Unexpected end of JSON input`**: cuando el servidor responde con 204 No Content, 205 Reset Content, o con `Content-Length: 0`, el body está vacío — `fetch(...).then(r => r.json())` falla en el cliente porque JSON.parse sobre string vacío lanza excepción. Causa: el contrato de fetch no verifica el status antes de parsear; el cliente asume que siempre hay JSON. Fix: verificar status y Content-Length antes de parsear: `if (r.status === 204 || r.status === 205 || r.headers.get('content-length') === '0') return null;`. Si el servidor FastAPI retorna 200 con body vacío por bug, considerar helper `await r.text()` antes y `JSON.parse` con try/catch — o mejor, devolver 204 explícito en endpoints que no retornan cuerpo.

**`req.destroy()` en un handler `http` cierra el socket antes de poder enviar la respuesta**: cuando se aborta la lectura del body (por ejemplo al exceder `maxPayloadBytes`), llamar `req.destroy()` en el listener `data` cierra el socket inmediatamente. El subsiguiente `res.writeHead(413); res.end()` del handler falla porque ya no hay socket — el cliente recibe `ECONNRESET` en lugar de 413. Causa: `destroy()` no espera al flush de la respuesta pendiente; equivale a un `RST` TCP. Fix: usar `req.pause()` + un flag `abortado = true` + rechazar la promesa de lectura; dejar que el handler envíe `res.writeHead(413); res.end(...)` normalmente. Node cierra el socket por sí solo tras `res.end()`. Aplica a servidores HTTP nativos sin Express/Fastify, donde el control del body es manual.

**Regex multi-line con `\n` falla con archivos CRLF de Windows**: un parser que usa `/^---\n([\s\S]*?)\n---\n/` para detectar frontmatter YAML rompe con archivos commiteados en Windows (donde git aplica `core.autocrlf=true` y convierte LF↔CRLF al checkout). Caso real (Sub-fase 11.5 v1.5.1, swl-ses): `parsearFrontmatter` en `scripts/lib/transformadores/base.js` devolvía `frontmatter:{}` y `cuerpo:<archivo entero>` para los 60 agentes `agentes/*.md` cuando se ejecutaba en Windows. Resultado downstream: `transformarAgente` de Codex generaba TOML con `description = ""` y `developer_instructions` con el frontmatter duplicado dentro del body. Bug latente que solo aparece al ejecutar contra archivos reales con CRLF. Fix: normalizar antes del match — `const norm = String(contenido).replace(/\r\n/g, '\n').replace(/\r/g, '\n');` y aplicar regex sobre `norm`. Siempre asumir que cualquier archivo leído de filesystem en Windows puede tener CRLF, incluso si git lo "almacena" como LF. Aplica a TODA función con regex multi-line que reciba contenido de filesystem.

**Serializar TOML zero-deps: preferir `'''literal'''` sobre `"""basic"""` para multi-line con backslashes**: TOML soporta dos sintaxis multi-line — basic `"""..."""` que procesa escapes (`\n`, `\t`, `\\`) y literal `'''...'''` que preserva el contenido tal cual. Caso real (Sub-fase 11 v1.5.1, swl-ses): al serializar el cuerpo Markdown de agentes SWL (con regex `\n`, paths Windows con `\`, etc.) a `developer_instructions` de `~/.codex/agents/*.toml`, usar basic `"""..."""` exigía escapar cada `\` a `\\` (laborioso y propenso a bugs). Solución: usar literal `'''...'''` por default (preserva Markdown crudo) y caer a basic `"""..."""` con escape SOLO si el cuerpo contiene `'''` literal (raro). Helper en `scripts/lib/transformadores/codex.js`:
```js
_tomlMultiline(s) {
  if (!s.includes("'''")) return `'''\n${s}\n'''`;
  return `"""\n${s.replace(/\\/g, '\\\\').replace(/"""/g, '\\"""')}\n"""`;
}
```
Aplica a cualquier serialización TOML manual sin librería (`@iarna/toml`, etc.) donde el contenido es texto rico con caracteres especiales.

```js
// MAL — ECONNRESET en cliente, nunca recibe 413
req.on('data', chunk => {
  total += chunk.length;
  if (total > MAX) {
    req.destroy();  // ← cierra socket. res.end() después es no-op
    return reject(new Error('PAYLOAD_TOO_LARGE'));
  }
  chunks.push(chunk);
});

// BIEN — cliente recibe 413 con body JSON
let abortado = false;
req.on('data', chunk => {
  if (abortado) return;
  total += chunk.length;
  if (total > MAX) {
    abortado = true;
    req.pause();  // ← TCP backpressure, socket vivo
    return reject(new Error('PAYLOAD_TOO_LARGE'));
  }
  chunks.push(chunk);
});
req.on('end', () => { if (!abortado) resolve(Buffer.concat(chunks)); });
req.on('error', err => { if (!abortado) reject(err); });
```

Origen: PR #13 sesión 2026-05-13, test de payload-too-large en `gateway/webhook-server.js`.
