trackfw 1.1.0 → 2.1.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,235 @@
1
+ 'use strict'
2
+
3
+ const { Command } = require('commander')
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+ const { t } = require('../i18n')
7
+
8
+ // lineRe: faz match do formato do .trackfw-log
9
+ // 2026-06-12 14:30 ROADMAP-2026-06-12-auth.md backlog → wip
10
+ const LINE_RE = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\s{2,}(\S+)\s{2,}(\S+)\s+→\s+(\S+)/
11
+
12
+ /**
13
+ * parseLog lê o arquivo .trackfw-log e retorna array de transições.
14
+ * Retorna [] se o arquivo não existe.
15
+ * @param {string} filePath
16
+ * @returns {{ timestamp: Date, basename: string, from: string, to: string }[]}
17
+ */
18
+ function parseLog(filePath) {
19
+ if (!fs.existsSync(filePath)) return []
20
+ const content = fs.readFileSync(filePath, 'utf8')
21
+ const lines = content.split('\n')
22
+ const transitions = []
23
+ for (const line of lines) {
24
+ if (!line.trim()) continue
25
+ const m = LINE_RE.exec(line)
26
+ if (!m) continue
27
+ const [, tsStr, basename, from, to] = m
28
+ const timestamp = new Date(tsStr.replace(' ', 'T') + ':00')
29
+ if (isNaN(timestamp.getTime())) continue
30
+ transitions.push({ timestamp, basename: basename.trim(), from: from.trim(), to: to.trim() })
31
+ }
32
+ return transitions
33
+ }
34
+
35
+ /**
36
+ * filter retorna transições com timestamp >= sinceMs.
37
+ * @param {Array} transitions
38
+ * @param {number} sinceMs - timestamp em milissegundos
39
+ * @returns {Array}
40
+ */
41
+ function filter(transitions, sinceMs) {
42
+ return transitions.filter(t => t.timestamp.getTime() >= sinceMs)
43
+ }
44
+
45
+ /**
46
+ * calculate computa cycle time médio, throughput e WIP age.
47
+ * @param {Array} transitions
48
+ * @returns {{ cycleTimeMeanMs: number, throughput: number, wipEntries: Array }}
49
+ */
50
+ function calculate(transitions) {
51
+ // Agrupar por basename
52
+ const byName = new Map()
53
+ for (const tr of transitions) {
54
+ if (!byName.has(tr.basename)) byName.set(tr.basename, [])
55
+ byName.get(tr.basename).push(tr)
56
+ }
57
+
58
+ // Cycle time: da entrada em backlog ou wip até done
59
+ const cycleTimes = []
60
+ for (const [, entries] of byName) {
61
+ let startTs = null
62
+ let doneTs = null
63
+ for (const e of entries) {
64
+ if ((e.to === 'backlog' || e.to === 'wip') && startTs === null) {
65
+ startTs = e.timestamp.getTime()
66
+ }
67
+ if (e.to === 'done') {
68
+ doneTs = e.timestamp.getTime()
69
+ }
70
+ }
71
+ if (startTs !== null && doneTs !== null) {
72
+ cycleTimes.push(doneTs - startTs)
73
+ }
74
+ }
75
+
76
+ let cycleTimeMeanMs = 0
77
+ if (cycleTimes.length > 0) {
78
+ cycleTimeMeanMs = cycleTimes.reduce((a, b) => a + b, 0) / cycleTimes.length
79
+ }
80
+
81
+ // Throughput: roadmaps done por semana
82
+ let doneCount = 0
83
+ let minTs = Infinity
84
+ let maxTs = -Infinity
85
+ for (const tr of transitions) {
86
+ const ms = tr.timestamp.getTime()
87
+ if (tr.to === 'done') doneCount++
88
+ if (ms < minTs) minTs = ms
89
+ if (ms > maxTs) maxTs = ms
90
+ }
91
+
92
+ let throughput = 0
93
+ if (doneCount > 0) {
94
+ const msPerWeek = 7 * 24 * 60 * 60 * 1000
95
+ let weeks = (maxTs - minTs) / msPerWeek
96
+ if (weeks < 1) weeks = 1
97
+ throughput = doneCount / weeks
98
+ }
99
+
100
+ // WIP age: basenames em wip sem done ou abandoned posterior
101
+ const wipEntries = []
102
+ const now = Date.now()
103
+ for (const [basename, entries] of byName) {
104
+ let wipTs = null
105
+ let concluded = false
106
+ for (const e of entries) {
107
+ if (e.to === 'wip') wipTs = e.timestamp.getTime()
108
+ if (e.to === 'done' || e.to === 'abandoned') concluded = true
109
+ }
110
+ if (wipTs !== null && !concluded) {
111
+ wipEntries.push({ basename, ageMs: now - wipTs })
112
+ }
113
+ }
114
+
115
+ return { cycleTimeMeanMs, throughput, wipEntries }
116
+ }
117
+
118
+ /**
119
+ * exportCSV grava transições e métricas em um arquivo CSV.
120
+ * @param {{ cycleTimeMeanMs: number, throughput: number, wipEntries: Array }} metrics
121
+ * @param {Array} transitions
122
+ * @param {string} filePath
123
+ */
124
+ function exportCSV(metrics, transitions, filePath) {
125
+ const rows = []
126
+ rows.push('basename,from,to,timestamp')
127
+ for (const tr of transitions) {
128
+ const ts = tr.timestamp.toISOString().slice(0, 16).replace('T', ' ')
129
+ rows.push(`${tr.basename},${tr.from},${tr.to},${ts}`)
130
+ }
131
+ rows.push('')
132
+ rows.push('metric,value')
133
+ const cycleHours = (metrics.cycleTimeMeanMs / (1000 * 3600)).toFixed(2)
134
+ rows.push(`cycle_time_mean_hours,${cycleHours}`)
135
+ rows.push(`throughput_per_week,${metrics.throughput.toFixed(2)}`)
136
+ rows.push(`wip_count,${metrics.wipEntries.length}`)
137
+ fs.writeFileSync(filePath, rows.join('\n') + '\n', 'utf8')
138
+ }
139
+
140
+ /**
141
+ * parseSinceDuration converte "7d", "30d", "90d" em milissegundos.
142
+ * @param {string} s
143
+ * @returns {number}
144
+ */
145
+ function parseSinceDuration(s) {
146
+ if (!s || s.length < 2) throw new Error(`formato inválido: "${s}"`)
147
+ const unit = s[s.length - 1]
148
+ if (unit !== 'd') throw new Error(`unidade não suportada "${unit}" (use 'd' para dias)`)
149
+ const n = parseInt(s.slice(0, -1), 10)
150
+ if (isNaN(n) || n <= 0) throw new Error(`número inválido em "${s}"`)
151
+ return n * 24 * 60 * 60 * 1000
152
+ }
153
+
154
+ /**
155
+ * formatDuration formata milissegundos em string legível.
156
+ * @param {number} ms
157
+ * @returns {string}
158
+ */
159
+ function formatDuration(ms) {
160
+ const totalHours = Math.floor(ms / (1000 * 3600))
161
+ const days = Math.floor(totalHours / 24)
162
+ const hours = totalHours % 24
163
+ if (days > 0) return `${days} days ${hours} hours`
164
+ return `${hours} hours`
165
+ }
166
+
167
+ /**
168
+ * printMetrics imprime as métricas em formato tabela ASCII.
169
+ * @param {{ cycleTimeMeanMs: number, throughput: number, wipEntries: Array }} metrics
170
+ */
171
+ function printMetrics(metrics) {
172
+ console.log('── trackfw metrics ──────────────────────')
173
+
174
+ if (metrics.cycleTimeMeanMs > 0) {
175
+ console.log(` Cycle Time Mean : ${formatDuration(metrics.cycleTimeMeanMs)}`)
176
+ } else {
177
+ console.log(' Cycle Time Mean : n/a (no completed cycles)')
178
+ }
179
+
180
+ if (metrics.throughput > 0) {
181
+ console.log(` Throughput : ${metrics.throughput.toFixed(2)} roadmaps/week`)
182
+ } else {
183
+ console.log(' Throughput : n/a (no completed roadmaps)')
184
+ }
185
+
186
+ if (metrics.wipEntries.length === 0) {
187
+ console.log(' WIP Age : no items in progress')
188
+ } else {
189
+ console.log(` WIP Age (${metrics.wipEntries.length} items) :`)
190
+ for (const w of metrics.wipEntries) {
191
+ console.log(` - ${w.basename}: ${formatDuration(w.ageMs)}`)
192
+ }
193
+ }
194
+
195
+ console.log('─────────────────────────────────────────')
196
+ }
197
+
198
+ const cmd = new Command('metrics')
199
+ cmd.description(t('metrics.description'))
200
+ cmd.option('--since <period>', t('metrics.since'))
201
+ cmd.option('--export <file>', t('metrics.export'))
202
+ cmd.action((opts) => {
203
+ const logPath = path.join('docs', 'roadmaps', '.trackfw-log')
204
+ let transitions = parseLog(logPath)
205
+
206
+ if (transitions.length === 0) {
207
+ console.log(t('metrics.no_data'))
208
+ return
209
+ }
210
+
211
+ if (opts.since) {
212
+ let sinceMs
213
+ try {
214
+ sinceMs = parseSinceDuration(opts.since)
215
+ } catch (err) {
216
+ console.error(`invalid --since format (use: 7d, 30d, 90d): ${err.message}`)
217
+ process.exit(1)
218
+ }
219
+ transitions = filter(transitions, Date.now() - sinceMs)
220
+ if (transitions.length === 0) {
221
+ console.log(t('metrics.no_data'))
222
+ return
223
+ }
224
+ }
225
+
226
+ const m = calculate(transitions)
227
+ printMetrics(m)
228
+
229
+ if (opts.export) {
230
+ exportCSV(m, transitions, opts.export)
231
+ console.log(`exported to ${opts.export}`)
232
+ }
233
+ })
234
+
235
+ module.exports = cmd
@@ -3,8 +3,59 @@ const { Command } = require('commander')
3
3
  const os = require('os')
