@saulwade/swl-ses 1.6.3 → 1.6.6

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 (46) hide show
  1. package/CLAUDE.md +3 -3
  2. package/README.md +2 -2
  3. package/agentes/gh-fix-ci-swl.md +275 -0
  4. package/agentes/nemesis-auditor-swl.md +90 -1
  5. package/comandos/swl/exportar-vault.md +106 -14
  6. package/comandos/swl/nemesis.md +70 -3
  7. package/comandos/swl/release.md +62 -2
  8. package/comandos/swl/salud.md +32 -0
  9. package/comandos/swl/verificar.md +116 -2
  10. package/habilidades/agent-browser/SKILL.md +111 -4
  11. package/habilidades/agent-deep-links/SKILL.md +148 -0
  12. package/habilidades/backend-async-postgres-testing/SKILL.md +215 -0
  13. package/habilidades/backend-error-design/SKILL.md +221 -0
  14. package/habilidades/browser-interaction-patterns/SKILL.md +514 -0
  15. package/habilidades/browser-research-domains/SKILL.md +635 -0
  16. package/habilidades/changelog-generator/SKILL.md +172 -0
  17. package/habilidades/changelog-generator/scripts/parse-commits.js +354 -0
  18. package/habilidades/devsecops-pipeline-security/SKILL.md +3 -0
  19. package/habilidades/fastapi-experto/SKILL.md +49 -4
  20. package/habilidades/harness-claude-code/SKILL.md +4 -1
  21. package/habilidades/postgresql-experto/SKILL.md +80 -4
  22. package/habilidades/proceso-discovery-machote/SKILL.md +157 -0
  23. package/habilidades/proceso-modular-split/SKILL.md +256 -0
  24. package/habilidades/tdd-workflow/SKILL.md +12 -5
  25. package/hooks/extraccion-aprendizajes.js +8 -0
  26. package/hooks/lib/deep-links.js +185 -0
  27. package/hooks/lib/evolution-tracker.js +148 -20
  28. package/hooks/lib/gateway-notify.js +70 -7
  29. package/manifiestos/modulos.json +13 -3
  30. package/manifiestos/skills-lock.json +1247 -1191
  31. package/package.json +92 -92
  32. package/plugin.json +371 -362
  33. package/reglas/arquitectura.md +38 -0
  34. package/reglas/arreglar-al-detectar.md +93 -0
  35. package/reglas/auditorias-documentales-estructurales.md +38 -0
  36. package/reglas/registro-componentes-nuevos.md +14 -0
  37. package/reglas/tests-cleanup.md +220 -0
  38. package/scripts/instalador.js +72 -4
  39. package/scripts/lib/mcp_config.py +29 -14
  40. package/scripts/lib/notificaciones-telegram.js +14 -0
  41. package/scripts/lib/transformadores/codex.js +4 -0
  42. package/scripts/lib/transformadores/cursor.js +5 -0
  43. package/scripts/mcp-orchestrator.py +153 -131
  44. package/scripts/mcp-pool-manager.py +132 -107
  45. package/scripts/mcp-telemetry.py +139 -120
  46. package/scripts/verificar-release.js +199 -1
@@ -29,6 +29,14 @@ from datetime import datetime, timezone
29
29
  from pathlib import Path
30
30
  from typing import Any
31
31
 
32
+ # Permitir importar mcp_config desde scripts/lib/ (consistente con
33
+ # mcp-orchestrator.py:34 y mcp-pool-manager.py:35). Sin esto, el import
34
+ # runtime dentro de MCPTelemetrySession.__aenter__ rompía con
35
+ # ModuleNotFoundError al usarse desde un consumidor que no había configurado
36
+ # scripts/lib/ en sys.path previamente.
37
+ sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
38
+ from mcp_config import build_stdio_env # noqa: E402
39
+
32
40
  # ---------------------------------------------------------------------------
33
41
  # Dependencias — raw mcp SDK
34
42
  # ---------------------------------------------------------------------------
@@ -36,6 +44,7 @@ try:
36
44
  from mcp import ClientSession
37
45
  from mcp.client.stdio import stdio_client, StdioServerParameters
38
46
  from mcp.client.sse import sse_client
47
+
39
48
  HAS_MCP = True
40
49
  except ImportError:
41
50
  HAS_MCP = False
@@ -44,9 +53,9 @@ except ImportError:
44
53
  # Constantes
45
54
  # ---------------------------------------------------------------------------
46
55
 
