openprompt-lang 1.2.7 → 1.3.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.
@@ -0,0 +1,845 @@
1
+ // @kind(module)
2
+ // @contract(in: action:string, data:object -> out: object @error: EpicManagerError)
3
+ // @limit(lines: 820)
4
+ // @pattern(provider)
5
+ // @deps(@external: [path, fs, ../../core/workflow/selector])
6
+
7
+ /**
8
+ * Gestor de épicas, tickets agrupados y sprints.
9
+ *
10
+ * Proporciona una capa de organización ágil sobre el sistema de tickets existente.
11
+ * Almacena épicas y sprints como archivos en .opencode/epics/ y .opencode/sprints/.
12
+ *
13
+ * Funcionalidades:
14
+ * - Crear épicas desde descripciones de features
15
+ * - Auto-generar tickets dentro de épicas (análisis de la descripción)
16
+ * - Organizar tickets en sprints
17
+ * - Trackear progreso de épicas y sprints
18
+ * - Ajustar planes existentes cuando llega nuevo trabajo
19
+ *
20
+ * Uso:
21
+ * import { createEpic, listEpics, createSprint, planSprint } from './epic-manager.js';
22
+ * const epic = createEpic("Sistema de reportes", "Implementar módulo de reportes con gráficos y exportación");
23
+ * console.log(epic.tickets); // [{title, status}, ...]
24
+ */
25
+
26
+ import { readFileSync, existsSync, writeFileSync, readdirSync, mkdirSync } from "fs"
27
+ import { join } from "path"
28
+
29
+ const EPICS_DIR = join(process.cwd(), ".opencode", "epics")
30
+ const SPRINTS_DIR = join(process.cwd(), ".opencode", "sprints")
31
+
32
+ /**
33
+ * @typedef {object} Epic
34
+ * @property {string} id - ID único de la épica (EPIC-001)
35
+ * @property {string} title - Título corto
36
+ * @property {string} description - Descripción completa
37
+ * @property {string} status - Estado: planning | active | done | cancelled
38
+ * @property {string} created - Fecha ISO
39
+ * @property {string} [started] - Fecha ISO de inicio
40
+ * @property {string} [completed] - Fecha ISO de completado
41
+ * @property {Array<{id:string, title:string, status:string, sprintId?:string}>} tickets - Tickets asociados
42
+ * @property {string[]} [sprints] - IDs de sprints que cubren esta épica
43
+ * @property {string} [domain] - Dominio asociado
44
+ * @property {object} [metrics] - Métricas: totalTickets, doneTickets, estimatedDays
45
+ */
46
+
47
+ /**
48
+ * @typedef {object} Sprint
49
+ * @property {string} id - ID del sprint (SPRINT-001)
50
+ * @property {string} name - Nombre del sprint (ej: "Sprint 1: Core")
51
+ * @property {string} goal - Objetivo del sprint
52
+ * @property {string} status - Estado: planning | active | completed
53
+ * @property {string} startDate - Fecha ISO inicio
54
+ * @property {string} endDate - Fecha ISO fin
55
+ * @property {string[]} ticketIds - IDs de tickets asignados
56
+ * @property {string[]} epicIds - IDs de épicas cubiertas
57
+ * @property {object} [metrics] - Métricas: totalTickets, doneTickets, velocity
58
+ */
59
+
60
+ // ─── Error ──────────────────────────────────────────────────────────────────────
61
+
62
+ export class EpicManagerError extends Error {
63
+ /**
64
+ * @param {string} message
65
+ * @param {object} [details]
66
+ */
67
+ constructor(message, details = {}) {
68
+ super(message)
69
+ this.name = "EpicManagerError"
70
+ this.details = details
71
+ }
72
+ }
73
+
74
+ // ─── Utilidades ──────────────────────────────────────────────────────────────────
75
+
76
+ function ensureDirs() {
77
+ mkdirSync(EPICS_DIR, { recursive: true })
78
+ mkdirSync(SPRINTS_DIR, { recursive: true })
79
+ }
80
+
81
+ function now() {
82
+ return new Date().toISOString()
83
+ }
84
+
85
+ function shortDate() {
86
+ return new Date().toISOString().split("T")[0]
87
+ }
88
+
89
+ function slug(text) {
90
+ return text
91
+ .toLowerCase()
92
+ .replace(/[^a-z0-9áéíóúüñ\s-]/g, "")
93
+ .replace(/\s+/g, "-")
94
+ .replace(/-+/g, "-")
95
+ .slice(0, 50)
96
+ .replace(/^-|-$/g, "")
97
+ }
98
+
99
+ function generateId(prefix, existing) {
100
+ const nums = existing
101
+ .map((e) => e.id)
102
+ .filter((id) => id && id.startsWith(prefix))
103
+ .map((id) => parseInt(id.replace(`${prefix}-`, ""), 10))
104
+ .filter((n) => !isNaN(n))
105
+ const max = nums.length > 0 ? Math.max(...nums) : 0
106
+ return `${prefix}-${String(max + 1).padStart(3, "0")}`
107
+ }
108
+
109
+ // ─── Persistencia de Épicas ──────────────────────────────────────────────────────
110
+
111
+ /**
112
+ * Lee todas las épicas desde el directorio de archivos.
113
+ *
114
+ * @returns {Epic[]}
115
+ */
116
+ function readAllEpics() {
117
+ if (!existsSync(EPICS_DIR)) return []
118
+ const files = readdirSync(EPICS_DIR).filter((f) => f.endsWith(".json"))
119
+ const epics = []
120
+ for (const f of files) {
121
+ try {
122
+ const epic = JSON.parse(readFileSync(join(EPICS_DIR, f), "utf-8"))
123
+ epics.push(epic)
124
+ } catch {
125
+ // Saltar archivos corruptos
126
+ }
127
+ }
128
+ return epics
129
+ }
130
+
131
+ /**
132
+ * Guarda una épica en disco.
133
+ *
134
+ * @param {Epic} epic
135
+ */
136
+ function writeEpic(epic) {
137
+ ensureDirs()
138
+ const filename = `${epic.id}-${slug(epic.title)}.json`
139
+ writeFileSync(join(EPICS_DIR, filename), JSON.stringify(epic, null, 2), "utf-8")
140
+ }
141
+
142
+ /**
143
+ * Lee una épica por ID.
144
+ *
145
+ * @param {string} id
146
+ * @returns {Epic|null}
147
+ */
148
+ function readEpic(id) {
149
+ if (!existsSync(EPICS_DIR)) return null
150
+ const files = readdirSync(EPICS_DIR).filter((f) => f.startsWith(id))
151
+ for (const f of files) {
152
+ try {
153
+ return JSON.parse(readFileSync(join(EPICS_DIR, f), "utf-8"))
154
+ } catch {
155
+ return null
156
+ }
157
+ }
158
+ return null
159
+ }
160
+
161
+ // ─── Persistencia de Sprints ─────────────────────────────────────────────────────
162
+
163
+ /**
164
+ * Lee todos los sprints desde el directorio.
165
+ *
166
+ * @returns {Sprint[]}
167
+ */
168
+ function readAllSprints() {
169
+ if (!existsSync(SPRINTS_DIR)) return []
170
+ const files = readdirSync(SPRINTS_DIR).filter((f) => f.endsWith(".json"))
171
+ const sprints = []
172
+ for (const f of files) {
173
+ try {
174
+ const sprint = JSON.parse(readFileSync(join(SPRINTS_DIR, f), "utf-8"))
175
+ sprints.push(sprint)
176
+ } catch {
177
+ // Saltar archivos corruptos
178
+ }
179
+ }
180
+ return sprints
181
+ }
182
+
183
+ /**
184
+ * Guarda un sprint en disco.
185
+ *
186
+ * @param {Sprint} sprint
187
+ */
188
+ function writeSprint(sprint) {
189
+ ensureDirs()
190
+ const filename = `${sprint.id}-${slug(sprint.name)}.json`
191
+ writeFileSync(join(SPRINTS_DIR, filename), JSON.stringify(sprint, null, 2), "utf-8")
192
+ }
193
+
194
+ // ─── Tickets helper ──────────────────────────────────────────────────────────────
195
+
196
+ /**
197
+ * Crea un ticket de bug usando el sistema existente de archivos.
198
+ * Devuelve el ID del ticket creado o null si falla.
199
+ *
200
+ * @param {string} title
201
+ * @param {string} description
202
+ * @param {string} domain
203
+ * @returns {string|null}
204
+ */
205
+ function createBugTicket(title, description, domain = "shared") {
206
+ try {
207
+ const bugsDir = join(process.cwd(), ".opencode", "bugs")
208
+ mkdirSync(bugsDir, { recursive: true })
209
+
210
+ // Leer tickets existentes para generar ID
211
+ let existing = []
212
+ if (existsSync(bugsDir)) {
213
+ const files = readdirSync(bugsDir).filter((f) => f.endsWith(".md"))
214
+ existing = files.map((f) => {
215
+ // Extract full ID (BUG-NNN) from filename like "BUG-001-sistema-reportes.md"
216
+ const match = f.match(/^(BUG-\d+)/)
217
+ return { id: match ? match[1] : f.split("-")[0] }
218
+ })
219
+ }
220
+
221
+ const id = generateId("BUG", existing)
222
+ const nowDate = shortDate()
223
+ const ticketSlug = slug(title)
224
+ const filename = `${id}-${ticketSlug}.md`
225
+
226
+ const content = `# ${id} — ${title}
227
+
228
+ | Campo | Valor |
229
+ |-------|-------|
230
+ | **ID** | ${id} |
231
+ | **Título** | ${title} |
232
+ | **Severidad** | medium |
233
+ | **Estado** | open |
234
+ | **Proyecto** | ${loadProjectName() || "unknown"} |
235
+ | **Dominio** | ${domain} |
236
+ | **Fecha** | ${nowDate} |
237
+ | **Origen** | epic-auto |
238
+
239
+ ## Descripción
240
+
241
+ ${description}
242
+
243
+ ## Tareas
244
+
245
+ - [ ] Implementar funcionalidad
246
+ - [ ] Escribir tests
247
+ - [ ] Actualizar documentación
248
+
249
+ ## Notas
250
+
251
+ _Creado automáticamente desde épica_
252
+ `
253
+
254
+ writeFileSync(join(bugsDir, filename), content, "utf-8")
255
+ return id
256
+ } catch (err) {
257
+ console.error(`[epic-manager] Error creando ticket: ${err.message}`)
258
+ return null
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Carga el nombre del proyecto desde prompt-lang.json.
264
+ *
265
+ * @returns {string|null}
266
+ */
267
+ function loadProjectName() {
268
+ try {
269
+ const configPath = join(process.cwd(), "prompt-lang.json")
270
+ if (existsSync(configPath)) {
271
+ const config = JSON.parse(readFileSync(configPath, "utf-8"))
272
+ return config.name || null
273
+ }
274
+ } catch {
275
+ // Ignorar
276
+ }
277
+ return null
278
+ }
279
+
280
+ // ─── Análisis de descripción para generar tickets ────────────────────────────────
281
+
282
+ /**
283
+ * Analiza una descripción de épica y extrae tickets sugeridos.
284
+ * Busca patrones como listas, verbos de acción, subtítulos.
285
+ *
286
+ * @param {string} description
287
+ * @returns {Array<{title:string, description:string}>}
288
+ */
289
+ function extractTicketsFromDescription(description) {
290
+ const tickets = []
291
+ const lines = description.split("\n")
292
+
293
+ let currentSection = ""
294
+ for (const line of lines) {
295
+ const trimmed = line.trim()
296
+
297
+ // Detectar subtítulos como secciones de tickets
298
+ const subtitleMatch = trimmed.match(/^#{1,3}\s+(.+)/)
299
+ if (subtitleMatch) {
300
+ currentSection = subtitleMatch[1]
301
+ tickets.push({
302
+ title: currentSection,
303
+ description: `Implementar: ${currentSection}`,
304
+ })
305
+ continue
306
+ }
307
+
308
+ // Detectar ítems de lista como tickets
309
+ const listItemMatch = trimmed.match(/^[-*+]\s+(.+)/)
310
+ if (listItemMatch) {
311
+ const item = listItemMatch[1]
312
+ // Saltar items de metadatos o sin contenido real
313
+ if (item.length > 10 && !item.startsWith("[") && !item.startsWith("**")) {
314
+ const fullTitle = currentSection ? `${currentSection}: ${item}` : item
315
+ tickets.push({
316
+ title: fullTitle.length > 80 ? `${fullTitle.slice(0, 77)}...` : fullTitle,
317
+ description: item,
318
+ })
319
+ }
320
+ continue
321
+ }
322
+
323
+ // Detectar verbos de acción al inicio de oraciones
324
+ const actionVerbs = [
325
+ "implementar",
326
+ "crear",
327
+ "añadir",
328
+ "agregar",
329
+ "configurar",
330
+ "migrar",
331
+ "refactorizar",
332
+ "extraer",
333
+ "unificar",
334
+ "optimizar",
335
+ "documentar",
336
+ ]
337
+ const actionMatch = trimmed.match(new RegExp(`^(${actionVerbs.join("|")})\\s+`, "i"))
338
+ if (actionMatch && trimmed.length > 10 && trimmed.length < 120) {
339
+ tickets.push({
340
+ title: trimmed,
341
+ description: trimmed,
342
+ })
343
+ }
344
+ }
345
+
346
+ return tickets
347
+ }
348
+
349
+ // ─── API Pública ─────────────────────────────────────────────────────────────────
350
+
351
+ /**
352
+ * Crea una nueva épica a partir de una descripción de feature o tarea grande.
353
+ * Opcionalmente genera tickets automáticamente analizando la descripción.
354
+ *
355
+ * @param {string} title - Título corto de la épica
356
+ * @param {string} description - Descripción detallada
357
+ * @param {object} [options]
358
+ * @param {boolean} [options.autoGenerateTickets=true] - Generar tickets automáticamente
359
+ * @param {string} [options.domain] - Dominio asociado
360
+ * @returns {Epic}
361
+ */
362
+ export function createEpic(title, description, options = {}) {
363
+ const { autoGenerateTickets = true, domain } = options
364
+
365
+ if (!title || !title.trim()) {
366
+ throw new EpicManagerError("El título de la épica es requerido")
367
+ }
368
+
369
+ ensureDirs()
370
+ const existing = readAllEpics()
371
+ const epicId = generateId("EPIC", existing)
372
+
373
+ // Generar tickets si se solicita
374
+ const tickets = []
375
+ if (autoGenerateTickets) {
376
+ const extracted = extractTicketsFromDescription(description)
377
+ // Crear tickets reales en el sistema de bugs
378
+ for (const t of extracted) {
379
+ const ticketId = createBugTicket(t.title, t.description, domain || "shared")
380
+ if (ticketId) {
381
+ tickets.push({
382
+ id: ticketId,
383
+ title: t.title,
384
+ status: "open",
385
+ })
386
+ }
387
+ }
388
+ }
389
+
390
+ const epic = {
391
+ id: epicId,
392
+ title: title.trim(),
393
+ description: description.trim(),
394
+ status: "planning",
395
+ created: now(),
396
+ tickets,
397
+ sprints: [],
398
+ domain: domain || "shared",
399
+ metrics: {
400
+ totalTickets: tickets.length,
401
+ doneTickets: 0,
402
+ estimatedDays: estimateEpicDays(description, tickets.length),
403
+ },
404
+ }
405
+
406
+ writeEpic(epic)
407
+ return epic
408
+ }
409
+
410
+ /**
411
+ * Estima días de desarrollo para una épica.
412
+ * Heurística simple basada en número de tickets y complejidad textual.
413
+ *
414
+ * @param {string} description
415
+ * @param {number} ticketCount
416
+ * @returns {number}
417
+ */
418
+ function estimateEpicDays(description, ticketCount) {
419
+ const complexityWords = [
420
+ "complej",
421
+ "difícil",
422
+ "ardu",
423
+ "grande",
424
+ "migración",
425
+ "rearquitectura",
426
+ "múltiple",
427
+ "varios",
428
+ "integracion",
429
+ "externa",
430
+ ]
431
+ const lower = description.toLowerCase()
432
+ const hasComplexity = complexityWords.some((w) => lower.includes(w))
433
+
434
+ const baseDays = Math.max(ticketCount * 1.5, 2)
435
+ return hasComplexity ? Math.ceil(baseDays * 1.5) : Math.ceil(baseDays)
436
+ }
437
+
438
+ /**
439
+ * Lista todas las épicas con sus métricas.
440
+ *
441
+ * @param {object} [options]
442
+ * @param {string} [options.status] - Filtrar por estado
443
+ * @param {string} [options.domain] - Filtrar por dominio
444
+ * @returns {Epic[]}
445
+ */
446
+ export function listEpics(options = {}) {
447
+ let epics = readAllEpics()
448
+
449
+ if (options.status) {
450
+ epics = epics.filter((e) => e.status === options.status)
451
+ }
452
+
453
+ if (options.domain) {
454
+ epics = epics.filter((e) => e.domain === options.domain)
455
+ }
456
+
457
+ // Calcular métricas en vivo
458
+ for (const epic of epics) {
459
+ if (epic.metrics) {
460
+ const done = (epic.tickets || []).filter(
461
+ (t) => t.status === "done" || t.status === "fixed"
462
+ ).length
463
+ epic.metrics.doneTickets = done
464
+ }
465
+ }
466
+
467
+ // Ordenar por fecha de creación descendente
468
+ return epics.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
469
+ }
470
+
471
+ /**
472
+ * Actualiza el estado de una épica.
473
+ *
474
+ * @param {string} epicId
475
+ * @param {object} updates
476
+ * @param {string} [updates.status] - Nuevo estado
477
+ * @param {string} [updates.title] - Nuevo título
478
+ * @param {string} [updates.description] - Nueva descripción
479
+ * @returns {Epic}
480
+ */
481
+ export function updateEpic(epicId, updates) {
482
+ const all = readAllEpics()
483
+ const epic = all.find((e) => e.id === epicId)
484
+ if (!epic) {
485
+ throw new EpicManagerError(`Épica no encontrada: ${epicId}`)
486
+ }
487
+
488
+ if (updates.status) {
489
+ epic.status = updates.status
490
+ if (updates.status === "active" && !epic.started) {
491
+ epic.started = now()
492
+ }
493
+ if (updates.status === "done" && !epic.completed) {
494
+ epic.completed = now()
495
+ }
496
+ }
497
+
498
+ if (updates.title) epic.title = updates.title
499
+ if (updates.description) epic.description = updates.description
500
+ if (updates.domain) epic.domain = updates.domain
501
+
502
+ writeEpic(epic)
503
+ return epic
504
+ }
505
+
506
+ /**
507
+ * Agrega tickets existentes a una épica.
508
+ *
509
+ * @param {string} epicId
510
+ * @param {string[]} ticketIds - IDs de tickets a agregar
511
+ * @returns {Epic}
512
+ */
513
+ export function addTicketsToEpic(epicId, ticketIds) {
514
+ const epic = readEpic(epicId)
515
+ if (!epic) {
516
+ throw new EpicManagerError(`Épica no encontrada: ${epicId}`)
517
+ }
518
+
519
+ const existingIds = new Set((epic.tickets || []).map((t) => t.id))
520
+ for (const ticketId of ticketIds) {
521
+ if (!existingIds.has(ticketId)) {
522
+ epic.tickets.push({
523
+ id: ticketId,
524
+ title: ticketId,
525
+ status: "open",
526
+ })
527
+ existingIds.add(ticketId)
528
+ }
529
+ }
530
+
531
+ if (epic.metrics) {
532
+ epic.metrics.totalTickets = epic.tickets.length
533
+ }
534
+
535
+ writeEpic(epic)
536
+ return epic
537
+ }
538
+
539
+ // ─── Sprints ─────────────────────────────────────────────────────────────────────
540
+
541
+ /**
542
+ * Crea un nuevo sprint.
543
+ *
544
+ * @param {string} name - Nombre del sprint (ej: "Sprint 1: Core")
545
+ * @param {string} goal - Objetivo del sprint
546
+ * @param {object} [options]
547
+ * @param {string} [options.startDate] - Fecha inicio (default: today)
548
+ * @param {number} [options.durationDays=14] - Duración en días
549
+ * @param {string[]} [options.ticketIds] - Tickets a incluir
550
+ * @param {string[]} [options.epicIds] - Épicas a cubrir
551
+ * @returns {Sprint}
552
+ */
553
+ export function createSprint(name, goal, options = {}) {
554
+ const { ticketIds = [], epicIds = [], durationDays = 14 } = options
555
+
556
+ if (!name || !name.trim()) {
557
+ throw new EpicManagerError("El nombre del sprint es requerido")
558
+ }
559
+
560
+ ensureDirs()
561
+ const existing = readAllSprints()
562
+ const sprintId = generateId("SPRINT", existing)
563
+
564
+ const startDate = options.startDate || shortDate()
565
+ const endDate = new Date(Date.now() + durationDays * 86400000).toISOString().split("T")[0]
566
+
567
+ const sprint = {
568
+ id: sprintId,
569
+ name: name.trim(),
570
+ goal: goal.trim(),
571
+ status: "planning",
572
+ startDate,
573
+ endDate,
574
+ ticketIds,
575
+ epicIds,
576
+ metrics: {
577
+ totalTickets: ticketIds.length,
578
+ doneTickets: 0,
579
+ velocity: 0,
580
+ },
581
+ }
582
+
583
+ writeSprint(sprint)
584
+
585
+ // Vincular sprints a épicas
586
+ for (const epicId of epicIds) {
587
+ const epic = readEpic(epicId)
588
+ if (epic) {
589
+ if (!epic.sprints) epic.sprints = []
590
+ if (!epic.sprints.includes(sprintId)) {
591
+ epic.sprints.push(sprintId)
592
+ writeEpic(epic)
593
+ }
594
+ }
595
+ }
596
+
597
+ return sprint
598
+ }
599
+
600
+ /**
601
+ * Planifica un sprint automáticamente basado en capacidad y prioridades.
602
+ * Selecciona tickets de épicas activas que quepan en la capacidad estimada.
603
+ *
604
+ * @param {string} sprintId - ID del sprint a planificar
605
+ * @param {object} [options]
606
+ * @param {number} [options.capacity=10] - Capacidad estimada en tickets
607
+ * @param {string[]} [options.priorityEpics] - Épicas prioritarias
608
+ * @returns {Sprint}
609
+ */
610
+ export function planSprint(sprintId, options = {}) {
611
+ const { capacity = 10, priorityEpics = [] } = options
612
+ const allSprints = readAllSprints()
613
+ const sprint = allSprints.find((s) => s.id === sprintId)
614
+
615
+ if (!sprint) {
616
+ throw new EpicManagerError(`Sprint no encontrado: ${sprintId}`)
617
+ }
618
+
619
+ // Obtener tickets de épicas activas
620
+ const allEpics = readAllEpics()
621
+ const activeEpics = allEpics.filter((e) => e.status === "active")
622
+
623
+ // Priorizar épicas
624
+ const sortedEpics = [...activeEpics].sort((a, b) => {
625
+ const aPriority = priorityEpics.includes(a.id) ? 0 : 1
626
+ const bPriority = priorityEpics.includes(b.id) ? 0 : 1
627
+ if (aPriority !== bPriority) return aPriority - bPriority
628
+ return (a.metrics?.estimatedDays || 5) - (b.metrics?.estimatedDays || 5)
629
+ })
630
+
631
+ // Seleccionar tickets hasta llenar capacidad
632
+ const selectedTickets = []
633
+ const selectedEpics = []
634
+ let count = 0
635
+
636
+ for (const epic of sortedEpics) {
637
+ if (count >= capacity) break
638
+ const openTickets = (epic.tickets || []).filter(
639
+ (t) => t.status === "open" || t.status === "backlog"
640
+ )
641
+ for (const ticket of openTickets) {
642
+ if (count >= capacity) break
643
+ selectedTickets.push(ticket.id)
644
+ count++
645
+ }
646
+ if (openTickets.length > 0) {
647
+ selectedEpics.push(epic.id)
648
+ }
649
+ }
650
+
651
+ // Actualizar sprint
652
+ sprint.ticketIds = selectedTickets
653
+ sprint.epicIds = [...new Set([...sprint.epicIds, ...selectedEpics])]
654
+ sprint.metrics.totalTickets = selectedTickets.length
655
+
656
+ writeSprint(sprint)
657
+ return sprint
658
+ }
659
+
660
+ /**
661
+ * Lista todos los sprints.
662
+ *
663
+ * @param {object} [options]
664
+ * @param {string} [options.status] - Filtrar por estado
665
+ * @returns {Sprint[]}
666
+ */
667
+ export function listSprints(options = {}) {
668
+ let sprints = readAllSprints()
669
+
670
+ if (options.status) {
671
+ sprints = sprints.filter((s) => s.status === options.status)
672
+ }
673
+
674
+ // Calcular métricas en vivo
675
+ for (const sprint of sprints) {
676
+ if (sprint.metrics) {
677
+ const bugsDir = join(process.cwd(), ".opencode", "bugs")
678
+ if (existsSync(bugsDir)) {
679
+ let doneCount = 0
680
+ for (const ticketId of sprint.ticketIds || []) {
681
+ const files = readdirSync(bugsDir).filter((f) => f.startsWith(ticketId))
682
+ for (const f of files) {
683
+ try {
684
+ const content = readFileSync(join(bugsDir, f), "utf-8")
685
+ const statusMatch = content.match(/\*\*Estado\*\*\s+\|\s+(\w+)/)
686
+ if (statusMatch && ["done", "fixed"].includes(statusMatch[1])) {
687
+ doneCount++
688
+ }
689
+ } catch {
690
+ // Ignorar
691
+ }
692
+ }
693
+ }
694
+ sprint.metrics.doneTickets = doneCount
695
+ }
696
+ }
697
+ }
698
+
699
+ return sprints.sort((a, b) => new Date(b.startDate) - new Date(a.startDate))
700
+ }
701
+
702
+ /**
703
+ * Actualiza el estado de un sprint.
704
+ *
705
+ * @param {string} sprintId
706
+ * @param {object} updates
707
+ * @param {string} [updates.status]
708
+ * @param {string} [updates.goal]
709
+ * @param {string[]} [updates.ticketIds]
710
+ * @returns {Sprint}
711
+ */
712
+ export function updateSprint(sprintId, updates) {
713
+ const all = readAllSprints()
714
+ const sprint = all.find((s) => s.id === sprintId)
715
+ if (!sprint) {
716
+ throw new EpicManagerError(`Sprint no encontrado: ${sprintId}`)
717
+ }
718
+
719
+ if (updates.status) sprint.status = updates.status
720
+ if (updates.goal) sprint.goal = updates.goal
721
+ if (updates.ticketIds) {
722
+ sprint.ticketIds = updates.ticketIds
723
+ if (sprint.metrics) sprint.metrics.totalTickets = updates.ticketIds.length
724
+ }
725
+
726
+ writeSprint(sprint)
727
+ return sprint
728
+ }
729
+
730
+ // ─── Plan Adjustment ─────────────────────────────────────────────────────────────
731
+
732
+ /**
733
+ * Ajusta planes existentes cuando llega nuevo trabajo que se solapa.
734
+ * Detecta épicas activas con keywords similares y sugiere merge o priorización.
735
+ *
736
+ * @param {string} newTitle - Título de la nueva tarea/épica
737
+ * @param {string} newDescription - Descripción
738
+ * @returns {Array<{epicId:string, title:string, overlap:number, suggestion:string}>}
739
+ */
740
+ export function detectPlanAdjustments(newTitle, newDescription) {
741
+ const epics = readAllEpics()
742
+ const activeEpics = epics.filter((e) => e.status !== "done" && e.status !== "cancelled")
743
+ const combinedText = `${newTitle} ${newDescription}`.toLowerCase()
744
+ const words = combinedText.split(/\s+/).filter((w) => w.length > 4)
745
+
746
+ if (words.length === 0) return []
747
+
748
+ const adjustments = []
749
+
750
+ for (const epic of activeEpics) {
751
+ const epicText = `${epic.title} ${epic.description}`.toLowerCase()
752
+ const overlapCount = words.filter((w) => epicText.includes(w)).length
753
+ const overlapRatio = overlapCount / words.length
754
+
755
+ if (overlapRatio > 0.3) {
756
+ let suggestion
757
+ if (overlapRatio > 0.6) {
758
+ suggestion =
759
+ "MERGE: La nueva tarea parece cubrir la misma épica existente. Considere fusionar."
760
+ } else if (overlapRatio > 0.4) {
761
+ suggestion =
762
+ "ALIGN: Superposición significativa. Revisar si la nueva tarea es un subconjunto o extensión."
763
+ } else {
764
+ suggestion = "NOTE: Superposición leve. Monitorear para evitar duplicación de esfuerzo."
765
+ }
766
+
767
+ adjustments.push({
768
+ epicId: epic.id,
769
+ title: epic.title,
770
+ overlapCount,
771
+ overlapRatio: Math.round(overlapRatio * 100),
772
+ suggestion,
773
+ })
774
+ }
775
+ }
776
+
777
+ return adjustments.sort((a, b) => b.overlapRatio - a.overlapRatio)
778
+ }
779
+
780
+ /**
781
+ * Genera un reporte de estado del proyecto basado en épicas y sprints.
782
+ *
783
+ * @returns {string} Reporte formateado
784
+ */
785
+ export function generateStatusReport() {
786
+ const epics = readAllEpics()
787
+ const sprints = readAllSprints()
788
+
789
+ const activeEpics = epics.filter((e) => e.status === "active")
790
+ const doneEpics = epics.filter((e) => e.status === "done")
791
+ const totalTickets = epics.reduce((sum, e) => sum + (e.tickets?.length || 0), 0)
792
+ const doneTickets = epics.reduce(
793
+ (sum, e) =>
794
+ sum + (e.tickets?.filter((t) => t.status === "done" || t.status === "fixed").length || 0),
795
+ 0
796
+ )
797
+ const plannedDays = epics.reduce((sum, e) => sum + (e.metrics?.estimatedDays || 0), 0)
798
+
799
+ const lines = []
800
+ lines.push("═══════════════════════════════════════")
801
+ lines.push(" REPORTE DE ESTADO DEL PROYECTO")
802
+ lines.push("═══════════════════════════════════════\n")
803
+
804
+ lines.push(
805
+ ` Épicas: ${epics.length} totales (${activeEpics.length} activas, ${doneEpics.length} completadas)`
806
+ )
807
+ lines.push(
808
+ ` Tickets: ${doneTickets}/${totalTickets} completados (${totalTickets > 0 ? Math.round((doneTickets / totalTickets) * 100) : 0}%)`
809
+ )
810
+ lines.push(` Días estimados: ${plannedDays}`)
811
+ lines.push(` Sprints: ${sprints.length} totales`)
812
+ lines.push("")
813
+
814
+ if (activeEpics.length > 0) {
815
+ lines.push(" Épicas Activas:")
816
+ for (const epic of activeEpics) {
817
+ const donePct =
818
+ epic.metrics?.totalTickets > 0
819
+ ? Math.round(((epic.metrics.doneTickets || 0) / epic.metrics.totalTickets) * 100)
820
+ : 0
821
+ lines.push(` ${epic.id}: ${epic.title} (${donePct}%)`)
822
+ }
823
+ lines.push("")
824
+ }
825
+
826
+ const activeSprints = sprints.filter((s) => s.status === "active")
827
+ if (activeSprints.length > 0) {
828
+ lines.push(" Sprints Activos:")
829
+ for (const sprint of activeSprints) {
830
+ const donePct =
831
+ sprint.metrics?.totalTickets > 0
832
+ ? Math.round(((sprint.metrics.doneTickets || 0) / sprint.metrics.totalTickets) * 100)
833
+ : 0
834
+ lines.push(` ${sprint.id}: ${sprint.name} (${donePct}%)`)
835
+ lines.push(` ${sprint.startDate} → ${sprint.endDate} | ${sprint.goal}`)
836
+ }
837
+ lines.push("")
838
+ }
839
+
840
+ lines.push(" Para crear una nueva épica:")
841
+ lines.push(' npx openPrompt-Lang epic create --title "..." --desc "..."')
842
+ lines.push("")
843
+
844
+ return lines.join("\n")
845
+ }