4
4
  const path = require('path')
5
5
  const fs = require('fs')
6
+ const https = require('https')
6
7
  const { t } = require('../i18n')
7
8
 
9
+ const REGISTRY_URL = 'https://raw.githubusercontent.com/kgsaran/trackfw-plugins/main/registry.yaml'
10
+
11
+ function fetchRegistry() {
12
+ return new Promise((resolve, reject) => {
13
+ https.get(REGISTRY_URL, (res) => {
14
+ let data = ''
15
+ res.on('data', chunk => { data += chunk })
16
+ res.on('end', () => resolve(data))
17
+ }).on('error', reject)
18
+ })
19
+ }
20
+
21
+ function parseRegistryYAML(text) {
22
+ const entries = []
23
+ let current = null
24
+ const lines = text.split('\n')
25
+ for (const line of lines) {
26
+ const trimmed = line.trim()
27
+ if (!trimmed || trimmed === 'plugins:') continue
28
+ if (trimmed.startsWith('- name:')) {
29
+ if (current) entries.push(current)
30
+ current = { name: trimmed.slice('- name:'.length).trim(), repo: '', description: '', tags: [] }
31
+ continue
32
+ }
33
+ if (!current) continue
34
+ if (trimmed.startsWith('repo:')) {
35
+ current.repo = trimmed.slice('repo:'.length).trim()
36
+ } else if (trimmed.startsWith('description:')) {
37
+ let desc = trimmed.slice('description:'.length).trim()
38
+ desc = desc.replace(/^"|"$/g, '')
39
+ current.description = desc
40
+ } else if (trimmed.startsWith('tags:')) {
41
+ const raw = trimmed.slice('tags:'.length).trim().replace(/^\[|\]$/g, '')
42
+ current.tags = raw.split(',').map(s => s.trim()).filter(Boolean)
43
+ }
44
+ }
45
+ if (current) entries.push(current)
46
+ return entries
47
+ }
48
+
49
+ function matchesKeyword(entry, kw) {
50
+ const lkw = kw.toLowerCase()
51
+ if (entry.name.toLowerCase().includes(lkw)) return true
52
+ if (entry.description.toLowerCase().includes(lkw)) return true
53
+ for (const tag of entry.tags) {
54
+ if (tag.toLowerCase().includes(lkw)) return true
55
+ }
56
+ return false
57
+ }
58
+
8
59
  function pluginsDir() {
9
60
  return path.join(os.homedir(), '.trackfw', 'plugins')
10
61
  }
