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.
- package/README.md +62 -8
- package/docs/EMBEDDINGS.md +214 -0
- package/docs/ONBOARDING_WORKFLOW.md +151 -0
- package/docs/OPL_ACADEMIC_ISSUES.md +158 -0
- package/docs/WEB_SCRAPER_PLAN.md +454 -0
- package/package.json +7 -1
- package/scripts/postinstall.js +37 -0
- package/src/cli/commands-knowledge.js +1 -0
- package/src/cli/commands-opl.js +79 -1
- package/src/cli/commands-workflow.js +125 -6
- package/src/commands/init-core.js +169 -5
- package/src/commands/knowledge-ops.js +52 -0
- package/src/commands/opl-embeddings.js +556 -0
- package/src/commands/opl-help.js +26 -2
- package/src/commands/opl-search.js +106 -2
- package/src/commands/opl-webscrape.js +390 -0
- package/src/commands/workflow/epic-cli.js +192 -0
- package/src/commands/workflow/select.js +146 -0
- package/src/commands/workflow/sprint-cli.js +174 -0
- package/src/core/webscrape/analyzer.js +481 -0
- package/src/core/webscrape/deep-scraper.js +1027 -0
- package/src/core/workflow/epic-manager.js +845 -0
- package/src/core/workflow/gates.js +180 -1
- package/src/core/workflow/selector.js +707 -0
- package/src/embeddings/chunker.js +450 -0
- package/src/embeddings/embedder.js +431 -0
- package/src/embeddings/index-pipeline.js +320 -0
- package/src/embeddings/vector-store.js +505 -0
|
@@ -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
|
+
}
|