@saulwade/swl-ses 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CLAUDE.md +196 -196
  2. package/README.md +579 -579
  3. package/agentes/_propose-step.md +90 -0
  4. package/agentes/implementador-swl.md +2 -0
  5. package/agentes/orquestador-swl.md +2 -0
  6. package/agentes/perfilador-usuario-swl.md +14 -1
  7. package/bin/swl-ses.js +64 -1
  8. package/comandos/swl/adoptar-proyecto.md +258 -255
  9. package/comandos/swl/aprender.md +828 -840
  10. package/comandos/swl/aprobar-plan.md +26 -37
  11. package/comandos/swl/autoresearch.md +12 -14
  12. package/comandos/swl/briefing.md +119 -0
  13. package/comandos/swl/checkpoint.md +10 -15
  14. package/comandos/swl/claudemd.md +239 -234
  15. package/comandos/swl/compactar.md +29 -2
  16. package/comandos/swl/configurar-ci.md +20 -19
  17. package/comandos/swl/cron.md +10 -12
  18. package/comandos/swl/discutir-fase.md +8 -5
  19. package/comandos/swl/ejecutar-fase.md +15 -2
  20. package/comandos/swl/evolucionar.md +6 -11
  21. package/comandos/swl/inbox.md +10 -10
  22. package/comandos/swl/modelo.md +7 -9
  23. package/comandos/swl/notificaciones.md +19 -116
  24. package/comandos/swl/nuevo-proyecto.md +205 -205
  25. package/comandos/swl/planear-fase.md +5 -3
  26. package/comandos/swl/release.md +46 -0
  27. package/comandos/swl/status.md +333 -279
  28. package/comandos/swl/verificar.md +817 -812
  29. package/habilidades/changelog-generator/scripts/parse-commits.js +6 -4
  30. package/habilidades/ejecutar-fase/SKILL.md +541 -518
  31. package/habilidades/planear-fase/SKILL.md +3 -2
  32. package/habilidades/swl-claudemd/SKILL.md +10 -6
  33. package/habilidades/tdd-workflow/SKILL.md +715 -713
  34. package/habilidades/validacion-ci-sistema/SKILL.md +17 -1
  35. package/hooks/calidad-pre-commit.js +5 -1
  36. package/hooks/check-update.js +39 -1
  37. package/hooks/lib/autonomia.js +208 -0
  38. package/hooks/lib/briefing.js +474 -0
  39. package/hooks/lib/propose-step.js +358 -0
  40. package/hooks/session-briefing.js +98 -0
  41. package/hooks/telemetria-skill-routing.js +100 -0
  42. package/instintos/autonomia.yaml +27 -0
  43. package/llms.txt +4 -4
  44. package/manifiestos/hooks-config.json +18 -0
  45. package/manifiestos/modulos.json +25 -3
  46. package/manifiestos/skills-lock.json +17 -17
  47. package/package.json +93 -93
  48. package/plugin.json +371 -371
  49. package/reglas/analizar-directorios-antes-de-escribir.md +228 -0
  50. package/reglas/consultar-vault-primero.md +195 -0
  51. package/reglas/debatir-antes-de-aceptar.md +158 -0
  52. package/reglas/git-coauthor.md +100 -0
  53. package/reglas/monitor-ci.md +309 -0
  54. package/reglas/registro-componentes-nuevos.md +38 -10
  55. package/reglas/sesiones-paralelas.md +180 -0
  56. package/reglas/usar-code-review-graph.md +155 -0
  57. package/reglas/verificar-citas-normativas.md +548 -0
  58. package/scripts/auditar-claudemd.js +38 -0
  59. package/scripts/cli/aprobar-plan.js +73 -0
  60. package/scripts/cli/briefing.js +23 -0
  61. package/scripts/cli/ciclo-evolucion.js +26 -0
  62. package/scripts/cli/configurar-ci.js +40 -0
  63. package/scripts/cli/derivar-feature-list.js +25 -0
  64. package/scripts/cli/detectar-host.js +27 -0
  65. package/scripts/cli/diary-entry.js +69 -0
  66. package/scripts/cli/execution-state.js +18 -0
  67. package/scripts/cli/gateway-notify.js +41 -0
  68. package/scripts/cli/liberar-fase.js +42 -0
  69. package/scripts/cli/loop-telemetry.js +125 -0
  70. package/scripts/cli/mark-evolved.js +56 -0
  71. package/scripts/cli/metricas-dora.js +26 -0
  72. package/scripts/cli/near-duplicate.js +55 -0
  73. package/scripts/cli/notificaciones.js +123 -0
  74. package/scripts/cli/propose-step.js +29 -0
  75. package/scripts/cli/schedule-parse.js +19 -0
  76. package/scripts/cli/sugerir-modelo.js +20 -0
  77. package/scripts/cli/verificar-plan.js +36 -0
  78. package/scripts/cli/verificar-trazabilidad.js +35 -0
  79. package/scripts/derivar-feature-list.js +1 -0
  80. package/scripts/instalador.js +52 -6
  81. package/scripts/lib/auditar-invocaciones-comandos.js +104 -0
  82. package/scripts/lib/ci-reader.js +193 -0
  83. package/scripts/lib/detectar-host-swl.js +175 -0
  84. package/scripts/lib/evidencia-release.js +322 -0
  85. package/scripts/lib/gate-hooks-requires.js +249 -0
  86. package/scripts/lib/gate-licencias.js +212 -0
  87. package/scripts/lib/git-metricas.js +257 -0
  88. package/scripts/lib/metricas-dora.js +204 -0
  89. package/scripts/lib/resolver-plan-fase.js +37 -0
  90. package/scripts/tui/ejecutores.js +1 -1
  91. package/scripts/validar-manifest.js +92 -1
  92. package/scripts/validar.js +13 -0
  93. package/scripts/verificar-evolucion.js +54 -4
  94. package/scripts/verificar-release.js +102 -0
  95. package/scripts/verificar-trazabilidad.js +12 -6
  96. package/reglas/arquitectura.evolved.json +0 -7
  97. package/reglas/seguridad.evolved.json +0 -7