47
- TRACES_DIR = Path('.planning') / 'traces'
48
- MCP_PREFIX = 'mcp' # prefijo de archivos de traza MCP
49
- TIMEOUT_S = 15
56
+ TRACES_DIR = Path(".planning") / "traces"
57
+ MCP_PREFIX = "mcp" # prefijo de archivos de traza MCP
58
+ TIMEOUT_S = 15
50
59
 
51
60
  # ---------------------------------------------------------------------------
52
61
  # Utilidades de traza OTLP-lite
@@ -54,11 +63,11 @@ TIMEOUT_S = 15
54
63
 
55
64
 
56
65
  def _trace_id() -> str:
57
- return secrets.token_hex(16) # 128 bits, 32 hex chars
66
+ return secrets.token_hex(16) # 128 bits, 32 hex chars
58
67
 
59
68
 
60
69
  def _span_id() -> str:
61
- return secrets.token_hex(8) # 64 bits, 16 hex chars
70
+ return secrets.token_hex(8) # 64 bits, 16 hex chars
62
71
 
63
72
 
64
73
  def _iso_now() -> str:
@@ -69,15 +78,19 @@ def _ruta_hoy(cwd: Path) -> Path:
69
78
  """Ruta del JSONL de trazas MCP del dia actual."""
70
79
  directorio = cwd / TRACES_DIR
71
80
  directorio.mkdir(parents=True, exist_ok=True)
72
- fecha = datetime.now(timezone.utc).strftime('%Y-%m-%d')
73
- return directorio / f'{MCP_PREFIX}-{fecha}.jsonl'
74
-
75
-
76
- def registrar_traza(cwd: Path, nombre: str, atributos: dict,
77
- estado: str = 'OK',
78
- inicio_iso: str | None = None,
79
- fin_iso: str | None = None,
80
- duracion_ms: int = 0) -> dict:
81
+ fecha = datetime.now(timezone.utc).strftime("%Y-%m-%d")
82
+ return directorio / f"{MCP_PREFIX}-{fecha}.jsonl"
83
+
84
+
85
+ def registrar_traza(
86
+ cwd: Path,
87
+ nombre: str,
88
+ atributos: dict,
89
+ estado: str = "OK",
90
+ inicio_iso: str | None = None,
91
+ fin_iso: str | None = None,
92
+ duracion_ms: int = 0,
93
+ ) -> dict:
81
94
  """Escribe una entrada OTLP-lite al JSONL de trazas del dia.
82
95
 
83
96
  Parametros:
@@ -93,23 +106,24 @@ def registrar_traza(cwd: Path, nombre: str, atributos: dict,
93
106
  """
94
107
  ahora = _iso_now()
95
108
  traza = {
96
- 'traceId': _trace_id(),
97
- 'spanId': _span_id(),
98
- 'nombre': nombre,
99
- 'inicio': inicio_iso or ahora,
100
- 'fin': fin_iso or ahora,
101
- 'duracionMs': duracion_ms,
102
- 'estado': estado,
103
- 'atributos': atributos,
109
+ "traceId": _trace_id(),
110
+ "spanId": _span_id(),
111
+ "nombre": nombre,
112
+ "inicio": inicio_iso or ahora,
113
+ "fin": fin_iso or ahora,
114
+ "duracionMs": duracion_ms,
115
+ "estado": estado,
116
+ "atributos": atributos,
104
117
  }
105
118
  try:
106
119
  ruta = _ruta_hoy(cwd)
107
- with open(ruta, 'a', encoding='utf-8') as fh:
108
- fh.write(json.dumps(traza, ensure_ascii=False) + '\n')
120
+ with open(ruta, "a", encoding="utf-8") as fh:
121
+ fh.write(json.dumps(traza, ensure_ascii=False) + "\n")
109
122
  except Exception as exc:
110
- sys.stderr.write(f'[mcp-telemetry] No se pudo escribir traza: {exc}\n')
123
+ sys.stderr.write(f"[mcp-telemetry] No se pudo escribir traza: {exc}\n")
111
124
  return traza
112
125
 
126
+
113
127
  # ---------------------------------------------------------------------------
114
128
  # Context manager: MCPTelemetrySession
115
129
  # ---------------------------------------------------------------------------