@@ -94,4 +145,26 @@ cmd.command('remove <name>')
94
145
  }
95
146
  })
96
147
 
148
+ cmd.command('search <keyword>')
149
+ .description('Search the plugin registry')
150
+ .action(async (keyword) => {
151
+ let entries
152
+ try {
153
+ const body = await fetchRegistry()
154
+ entries = parseRegistryYAML(body).filter(e => matchesKeyword(e, keyword))
155
+ } catch (err) {
156
+ console.log(`Registry unavailable: ${err.message}`)
157
+ return
158
+ }
159
+ if (entries.length === 0) {
160
+ console.log(`No plugins found for "${keyword}"`)
161
+ return
162
+ }
163
+ console.log(String('NAME').padEnd(30) + String('REPO').padEnd(30) + 'DESCRIPTION')
164
+ console.log('-'.repeat(90))
165
+ for (const e of entries) {
166
+ console.log(e.name.padEnd(30) + e.repo.padEnd(30) + e.description)
167
+ }
168
+ })
169
+
97
170
  module.exports = cmd
@@ -64,7 +64,7 @@ cmd.command('new <title>')
64
64
  cmd.command('list')
65
65
  .description(t('req.list.description'))
66
66
  .action(async () => {
67
- listREQs('docs/req')
67
+ listREQs(require('../config').load().reqDir)
68
68
  })
69
69
 
70
70
  module.exports = cmd
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
  const { Command } = require('commander')
3
- const { listRoadmaps, showRoadmap, moveRoadmap, newRoadmap } = require('../generators/roadmap')
3
+ const { listRoadmaps, showRoadmap, moveRoadmap, newRoadmap, newRoadmapFromReq } = require('../generators/roadmap')
4
4
  const { t } = require('../i18n')
5
5
 
6
6
  const cmd = new Command('roadmap')
@@ -10,7 +10,12 @@ cmd.command('new')
10
10
  .description(t('roadmap.new.description'))
11
11
  .option('-t, --title <title>', 'Roadmap title')
12
12
  .option('-r, --req <path>', 'Path to the linked REQ')
13
+ .option('--from-req <path>', 'Generate roadmap with ML stubs from REQ acceptance criteria')
13
14
  .action(async (opts) => {
15
+ if (opts.fromReq) {
16
+ newRoadmapFromReq(opts.fromReq)
17
+ return
18
+ }
14
19
  const title = opts.title || 'New Roadmap'
15
20
  const reqPath = opts.req || ''
16
21
  newRoadmap(title, reqPath)