@@ -0,0 +1,474 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * briefing.js — Recolectores de señales para el briefing proactivo de sesión.
5
+ *
6
+ * Fase 12 (ADR-0036). Cada recolector es una función PURA que recibe `baseDir`
7
+ * (raíz del proyecto destino) y `hoy` (Date inyectable para testabilidad) y
8
+ * devuelve un array de ítems `{ categoria, titulo, accion, hash }`:
9
+ * - categoria: clave estable de la señal (para telemetría y dedupe por grupo).
10
+ * - titulo: texto corto de qué pasa.
11
+ * - accion: comando ejecutable concreto (REQ-12-03: nunca consejo vago).
12
+ * - hash: sha1 corto de categoria+titulo, para dedupe entre digests (D-18).
13
+ *
14
+ * Solo lecturas de filesystem ya computado: cero LLM, cero red, presupuesto
15
+ * <200ms (REQ-12-02). Las señales caras (CVEs, cobertura, hubs del grafo) viven
16
+ * en el comando `/swl:briefing`, no aquí (D-12).
17
+ *
18
+ * Zero-deps. El hook session-briefing.js consume esta lib con require de
19
+ * fallback dual (repo madre `./lib/briefing` vs destino aplanado `./briefing`).
20
+ *
21
+ * @module hooks/lib/briefing
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const crypto = require('crypto');
27
+
28
+ // Métricas DORA (Fase 15, ADR-0039): require tolerante. En modo de instalación
29
+ // `flat` el require cross-dir hooks/lib → scripts/lib no resuelve y el recolector
30
+ // degrada a silencio; en modo `copy` (default) resuelve y el aviso proactivo
31
+ // funciona. El recolector usa SOLO git (rápido); NUNCA invoca gh.
32
+ let detectarDegradacionEntrega = null;
33
+ try {
34
+ ({ detectarDegradacionEntrega } = require('../../scripts/lib/git-metricas'));
35
+ } catch {
36
+ try {
37
+ ({ detectarDegradacionEntrega } = require('./git-metricas'));
38
+ } catch {
39
+ detectarDegradacionEntrega = null;
40
+ }
41
+ }
42
+
43
+ const VENTANA_CALIBRACION_MS = 14 * 24 * 3600 * 1000; // 14 días (ADR-0034/0035)
44
+
45
+ // Gates en calibración → kind de su nudge (conocimiento de dominio, Fase 10).
46
+ const GATES_CALIBRABLES = {
47
+ 'spec-gate.js': 'spec-gate',
48
+ 'tdd-gate.js': 'tdd-red-evidence',
49
+ };
50
+
51
+ // ─── utilidades ──────────────────────────────────────────────────────────────
52
+
53
+ function leerTexto(p) {
54
+ try {
55
+ return fs.readFileSync(p, 'utf8');
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ function hashItem(categoria, titulo) {
62
+ return crypto.createHash('sha1').update(`${categoria}\u0000${titulo}`).digest('hex').slice(0, 10);
63
+ }
64
+
65
+ function item(categoria, titulo, accion) {
66
+ return { categoria, titulo, accion, hash: hashItem(categoria, titulo) };
67
+ }
68
+
69
+ /** Primera fecha ISO `YYYY-MM-DD` de un texto, como Date (UTC) o null. */
70
+ function primeraFechaISO(texto) {
71
+ const m = (texto || '').match(/(\d{4})-(\d{2})-(\d{2})/);
72
+ if (!m) return null;
73
+ const d = new Date(`${m[1]}-${m[2]}-${m[3]}T00:00:00Z`);
74
+ return Number.isNaN(d.getTime()) ? null : d;
75
+ }
76
+
77
+ /** Lee `.planning/evolution/nudges.jsonl` como array de objetos (tolera líneas corruptas). */
78
+ function leerNudges(baseDir) {
79
+ const txt = leerTexto(path.join(baseDir, '.planning', 'evolution', 'nudges.jsonl'));
80
+ if (!txt) return [];
81
+ const out = [];
82
+ for (const linea of txt.split('\n')) {
83
+ const t = linea.trim();
84
+ if (!t) continue;
85
+ try {
86
+ out.push(JSON.parse(t));
87
+ } catch {
88
+ /* línea corrupta: ignorar */
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
94
+ // ─── recolectores ──────────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * ADRs en estado Propuesto cuya fecha de reevaluación ya pasó.
98
+ * Fuente: `.planning/adrs/README.md` (tabla `| # | Título | Estado | Fecha | Reevaluar |`).
99
+ */
100
+ function adrsVencidos(baseDir, hoy = new Date()) {
101
+ const txt = leerTexto(path.join(baseDir, '.planning', 'adrs', 'README.md'));
102
+ if (!txt) return [];
103
+ const out = [];
104
+ for (const linea of txt.split('\n')) {
105
+ if (!linea.includes('|')) continue;
106
+ const celdas = linea.split('|').map((c) => c.trim());
107
+ // celdas: ['', '#', 'Título', 'Estado', 'Fecha', 'Reevaluar', '']
108
+ if (celdas.length < 6) continue;
109
+ const num = celdas[1];
110
+ const titulo = celdas[2];
111
+ const estado = celdas[3];
112
+ const reevaluar = celdas[5];
113
+ if (!/^\d{3,4}$/.test(num)) continue; // fila de datos (no encabezado/separador)
114
+ if (!/Propuesto/i.test(estado)) continue;
115
+ const fecha = primeraFechaISO(reevaluar);
116
+ if (!fecha || fecha.getTime() > hoy.getTime()) continue;
117
+ out.push(
118
+ item(
119
+ 'adr-vencido',
120
+ `ADR-${num} Propuesto con reevaluación vencida (${reevaluar.replace(/\*/g, '')})`,
121
+ `Revisar .planning/adrs/${num}-*.md y resolver (aceptar / descartar / extender)`
122
+ )
123
+ );
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /**
129
+ * Deuda técnica Abierta cuyo trigger verificable contiene una fecha ISO ya pasada.
130
+ * Solo fechas (D-16): los triggers en prosa libre quedan para `/swl:briefing`.
131
+ * Fuente: `.planning/DEUDA-TECNICA.md` (entradas `## DT-X —`, `**Estado**: Abierta`).
132
+ */
133
+ function deudaTriggerCumplido(baseDir, hoy = new Date()) {
134
+ const txt = leerTexto(path.join(baseDir, '.planning', 'DEUDA-TECNICA.md'));
135
+ if (!txt) return [];
136
+ const out = [];
137
+ // Partir por encabezados `## DT-...` / `## DA-...` / `## OP-...`
138
+ const bloques = txt.split(/\n(?=## (?:DT|DA|OP)-)/);
139
+ for (const bloque of bloques) {
140
+ const cab = bloque.match(/^##\s+((?:DT|DA|OP)-[A-Z0-9-]+)\s*(?:—\s*(.*))?/m);
141
+ if (!cab) continue;
142
+ const id = cab[1];
143
+ if (!/\*\*Estado\*\*\s*:\s*Abierta/i.test(bloque)) continue;
144
+ // Buscar fecha ISO en la sección de trigger (o en todo el bloque como fallback)
145
+ const triggerSec = bloque.split(/###\s*Trigger/i)[1] || bloque;
146
+ const fecha = primeraFechaISO(triggerSec);
147
+ if (!fecha || fecha.getTime() > hoy.getTime()) continue;
148
+ out.push(
149
+ item(
150
+ 'deuda-trigger',
151
+ `${id}: trigger con fecha cumplida (${fecha.toISOString().slice(0, 10)})`,
152
+ `Revisar .planning/DEUDA-TECNICA.md § ${id} y cerrar o re-planear`
153
+ )
154
+ );
155
+ }
156
+ return out;
157
+ }
158
+
159
+ /**
160
+ * Nudges sin accionar acumulados (formato nudge-tracker: { kind, accionado }).
161
+ * Las líneas legacy sin `kind` se ignoran.
162
+ */
163
+ function nudgesPendientes(baseDir /*, hoy */) {
164
+ const nudges = leerNudges(baseDir).filter((n) => n && n.kind && n.accionado === false);
165
+ if (nudges.length === 0) return [];
166
+ const porKind = {};
167
+ for (const n of nudges) porKind[n.kind] = (porKind[n.kind] || 0) + 1;
168
+ const detalle = Object.entries(porKind)
169
+ .map(([k, c]) => `${k}×${c}`)
170
+ .join(', ');
171
+ return [
172
+ item(
173
+ 'nudges-pendientes',
174
+ `${nudges.length} nudge(s) sin accionar (${detalle})`,
175
+ 'Ejecutar /swl:evolucion-estado para revisarlos y accionar'
176
+ ),
177
+ ];
178
+ }
179
+
180
+ /**
181
+ * Gates en calibración (warn-only) cuya ventana de ~14 días ya se cumplió:
182
+ * blocking:false en hooks-config Y el nudge más antiguo de su kind ≥14 días.
183
+ * Señal de que toca decidir la promoción a blocking (ADR-0034/0035).
184
+ */
185
+ function gatesEnCalibracion(baseDir, hoy = new Date(), opts = {}) {
186
+ const hooksConfigPath =
187
+ opts.hooksConfigPath || path.join(baseDir, 'manifiestos', 'hooks-config.json');
188
+ const txt = leerTexto(hooksConfigPath);
189
+ if (!txt) return [];
190
+ let config;
191
+ try {
192
+ config = JSON.parse(txt);
193
+ } catch {
194
+ return [];
195
+ }
196
+ const hooks = (config && config.hooks) || {};
197
+ const nudges = leerNudges(baseDir).filter((n) => n && n.kind && n.ts);
198
+ const out = [];
199
+ for (const [archivo, kind] of Object.entries(GATES_CALIBRABLES)) {
200
+ const entry = hooks[archivo];
201
+ if (!entry || entry.blocking !== false) continue; // ya promovido o ausente
202
+ const delKind = nudges
203
+ .map((n) => (n.kind === kind ? Date.parse(n.ts) : NaN))
204
+ .filter((t) => Number.isFinite(t));
205
+ if (delKind.length === 0) continue; // sin evidencia todavía
206
+ const masAntiguo = Math.min(...delKind);
207
+ if (hoy.getTime() - masAntiguo < VENTANA_CALIBRACION_MS) continue; // ventana no cumplida
208
+ out.push(
209
+ item(
210
+ 'gate-calibracion',
211
+ `Gate ${archivo} en calibración con ventana cumplida (${delKind.length} nudge[s] kind:${kind})`,
212
+ `Revisar promoción a blocking (ADR-0034/0035) y flip en manifiestos/hooks-config.json`
213
+ )
214
+ );
215
+ }
216
+ return out;
217
+ }
218
+
219
+ /**
220
+ * Trabajo pendiente de retoma: existe `.planning/continue-here.md`.
221
+ */
222
+ function continuePendiente(baseDir /*, hoy */) {
223
+ const p = path.join(baseDir, '.planning', 'continue-here.md');
224
+ const txt = leerTexto(p);
225
+ if (txt === null) return [];
226
+ const m = txt.match(/\*\*Checkpoint creado\*\*\s*:\s*(.+)/);
227
+ const cuando = m ? m[1].trim() : 'desconocido';
228
+ return [
229
+ item(
230
+ 'continue-here',
231
+ `Checkpoint de retoma pendiente (${cuando})`,
232
+ 'Leer .planning/continue-here.md y .planning/ESTADO.md para retomar'
233
+ ),
234
+ ];
235
+ }
236
+
237
+ /**
238
+ * Recolector DORA (Fase 15, REQ-15-07): avisa SOLO cuando la velocidad de entrega
239
+ * se degrada (deploy freq cae >50% o lead time mediano sube >50% vs la ventana
240
+ * previa). Git-only y rápido — NUNCA invoca `gh` (la latencia de gh queda para el
241
+ * subcomando bajo demanda). Opt-out: `SWL_DORA=0`. Silencio si la entrega es
242
+ * estable, si no hay repo git, o si la lib no resuelve (modo `flat`).
243
+ */
244
+ function doraDegradado(baseDir, hoy = new Date()) {
245
+ if (process.env.SWL_DORA === '0') return [];
246
+ if (typeof detectarDegradacionEntrega !== 'function') return [];
247
+ let r;
248
+ try {
249
+ r = detectarDegradacionEntrega(baseDir, { hoy });
250
+ } catch {
251
+ return [];
252
+ }
253
+ if (!r || !r.disponible || !r.degradado) return [];
254
+ return [
255
+ item(
256
+ 'dora-degradado',
257
+ `Velocidad de entrega degradada: ${r.motivo}`,
258
+ 'Revisar con /swl:status dora'
259
+ ),
260
+ ];
261
+ }
262
+
263
+ /** Orden de prioridad de categorías para el digest (mayor primero). */
264
+ const PRIORIDAD = [
265
+ 'continue-here',
266
+ 'gate-calibracion',
267
+ 'deuda-trigger',
268
+ 'dora-degradado',
269
+ 'adr-vencido',
270
+ 'nudges-pendientes',
271
+ ];
272
+
273
+ /** Ejecuta todos los recolectores y devuelve el array plano de ítems. */
274
+ function recolectarTodo(baseDir, hoy = new Date(), opts = {}) {
275
+ return [
276
+ ...continuePendiente(baseDir, hoy),
277
+ ...gatesEnCalibracion(baseDir, hoy, opts),
278
+ ...deudaTriggerCumplido(baseDir, hoy),
279
+ ...doraDegradado(baseDir, hoy),
280
+ ...adrsVencidos(baseDir, hoy),
281
+ ...nudgesPendientes(baseDir, hoy),
282
+ ];
283
+ }
284
+
285
+ const TOPE_DIGEST = 5; // D-13: máximo de ítems por digest
286
+
287
+ /** Índice de prioridad de una categoría (menor = mayor prioridad). */
288
+ function indicePrioridad(categoria) {
289
+ const i = PRIORIDAD.indexOf(categoria);
290
+ return i === -1 ? PRIORIDAD.length : i;
291
+ }
292
+
293
+ /**
294
+ * Arma el digest del briefing aplicando dedupe diario (D-15), tope de 5 con
295
+ * overflow (D-13), y sugerencia de `/swl:briefing` solo con volumen (D-14).
296
+ *
297
+ * @param {Array} items ítems recolectados (recolectarTodo)
298
+ * @param {object} estadoPrevio { hashesPrevios: string[], dia: 'YYYY-MM-DD' }
299
+ * @param {string} dia día actual 'YYYY-MM-DD' (inyectable)
300
+ * @returns {{ texto: string, estado: object, mostrados: string[] } | null}
301
+ * null = silencio total (sin señales nuevas).
302
+ */
303
+ function armarDigest(items, estadoPrevio = {}, dia, opts = {}) {
304
+ const hoyStr = dia || new Date().toISOString().slice(0, 10);
305
+ const mismoDia = estadoPrevio && estadoPrevio.dia === hoyStr;
306
+ const yaMostrados = mismoDia && Array.isArray(estadoPrevio.hashesPrevios)
307
+ ? new Set(estadoPrevio.hashesPrevios)
308
+ : new Set();
309
+
310
+ // Dedupe: en el mismo día, omitir ítems ya mostrados. Día nuevo → set vacío.
311
+ const frescos = (items || []).filter((it) => it && !yaMostrados.has(it.hash));
312
+ if (frescos.length === 0) return null; // silencio total (REQ-12-01)
313
+
314
+ // Categorías silenciadas (telemetría D-18): sus ítems no entran al top 5,
315
+ // van al final (overflow) para no copar el digest con señales que el usuario ignora.
316
+ const silenciadas = opts.categoriasSilenciadas instanceof Set ? opts.categoriasSilenciadas : new Set();
317
+
318
+ // Orden: prioridad de categoría, pero las silenciadas siempre al final.
319
+ frescos.sort((a, b) => {
320
+ const sa = silenciadas.has(a.categoria) ? 1 : 0;
321
+ const sb = silenciadas.has(b.categoria) ? 1 : 0;
322
+ if (sa !== sb) return sa - sb;
323
+ return indicePrioridad(a.categoria) - indicePrioridad(b.categoria);
324
+ });
325
+
326
+ const visibles = frescos.slice(0, TOPE_DIGEST);
327
+ const overflow = frescos.length - visibles.length;
328
+
329
+ const lineas = ['Briefing de sesión — señales pendientes:'];
330
+ for (const it of visibles) {
331
+ lineas.push(`— [${it.categoria}] ${it.titulo} → ${it.accion}`);
332
+ }
333
+ // Sugerencia de /swl:briefing SOLO con overflow (D-14).
334
+ if (overflow > 0) {
335
+ lineas.push(`+${overflow} señales más → ejecuta /swl:briefing para el análisis completo`);
336
+ }
337
+
338
+ // Estado nuevo: acumula los hashes mostrados HOY (frescos completos, no solo visibles —
339
+ // los de overflow también se consideran "vistos" para no re-listarlos mañana mismo).
340
+ const hashesMostrados = mismoDia
341
+ ? [...yaMostrados, ...frescos.map((it) => it.hash)]
342
+ : frescos.map((it) => it.hash);
343
+
344
+ return {
345
+ texto: lineas.join('\n'),
346
+ estado: { dia: hoyStr, hashesPrevios: [...new Set(hashesMostrados)] },
347
+ mostrados: visibles.map((it) => it.hash),
348
+ };
349
+ }
350
+
351
+ /** Lee el estado de dedupe persistido en `.planning/user-profile/briefing-estado.json`. */
352
+ function leerEstadoBriefing(baseDir) {
353
+ const txt = leerTexto(path.join(baseDir, '.planning', 'user-profile', 'briefing-estado.json'));
354
+ if (!txt) return { dia: null, hashesPrevios: [] };
355
+ try {
356
+ const o = JSON.parse(txt);
357
+ return { dia: o.dia || null, hashesPrevios: Array.isArray(o.hashesPrevios) ? o.hashesPrevios : [] };
358
+ } catch {
359
+ return { dia: null, hashesPrevios: [] };
360
+ }
361
+ }
362
+
363
+ // ─── telemetría de aceptación (D-18, REQ-12-05) ──────────────────────────────
364
+
365
+ const UMBRAL_IGNORADO = 3; // apariciones sin resolver para contar "ignorado"
366
+ const RATIO_SILENCIO = 0.8; // ignorado/mostrado para silenciar la categoría
367
+ const MIN_MUESTRAS_SILENCIO = 5; // mínimo de mostrados antes de silenciar
368
+
369
+ function catVacia() {
370
+ return { mostrado: 0, actuado: 0, ignorado: 0, silenciada: false, ultima_ts: null };
371
+ }
372
+
373
+ /**
374
+ * Actualiza la telemetría de aceptación comparando lo visto antes con lo que
375
+ * sigue presente ahora (D-18: detección por resolución de señal, zero-LLM):
376
+ * - señal que desaparece de los ítems actuales → "actuado" en su categoría.
377
+ * - señal nueva → "mostrado" y entra a `vistos`.
378
+ * - señal que persiste ≥UMBRAL_IGNORADO corridas → "ignorado" (una sola vez).
379
+ * - categoría con ignorado/mostrado ≥RATIO_SILENCIO y ≥MIN_MUESTRAS → silenciada.
380
+ *
381
+ * @param {object} prev { vistos:{hash:{categoria,veces,contadoIgnorado?}}, categorias:{} }
382
+ * @param {Array} itemsActuales ítems recolectados ahora
383
+ * @param {string} hoyISO timestamp ISO de esta corrida
384
+ * @returns {object} telemetría nueva (consumible por perfilador-usuario-swl)
385
+ */
386
+ function actualizarTelemetria(prev, itemsActuales, hoyISO) {
387
+ const vistos = { ...(prev && prev.vistos) };
388
+ const categorias = {};
389
+ for (const [k, v] of Object.entries((prev && prev.categorias) || {})) {
390
+ categorias[k] = { ...catVacia(), ...v };
391
+ }
392
+ const ensure = (cat) => {
393
+ if (!categorias[cat]) categorias[cat] = catVacia();
394
+ return categorias[cat];
395
+ };
396
+
397
+ const hashesActuales = new Set((itemsActuales || []).map((it) => it.hash));
398
+
399
+ // 1. Resueltos: lo que estaba visto y ya no aparece → actuado.
400
+ for (const [hash, info] of Object.entries(vistos)) {
401
+ if (!hashesActuales.has(hash)) {
402
+ ensure(info.categoria).actuado += 1;
403
+ ensure(info.categoria).ultima_ts = hoyISO;
404
+ delete vistos[hash];
405
+ }
406
+ }
407
+
408
+ // 2. Presentes ahora: nuevos (mostrado++) o persistentes (veces++ → ignorado).
409
+ for (const it of itemsActuales || []) {
410
+ const c = ensure(it.categoria);
411
+ c.ultima_ts = hoyISO;
412
+ if (!vistos[it.hash]) {
413
+ vistos[it.hash] = { categoria: it.categoria, veces: 1, contadoIgnorado: false };
414
+ c.mostrado += 1;
415
+ } else {
416
+ vistos[it.hash].veces += 1;
417
+ if (vistos[it.hash].veces >= UMBRAL_IGNORADO && !vistos[it.hash].contadoIgnorado) {
418
+ c.ignorado += 1;
419
+ vistos[it.hash].contadoIgnorado = true;
420
+ }
421
+ }
422
+ }
423
+
424
+ // 3. Silenciar categorías con ratio de ignorado alto.
425
+ for (const c of Object.values(categorias)) {
426
+ c.silenciada =
427
+ c.mostrado >= MIN_MUESTRAS_SILENCIO && c.ignorado / c.mostrado >= RATIO_SILENCIO;
428
+ }
429
+
430
+ return { vistos, categorias };
431
+ }
432
+
433
+ /** Lee la telemetría persistida en `.planning/user-profile/briefing-telemetria.json`. */
434
+ function leerTelemetria(baseDir) {
435
+ const txt = leerTexto(path.join(baseDir, '.planning', 'user-profile', 'briefing-telemetria.json'));
436
+ if (!txt) return { vistos: {}, categorias: {} };
437
+ try {
438
+ const o = JSON.parse(txt);
439
+ return { vistos: o.vistos || {}, categorias: o.categorias || {} };
440
+ } catch {
441
+ return { vistos: {}, categorias: {} };
442
+ }
443
+ }
444
+
445
+ /** Conjunto de categorías actualmente silenciadas (para que armarDigest las degrade). */
446
+ function categoriasSilenciadas(telemetria) {
447
+ const set = new Set();
448
+ for (const [cat, v] of Object.entries((telemetria && telemetria.categorias) || {})) {
449
+ if (v && v.silenciada) set.add(cat);
450
+ }
451
+ return set;
452
+ }
453
+
454
+ module.exports = {
455
+ adrsVencidos,
456
+ deudaTriggerCumplido,
457
+ nudgesPendientes,
458
+ gatesEnCalibracion,
459
+ continuePendiente,
460
+ doraDegradado,
461
+ recolectarTodo,
462
+ armarDigest,
463
+ leerEstadoBriefing,
464
+ actualizarTelemetria,
465
+ leerTelemetria,
466
+ categoriasSilenciadas,
467
+ PRIORIDAD,
468
+ TOPE_DIGEST,
469
+ VENTANA_CALIBRACION_MS,
470
+ UMBRAL_IGNORADO,
471
+ RATIO_SILENCIO,
472
+ MIN_MUESTRAS_SILENCIO,
473
+ _hashItem: hashItem,
474
+ };