@@ -128,33 +142,32 @@ class MCPTelemetrySession:
128
142
  """
129
143
 
130
144
  def __init__(self, cwd: Path, server_name: str, server_cfg: dict) -> None:
131
- self.cwd = cwd
145
+ self.cwd = cwd
132
146
  self.server_name = server_name
133
- self.server_cfg = server_cfg
147
+ self.server_cfg = server_cfg
134
148
  self._session: ClientSession | None = None
135
149
  self._ctx_stack: list = []
136
150
 
137
- async def __aenter__(self) -> 'MCPTelemetrySession':
151
+ async def __aenter__(self) -> "MCPTelemetrySession":
138
152
  if not HAS_MCP:
139
- raise RuntimeError('La libreria mcp no esta instalada. Ejecuta: pip install mcp')
153
+ raise RuntimeError(
154
+ "La libreria mcp no esta instalada. Ejecuta: pip install mcp"
155
+ )
140
156
 
141
157
  cfg = self.server_cfg
142
- if 'url' in cfg:
143
- transport = sse_client(cfg['url'])
158
+ if "url" in cfg:
159
+ transport = sse_client(cfg["url"])
144
160
  else:
145
- env: dict | None = None
146
- if extra := cfg.get('env'):
147
- env = {**os.environ, **extra}
148
161
  params = StdioServerParameters(
149
- command=cfg['command'],
150
- args=cfg.get('args', []),
151
- env=env,
152
- cwd=cfg.get('cwd'),
162
+ command=cfg["command"],
163
+ args=cfg.get("args", []),
164
+ env=build_stdio_env(cfg),
165
+ cwd=cfg.get("cwd"),
153
166
  )
154
167
  transport = stdio_client(params)
155
168
 
156
169
  t_ctx = transport
157
- rw = await t_ctx.__aenter__()
170
+ rw = await t_ctx.__aenter__()
158
171
  self._ctx_stack.append(t_ctx)
159
172
 
160
173
  sess_ctx = ClientSession(rw[0], rw[1])
@@ -176,7 +189,7 @@ class MCPTelemetrySession:
176
189
  async def list_tools(self) -> list:
177
190
  """Lista herramientas disponibles en el servidor."""
178
191
  if not self._session:
179
- raise RuntimeError('Sesion no inicializada — usar como context manager')
192
+ raise RuntimeError("Sesion no inicializada — usar como context manager")
180
193
  resp = await asyncio.wait_for(self._session.list_tools(), timeout=TIMEOUT_S)
181
194
  return resp.tools or []
182
195
 
@@ -186,51 +199,51 @@ class MCPTelemetrySession:
186
199
  Retorna dict con: result (list[str]), is_error (bool), duration_ms (int).
187
200
  """
188
201
  if not self._session:
189
- raise RuntimeError('Sesion no inicializada — usar como context manager')
202
+ raise RuntimeError("Sesion no inicializada — usar como context manager")
190
203
 
191
204
  inicio_ts = time.time()
192
205
  inicio_iso = _iso_now()
193
- estado = 'OK'
194
- resultado: dict = {'result': [], 'is_error': False, 'duration_ms': 0}
206
+ estado = "OK"
207
+ resultado: dict = {"result": [], "is_error": False, "duration_ms": 0}
195
208
 
196
209
  try:
197
210
  resp = await asyncio.wait_for(
198
211
  self._session.call_tool(tool, arguments or {}),
199
212
  timeout=TIMEOUT_S,
200
213
  )
