@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.
- package/CLAUDE.md +3 -3
- package/README.md +2 -2
- package/agentes/gh-fix-ci-swl.md +275 -0
- package/agentes/nemesis-auditor-swl.md +90 -1
- package/comandos/swl/exportar-vault.md +106 -14
- package/comandos/swl/nemesis.md +70 -3
- package/comandos/swl/release.md +62 -2
- package/comandos/swl/salud.md +32 -0
- package/comandos/swl/verificar.md +116 -2
- package/habilidades/agent-browser/SKILL.md +111 -4
- package/habilidades/agent-deep-links/SKILL.md +148 -0
- package/habilidades/backend-async-postgres-testing/SKILL.md +215 -0
- package/habilidades/backend-error-design/SKILL.md +221 -0
- package/habilidades/browser-interaction-patterns/SKILL.md +514 -0
- package/habilidades/browser-research-domains/SKILL.md +635 -0
- package/habilidades/changelog-generator/SKILL.md +172 -0
- package/habilidades/changelog-generator/scripts/parse-commits.js +354 -0
- package/habilidades/devsecops-pipeline-security/SKILL.md +3 -0
- package/habilidades/fastapi-experto/SKILL.md +49 -4
- package/habilidades/harness-claude-code/SKILL.md +4 -1
- package/habilidades/postgresql-experto/SKILL.md +80 -4
- package/habilidades/proceso-discovery-machote/SKILL.md +157 -0
- package/habilidades/proceso-modular-split/SKILL.md +256 -0
- package/habilidades/tdd-workflow/SKILL.md +12 -5
- package/hooks/extraccion-aprendizajes.js +8 -0
- package/hooks/lib/deep-links.js +185 -0
- package/hooks/lib/evolution-tracker.js +148 -20
- package/hooks/lib/gateway-notify.js +70 -7
- package/manifiestos/modulos.json +13 -3
- package/manifiestos/skills-lock.json +1247 -1191
- package/package.json +92 -92
- package/plugin.json +371 -362
- package/reglas/arquitectura.md +38 -0
- package/reglas/arreglar-al-detectar.md +93 -0
- package/reglas/auditorias-documentales-estructurales.md +38 -0
- package/reglas/registro-componentes-nuevos.md +14 -0
- package/reglas/tests-cleanup.md +220 -0
- package/scripts/instalador.js +72 -4
- package/scripts/lib/mcp_config.py +29 -14
- package/scripts/lib/notificaciones-telegram.js +14 -0
- package/scripts/lib/transformadores/codex.js +4 -0
- package/scripts/lib/transformadores/cursor.js +5 -0
- package/scripts/mcp-orchestrator.py +153 -131
- package/scripts/mcp-pool-manager.py +132 -107
- package/scripts/mcp-telemetry.py +139 -120
- package/scripts/verificar-release.js +199 -1
package/scripts/mcp-telemetry.py
CHANGED
|
@@ -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
|
|
48
|
-
MCP_PREFIX
|
|
49
|
-
TIMEOUT_S
|
|
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)
|
|
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)
|
|
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(
|
|
73
|
-
return directorio / f
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def registrar_traza(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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,
|
|
108
|
-
fh.write(json.dumps(traza, ensure_ascii=False) +
|
|
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
|
|
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
|
|
145
|
+
self.cwd = cwd
|
|
132
146
|
self.server_name = server_name
|
|
133
|
-
self.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) ->
|
|
151
|
+
async def __aenter__(self) -> "MCPTelemetrySession":
|
|
138
152
|
if not HAS_MCP:
|
|
139
|
-
raise RuntimeError(
|
|
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
|
|
143
|
-
transport = sse_client(cfg[
|
|
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[
|
|
150
|
-
args=cfg.get(
|
|
151
|
-
env=
|
|
152
|
-
cwd=cfg.get(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
194
|
-
resultado: dict = {
|
|
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[
|
|
202
|
-
resultado[
|
|
203
|
-
c.text if hasattr(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[
|
|
207
|
-
estado =
|
|
218
|
+
if resultado["is_error"]:
|
|
219
|
+
estado = "ERROR"
|
|
208
220
|
except Exception as exc:
|
|
209
|
-
resultado[
|
|
210
|
-
estado =
|
|
221
|
+
resultado["error"] = str(exc)
|
|
222
|
+
estado = "ERROR"
|
|
211
223
|
finally:
|
|
212
|
-
fin_ts
|
|
213
|
-
dur_ms
|
|
224
|
+
fin_ts = time.time()
|
|
225
|
+
dur_ms = int((fin_ts - inicio_ts) * 1000)
|
|
214
226
|
fin_iso = _iso_now()
|
|
215
|
-
resultado[
|
|
227
|
+
resultado["duration_ms"] = dur_ms
|
|
216
228
|
|
|
217
229
|
registrar_traza(
|
|
218
|
-
cwd
|
|
219
|
-
nombre
|
|
220
|
-
atributos
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
227
|
-
inicio_iso
|
|
228
|
-
fin_iso
|
|
229
|
-
duracion_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
|
|
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=
|
|
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
|
|
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) +
|
|
279
|
+
out(json.dumps(trazas, ensure_ascii=False, indent=2) + "\n")
|
|
267
280
|
return
|
|
268
281
|
|
|
269
282
|
if not trazas:
|
|
270
|
-
out(f
|
|
271
|
-
out(f
|
|
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
|
|
275
|
-
out(
|
|
276
|
-
for t in trazas[-50:]:
|
|
277
|
-
estado = t.get(
|
|
278
|
-
nombre = t.get(
|
|
279
|
-
dur
|
|
280
|
-
attrs
|
|
281
|
-
server = attrs.get(
|
|
282
|
-
tool
|
|
283
|
-
ts
|
|
284
|
-
out(f
|
|
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
|
|
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 = {}
|
|
305
|
+
stats: dict = {} # server -> {tool -> {count, ok, errors, total_ms}}
|
|
293
306
|
for t in trazas:
|
|
294
|
-
attrs
|
|
295
|
-
server = attrs.get(
|
|
296
|
-
tool
|
|
297
|
-
estado = t.get(
|
|
298
|
-
dur
|
|
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] = {
|
|
316
|
+
stats[server][tool] = {"count": 0, "ok": 0, "errors": 0, "total_ms": 0}
|
|
304
317
|
|
|
305
318
|
s = stats[server][tool]
|
|
306
|
-
s[
|
|
307
|
-
s[
|
|
308
|
-
if estado ==
|
|
309
|
-
s[
|
|
319
|
+
s["count"] += 1
|
|
320
|
+
s["total_ms"] += dur
|
|
321
|
+
if estado == "OK":
|
|
322
|
+
s["ok"] += 1
|
|
310
323
|
else:
|
|
311
|
-
s[
|
|
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[
|
|
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) +
|
|
333
|
+
out(json.dumps(stats, ensure_ascii=False, indent=2) + "\n")
|
|
321
334
|
return
|
|
322
335
|
|
|
323
336
|
if not stats:
|
|
324
|
-
out(f
|
|
337
|
+
out(f"Sin datos de uso MCP en los ultimos {dias} dia(s).\n")
|
|
325
338
|
return
|
|
326
339
|
|
|
327
|
-
out(f
|
|
328
|
-
out(
|
|
329
|
-
out(
|
|
330
|
-
|
|
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
|
|
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,
|
|
346
|
-
sys.stdout.reconfigure(encoding=
|
|
360
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
361
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
347
362
|
|
|
348
363
|
parser = argparse.ArgumentParser(
|
|
349
|
-
description=
|
|
364
|
+
description="Trazabilidad OTLP-lite para sesiones MCP en swl-ses"
|
|
350
365
|
)
|
|
351
|
-
parser.add_argument(
|
|
352
|
-
parser.add_argument(
|
|
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=
|
|
369
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
355
370
|
|
|
356
|
-
p_rep = sub.add_parser(
|
|
357
|
-
p_rep.add_argument(
|
|
358
|
-
|
|
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(
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
384
|
+
cwd = Path(args.cwd).resolve()
|
|
366
385
|
|
|
367
|
-
as_json = bool(getattr(args,
|
|
386
|
+
as_json = bool(getattr(args, "json", False))
|
|
368
387
|
|
|
369
|
-
if args.cmd ==
|
|
388
|
+
if args.cmd == "report":
|
|
370
389
|
_cmd_report(cwd, args.days, as_json)
|
|
371
|
-
elif args.cmd ==
|
|
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__ ==
|
|
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 = {
|
|
800
|
+
module.exports = {
|
|
801
|
+
CHECKLIST,
|
|
802
|
+
verificarEntrada,
|
|
803
|
+
versionObjetivo,
|
|
804
|
+
extraerCifrasDescription,
|
|
805
|
+
ejecutarGateDescription,
|
|
806
|
+
};
|