201
- resultado['is_error'] = bool(getattr(resp, 'isError', False))
202
- resultado['result'] = [
203
- c.text if hasattr(c, 'text') else str(c)
204
- for c in (resp.content or [])
214
+ resultado["is_error"] = bool(getattr(resp, "isError", False))
215
+ resultado["result"] = [
216
+ c.text if hasattr(c, "text") else str(c) for c in (resp.content or [])
205
217
  ]
206
- if resultado['is_error']:
207
- estado = 'ERROR'
218
+ if resultado["is_error"]:
219
+ estado = "ERROR"
208
220
  except Exception as exc:
209
- resultado['error'] = str(exc)
210
- estado = 'ERROR'
221
+ resultado["error"] = str(exc)
222
+ estado = "ERROR"
211
223
  finally:
212
- fin_ts = time.time()
213
- dur_ms = int((fin_ts - inicio_ts) * 1000)
224
+ fin_ts = time.time()
225
+ dur_ms = int((fin_ts - inicio_ts) * 1000)
214
226
  fin_iso = _iso_now()
215
- resultado['duration_ms'] = dur_ms
227
+ resultado["duration_ms"] = dur_ms
216
228
 
217
229
  registrar_traza(
218
- cwd = self.cwd,
219
- nombre = f'mcp:call_tool',
220
- atributos = {
221
- 'server': self.server_name,
222
- 'tool': tool,
223
- 'args_keys': list((arguments or {}).keys()),
224
- 'duration_ms': dur_ms,
230
+ cwd=self.cwd,
231
+ nombre=f"mcp:call_tool",
232
+ atributos={
233
+ "server": self.server_name,
234
+ "tool": tool,
235
+ "args_keys": list((arguments or {}).keys()),
236
+ "duration_ms": dur_ms,
225
237
  },
226
- estado = estado,
227
- inicio_iso = inicio_iso,
228
- fin_iso = fin_iso,
229
- duracion_ms = dur_ms,
238
+ estado=estado,
239
+ inicio_iso=inicio_iso,
240
+ fin_iso=fin_iso,
241
+ duracion_ms=dur_ms,
230
242
  )
231
243
 
232
244
  return resultado
233
245
 
246
+
234
247
  # ---------------------------------------------------------------------------
235
248
  # CLI helpers
236
249
  # ---------------------------------------------------------------------------
@@ -243,10 +256,10 @@ def _leer_trazas_mcp(cwd: Path, dias: int = 1) -> list:
243
256
  return []
244
257
 
245
258
  trazas: list = []
246
- archivos = sorted(directorio.glob(f'{MCP_PREFIX}-*.jsonl'), reverse=True)[:dias]
259
+ archivos = sorted(directorio.glob(f"{MCP_PREFIX}-*.jsonl"), reverse=True)[:dias]
247
260
  for archivo in archivos:
248
261
  try:
249
- for linea in archivo.read_text(encoding='utf-8').splitlines():
262
+ for linea in archivo.read_text(encoding="utf-8").splitlines():
250
263
  linea = linea.strip()
251
264
  if linea:
252
265
  try:
@@ -259,79 +272,81 @@ def _leer_trazas_mcp(cwd: Path, dias: int = 1) -> list:
259
272
 
260
273
 
261
274
  def _cmd_report(cwd: Path, dias: int, as_json: bool) -> None:
262
- out = sys.stdout.write
275
+ out = sys.stdout.write
263
276
  trazas = _leer_trazas_mcp(cwd, dias)
264
277
 
265
278
  if as_json:
266
- out(json.dumps(trazas, ensure_ascii=False, indent=2) + '\n')
279
+ out(json.dumps(trazas, ensure_ascii=False, indent=2) + "\n")
267
280
  return
268
281
 
269
282
  if not trazas:
270
- out(f'Sin trazas MCP en los ultimos {dias} dia(s).\n')
271
- out(f'Directorio: {cwd / TRACES_DIR}\n')
283
+ out(f"Sin trazas MCP en los ultimos {dias} dia(s).\n")
284
+ out(f"Directorio: {cwd / TRACES_DIR}\n")
272
285
  return
273
286
 
274
- out(f'\nTrazas MCP ({len(trazas)} entradas, ultimos {dias} dia(s)):\n')
275
- out('-' * 72 + '\n')
276
- for t in trazas[-50:]: # mostrar ultimas 50
277
- estado = t.get('estado', '?')
278
- nombre = t.get('nombre', '?')
279
- dur = t.get('duracionMs', 0)
280
- attrs = t.get('atributos', {})
281
- server = attrs.get('server', '')
282
- tool = attrs.get('tool', '')
283
- ts = t.get('inicio', '')[:19]
284
- out(f' {ts} {estado:<5} {nombre:<20} {server}/{tool} {dur}ms\n')
287
+ out(f"\nTrazas MCP ({len(trazas)} entradas, ultimos {dias} dia(s)):\n")
288
+ out("-" * 72 + "\n")
289
+ for t in trazas[-50:]: # mostrar ultimas 50
290
+ estado = t.get("estado", "?")
291
+ nombre = t.get("nombre", "?")
292
+ dur = t.get("duracionMs", 0)
293
+ attrs = t.get("atributos", {})
294
+ server = attrs.get("server", "")
295
+ tool = attrs.get("tool", "")
296
+ ts = t.get("inicio", "")[:19]
297
+ out(f" {ts} {estado:<5} {nombre:<20} {server}/{tool} {dur}ms\n")
285
298
 
286
299
 
287
300
  def _cmd_stats(cwd: Path, dias: int, as_json: bool) -> None:
288
- out = sys.stdout.write
301
+ out = sys.stdout.write
289
302
  trazas = _leer_trazas_mcp(cwd, dias)
290
303
 
291
304
  # Agregar por servidor y herramienta
292
- stats: dict = {} # server -> {tool -> {count, ok, errors, total_ms}}
305
+ stats: dict = {} # server -> {tool -> {count, ok, errors, total_ms}}
293
306
  for t in trazas:
294
- attrs = t.get('atributos', {})
295
- server = attrs.get('server', 'desconocido')
296
- tool = attrs.get('tool', 'desconocido')
297
- estado = t.get('estado', 'OK')
298
- dur = t.get('duracionMs', 0)
307
+ attrs = t.get("atributos", {})
308
+ server = attrs.get("server", "desconocido")
309
+ tool = attrs.get("tool", "desconocido")
310
+ estado = t.get("estado", "OK")
311
+ dur = t.get("duracionMs", 0)
299
312
 
300
313
  if server not in stats:
301
314
  stats[server] = {}
302
315
  if tool not in stats[server]:
303
- stats[server][tool] = {'count': 0, 'ok': 0, 'errors': 0, 'total_ms': 0}
316
+ stats[server][tool] = {"count": 0, "ok": 0, "errors": 0, "total_ms": 0}
304
317
 
305
318
  s = stats[server][tool]
306
- s['count'] += 1
307
- s['total_ms'] += dur
308
- if estado == 'OK':
309
- s['ok'] += 1
319
+ s["count"] += 1
320
+ s["total_ms"] += dur
321
+ if estado == "OK":
322
+ s["ok"] += 1
310
323
  else:
311
- s['errors'] += 1
324
+ s["errors"] += 1
312
325
 
313
326
  # Calcular promedio
314
327
  for server in stats:
315
328
  for tool in stats[server]:
316
329
  s = stats[server][tool]
317
- s['avg_ms'] = int(s['total_ms'] / s['count']) if s['count'] else 0
330
+ s["avg_ms"] = int(s["total_ms"] / s["count"]) if s["count"] else 0
318
331
 
319
332
  if as_json:
320
- out(json.dumps(stats, ensure_ascii=False, indent=2) + '\n')
333
+ out(json.dumps(stats, ensure_ascii=False, indent=2) + "\n")
321
334
  return
322
335
 
323
336
  if not stats:
324
- out(f'Sin datos de uso MCP en los ultimos {dias} dia(s).\n')
337
+ out(f"Sin datos de uso MCP en los ultimos {dias} dia(s).\n")
325
338
  return
326
339
 
327
- out(f'\nEstadisticas MCP — ultimos {dias} dia(s) ({len(trazas)} trazas):\n')
328
- out('-' * 72 + '\n')
329
- out(f' {"Servidor":<22} {"Herramienta":<24} {"Calls":>6} {"OK":>4} {"Err":>4} {"Avg ms":>7}\n')
330
- out('-' * 72 + '\n')
340
+ out(f"\nEstadisticas MCP — ultimos {dias} dia(s) ({len(trazas)} trazas):\n")
341
+ out("-" * 72 + "\n")
342
+ out(
343
+ f' {"Servidor":<22} {"Herramienta":<24} {"Calls":>6} {"OK":>4} {"Err":>4} {"Avg ms":>7}\n'
344
+ )
345
+ out("-" * 72 + "\n")
331
346
  for server, tools in sorted(stats.items()):
332
347
  for tool, s in sorted(tools.items()):
333
348
  out(
334
- f' {server:<22} {tool:<24}'
349
+ f" {server:<22} {tool:<24}"
335
350
  f' {s["count"]:>6} {s["ok"]:>4} {s["errors"]:>4} {s["avg_ms"]:>7}\n'
336
351
  )
337
352
 
@@ -342,37 +357,41 @@ def _cmd_stats(cwd: Path, dias: int, as_json: bool) -> None:
342
357
 
343
358
 
344
359
  def main() -> None:
345
- if hasattr(sys.stdout, 'reconfigure'):
346
- sys.stdout.reconfigure(encoding='utf-8', errors='replace')
360
+ if hasattr(sys.stdout, "reconfigure"):
361
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
347
362
 
348
363
  parser = argparse.ArgumentParser(
349
- description='Trazabilidad OTLP-lite para sesiones MCP en swl-ses'
364
+ description="Trazabilidad OTLP-lite para sesiones MCP en swl-ses"
350
365
  )
351
- parser.add_argument('--json', action='store_true', help='Salida en formato JSON')
352
- parser.add_argument('--cwd', default='.', help='Directorio raiz del proyecto')
366
+ parser.add_argument("--json", action="store_true", help="Salida en formato JSON")
367
+ parser.add_argument("--cwd", default=".", help="Directorio raiz del proyecto")
353
368
 
354
- sub = parser.add_subparsers(dest='cmd')
369
+ sub = parser.add_subparsers(dest="cmd")
355
370
 
356
- p_rep = sub.add_parser('report', help='Muestra trazas MCP recientes')
357
- p_rep.add_argument('--days', type=int, default=1,
358
- help='Numero de dias hacia atras (default: 1)')
371
+ p_rep = sub.add_parser("report", help="Muestra trazas MCP recientes")
372
+ p_rep.add_argument(
373
+ "--days", type=int, default=1, help="Numero de dias hacia atras (default: 1)"
374
+ )
359
375
 
360
- p_stats = sub.add_parser('stats', help='Estadisticas de uso por servidor/herramienta')
361
- p_stats.add_argument('--days', type=int, default=7,
362
- help='Numero de dias hacia atras (default: 7)')
376
+ p_stats = sub.add_parser(
377
+ "stats", help="Estadisticas de uso por servidor/herramienta"
378
+ )
379
+ p_stats.add_argument(
380
+ "--days", type=int, default=7, help="Numero de dias hacia atras (default: 7)"
381
+ )
363
382
 
364
383
  args = parser.parse_args()
365
- cwd = Path(args.cwd).resolve()
384
+ cwd = Path(args.cwd).resolve()
366
385
 
367
- as_json = bool(getattr(args, 'json', False))
386
+ as_json = bool(getattr(args, "json", False))
368
387
 
369
- if args.cmd == 'report':
388
+ if args.cmd == "report":
370
389
  _cmd_report(cwd, args.days, as_json)
371
- elif args.cmd == 'stats':
390
+ elif args.cmd == "stats":
372
391
  _cmd_stats(cwd, args.days, as_json)
373
392
  else:
374
393
  parser.print_help()
375
394
 
376
395
 
377
- if __name__ == '__main__':
396
+ if __name__ == "__main__":
378
397
  main()
@@ -272,6 +272,19 @@ function main() {
272
272
  fallasObligatorias++;
273
273
  }
274
274
 
275
+ // Gate de consistencia cross-manifest del campo description:
276
+ // valida que package.json#description y plugin.json#description tengan
277
+ // las mismas cifras (60 agentes / N habilidades / M comandos / K reglas / L hooks)
278
+ // Y que esas cifras coincidan con los conteos reales de INVENTARIO.md.
279
+ // Origen: bug v1.6.4 — package.json#description quedó con cifras stale de v1.6.2
280
+ // (162/67/41 + ADR-0025) mientras plugin.json#description se actualizó correctamente
281
+ // a (171/69/42 + ADR-0028). Ni el gate de contadores ni el de versión lo detectaron
282
+ // porque solo escanean .md, no campos `description` de manifiestos JSON.
283
+ const gateDescription = ejecutarGateDescription(gateContadores.contadoresReales);
284
+ if (gateDescription.disponible && gateDescription.discrepancias.length > 0) {
285
+ fallasObligatorias++;
286
+ }
287
+
275
288
  // Gate opt-in de AI-isms sobre CHANGELOG.md + RELEASE_NOTES.md
276
289
  // Se activa solo cuando SWL_AIISMS_GATE=1 y bloquea si encuentra P0.
277
290
  let aiismsGate = null;
@@ -291,6 +304,7 @@ function main() {
291
304
  warnings_opcionales: warningsOpcionales,
292
305
  contadores_gate: gateContadores,
293
306
  bin_imports_gate: gateBinImports,
307
+ description_gate: gateDescription,
294
308
  aiisms_gate: aiismsGate,
295
309
  resultados: resultados.map(({ entrada, resultado }) => ({
296
310
  archivo: entrada.archivo,
@@ -364,6 +378,32 @@ function main() {
364
378
  process.stdout.write('\n');
365
379
  process.stdout.write('Gate de bin-imports: no disponible — ' + gateBinImports.error + '\n');
366
380
  }
381
+ if (gateDescription.disponible) {
382
+ process.stdout.write('\n');
383
+ process.stdout.write('Gate de description (package.json vs plugin.json vs INVENTARIO.md):\n');
384
+ process.stdout.write(' package.json#description: ' +
385
+ formatearCifras(gateDescription.cifrasPackage) + '\n');
386
+ process.stdout.write(' plugin.json#description: ' +
387
+ formatearCifras(gateDescription.cifrasPlugin) + '\n');
388
+ if (gateDescription.contadoresReales) {
389
+ process.stdout.write(' Reales (INVENTARIO.md): ' +
390
+ formatearCifras(gateDescription.contadoresReales) + '\n');
391
+ }
392
+ if (gateDescription.discrepancias.length === 0) {
393
+ process.stdout.write(' [OK] description consistente cross-manifest y vs INVENTARIO.md\n');
394
+ } else {
395
+ for (const d of gateDescription.discrepancias) {
396
+ process.stdout.write(' [FALLA] ' + d + '\n');
397
+ }
398
+ process.stdout.write(
399
+ ' Bloqueo: actualiza description en package.json y/o plugin.json. ' +
400
+ 'Las cifras deben coincidir entre sí Y con INVENTARIO.md.\n'
401
+ );
402
+ }
403
+ } else if (gateDescription.error) {
404
+ process.stdout.write('\n');
405
+ process.stdout.write('Gate de description: no disponible — ' + gateDescription.error + '\n');
406
+ }
367
407
  if (aiismsGate) {
368
408
  process.stdout.write('\n');
369
409
  process.stdout.write('Gate AI-isms (SWL_AIISMS_GATE=1):\n');
@@ -603,6 +643,164 @@ function ejecutarGateAiisms() {
603
643
  return { disponible: true, archivos, p0Total };
604
644
  }
605
645
 
646
+ /**
647
+ * Extrae cifras cuantificadas del campo description de un manifiesto JSON.
648
+ *
649
+ * Busca patrones del tipo "N agentes", "N habilidades|skills", "N comandos",
650
+ * "N reglas", "N hooks" en cualquier orden y los devuelve como objeto.
651
+ *
652
+ * Devuelve null para cualquier categoría que no aparezca — el caller decide
653
+ * si tratar la ausencia como falla o como tolerable.
654
+ *
655
+ * @param {string} description - Texto del campo description.
656
+ * @returns {{agentes:number|null, skills:number|null, comandos:number|null, reglas:number|null, hooks:number|null}}
657
+ */
658
+ function extraerCifrasDescription(description) {
659
+ if (typeof description !== 'string' || description.trim() === '') {
660
+ return { agentes: null, skills: null, comandos: null, reglas: null, hooks: null };
661
+ }
662
+ const extraer = (regex) => {
663
+ const m = description.match(regex);
664
+ return m ? parseInt(m[1], 10) : null;
665
+ };
666
+ return {
667
+ agentes: extraer(/(\d+)\s+agentes\b/i),
668
+ // "habilidades" o "skills" — ambos válidos en descriptions del proyecto
669
+ skills: extraer(/(\d+)\s+(?:habilidades|skills)\b/i),
670
+ comandos: extraer(/(\d+)\s+comandos\b/i),
671
+ reglas: extraer(/(\d+)\s+reglas\b/i),
672
+ hooks: extraer(/(\d+)\s+hooks\b/i),
673
+ };
674
+ }
675
+
676
+ /**
677
+ * Formatea un objeto de cifras como "A agentes / S skills / C cmds / R reglas / H hooks"
678
+ * usando "?" cuando la cifra es null (no encontrada en el texto).
679
+ */
680
+ function formatearCifras(c) {
681
+ if (!c) return '(no extraído)';
682
+ const fmt = (v) => v == null ? '?' : String(v);
683
+ return `${fmt(c.agentes)} agentes / ${fmt(c.skills)} skills / ${fmt(c.comandos)} cmds / ${fmt(c.reglas)} reglas / ${fmt(c.hooks)} hooks`;
684
+ }
685
+
686
+ /**
687
+ * Gate de consistencia cross-manifest del campo description:
688
+ *
689
+ * 1. Lee package.json#description y plugin.json#description.
690
+ * 2. Extrae cifras (agentes, skills, comandos, reglas, hooks) de ambas.
691
+ * 3. Valida que las cifras coincidan entre los 2 manifiestos.
692
+ * 4. Si se pasa `contadoresReales` (de INVENTARIO.md), valida también que
693
+ * coincidan con la fuente de verdad estructural del disco.
694
+ *
695
+ * Solo reporta discrepancias para categorías donde AMBOS lados tienen cifra
696
+ * (si una description no menciona "hooks", no falla — solo se reporta como
697
+ * info en el campo cifrasPackage/cifrasPlugin).
698
+ *
699
+ * Origen: bug v1.6.4 — package.json#description quedó stale con (162 skills,
700
+ * 67 reglas, 41 hooks, ADR-0025) mientras plugin.json#description se
701
+ * actualizó a (171 skills, 69 reglas, 42 hooks, ADR-0028). El bug pasó por
702
+ * los otros 3 gates porque ninguno escaneaba campos JSON descriptivos.
703
+ *
704
+ * @param {object|null} contadoresReales - Output de contadoresLib.parsearInventario, opcional.
705
+ * @returns {{disponible:boolean, error?:string, cifrasPackage?:object, cifrasPlugin?:object, contadoresReales?:object, discrepancias:string[]}}
706
+ */
707
+ function ejecutarGateDescription(contadoresReales) {
708
+ let pkg, plugin;
709
+ try {
710
+ pkg = JSON.parse(fs.readFileSync(path.join(CWD, 'package.json'), 'utf-8'));
711
+ } catch (e) {
712
+ return { disponible: false, error: 'package.json no se pudo leer/parsear', discrepancias: [] };
713
+ }
714
+ try {
715
+ plugin = JSON.parse(fs.readFileSync(path.join(CWD, 'plugin.json'), 'utf-8'));
716
+ } catch (e) {
717
+ return { disponible: false, error: 'plugin.json no se pudo leer/parsear', discrepancias: [] };
718
+ }
719
+
720
+ const cifrasPackage = extraerCifrasDescription(pkg.description);
721
+ const cifrasPlugin = extraerCifrasDescription(plugin.description);
722
+ const discrepancias = [];
723
+
724
+ // Validación cross-manifest: package.json#description vs plugin.json#description
725
+ const categorias = ['agentes', 'skills', 'comandos', 'reglas', 'hooks'];
726
+ for (const cat of categorias) {
727
+ const v1 = cifrasPackage[cat];
728
+ const v2 = cifrasPlugin[cat];
729
+ // Solo comparar cuando AMBOS lados tienen cifra explícita
730
+ if (v1 != null && v2 != null && v1 !== v2) {
731
+ discrepancias.push(
732
+ `${cat}: package.json#description=${v1} vs plugin.json#description=${v2} ` +
733
+ `(deben coincidir)`
734
+ );
735
+ }
736
+ }
737
+
738
+ // Validación contra INVENTARIO.md (fuente de verdad estructural).
739
+ //
740
+ // Nota: NO usar truthy checks (`&& contadoresReales.agentes`) — `parsearInventario`
741
+ // devuelve 0 cuando una sección está ausente del INVENTARIO (línea
742
+ // `resumen['Agentes SWL'] || 0` en contadores-inventario.js). Un truthy
743
+ // check sobre 0 salta la validación y oculta INVENTARIO corrupto.
744
+ // Verificación por tipo (`typeof === 'number'`) distingue "campo ausente"
745
+ // de "campo presente con valor 0".
746
+ if (contadoresReales && typeof contadoresReales === 'object') {
747
+ const mapa = {
748
+ agentes: contadoresReales.agentes,
749
+ skills: contadoresReales.skills,
750
+ comandos: contadoresReales.comandos,
751
+ reglas: contadoresReales.reglas,
752
+ hooks: contadoresReales.hooks,
753
+ };
754
+ // Sanidad: un INVENTARIO con TODAS las cifras = 0 indica parsing fallido
755
+ // (el sistema real nunca tiene 0 agentes/skills). Reportar como
756
+ // discrepancia explícita en lugar de saltar silenciosamente.
757
+ const algunaCifraValida = Object.values(mapa).some(
758
+ v => typeof v === 'number' && v > 0
759
+ );
760
+ if (!algunaCifraValida) {
761
+ discrepancias.push(
762
+ 'INVENTARIO.md parece corrupto o no parseable (todas las cifras = 0). ' +
763
+ 'Regenera con `node scripts/generar-inventario.js` antes de release. ' +
764
+ 'La validación cross-manifest sigue siendo válida; solo se salta ' +
765
+ 'la comparación vs INVENTARIO hasta que sea legible.'
766
+ );
767
+ } else {
768
+ for (const cat of categorias) {
769
+ const real = mapa[cat];
770
+ // Solo saltar si el campo NO es número (ausente/undefined).
771
+ // Aceptar 0 como cifra válida — la comparación detectará discrepancias.
772
+ if (typeof real !== 'number') continue;
773
+ if (cifrasPackage[cat] != null && cifrasPackage[cat] !== real) {
774
+ discrepancias.push(
775
+ `${cat}: package.json#description=${cifrasPackage[cat]} vs INVENTARIO=${real} ` +
776
+ `(description debe reflejar conteo real)`
777
+ );
778
+ }
779
+ if (cifrasPlugin[cat] != null && cifrasPlugin[cat] !== real) {
780
+ discrepancias.push(
781
+ `${cat}: plugin.json#description=${cifrasPlugin[cat]} vs INVENTARIO=${real} ` +
782
+ `(description debe reflejar conteo real)`
783
+ );
784
+ }
785
+ }
786
+ }
787
+ }
788
+
789
+ return {
790
+ disponible: true,
791
+ cifrasPackage,
792
+ cifrasPlugin,
793
+ contadoresReales: contadoresReales || null,
794
+ discrepancias,
795
+ };
796
+ }
797
+
606
798
  if (require.main === module) main();
607
799
 
608
- module.exports = { CHECKLIST, verificarEntrada, versionObjetivo };
800
+ module.exports = {
801
+ CHECKLIST,
802
+ verificarEntrada,
803
+ versionObjetivo,
804
+ extraerCifrasDescription,
805
+ ejecutarGateDescription,
806
+ };